diff --git a/vue/src/composables/__tests__/useFeeds.spec.js b/vue/src/composables/__tests__/useFeeds.spec.js index bf7b215..36879b9 100644 --- a/vue/src/composables/__tests__/useFeeds.spec.js +++ b/vue/src/composables/__tests__/useFeeds.spec.js @@ -12,7 +12,7 @@ class FakeIntersectionObserver { vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) describe('useFeeds', () => { - const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, markRead, setupIntersectionObserver } = useFeeds() + const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, viewMode, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, markRead, markCurrentArticleRead, setupIntersectionObserver } = useFeeds() beforeEach(() => { localStorage.setItem('user-token', 'test-token') @@ -24,7 +24,7 @@ describe('useFeeds', () => { feedFilter.value = null showMessage.value = false message.value = '' - showModal.value = false + viewMode.value = 'list' }) it('fetches and flattens articles for the current user', async () => { @@ -423,6 +423,60 @@ describe('useFeeds', () => { expect(feeds.value.map(f => f.id)).toEqual([2, 1]) }) + it('does not resurrect an article-view read-in-place article when re-selecting its feed', async () => { + axios.get.mockResolvedValueOnce(twoFeedsResponse) + axios.put.mockResolvedValue({ status: 200 }) + await fetchData() + + await setFeedFilter('Feed B') + expect(feeds.value.map(f => f.id)).toEqual([2]) + + // Article view marks the displayed article read in place (feed.read = + // true) without removing it, so currentIndex stays valid while paging — + // see markCurrentArticleRead. It's still in the list at this point. + markCurrentArticleRead() + expect(feeds.value.map(f => f.id)).toEqual([2]) + + // Switching to another feed and back must not resurrect it as if it + // were still unread. + await setFeedFilter('Feed A') + await setFeedFilter('Feed B') + + expect(feeds.value).toEqual([]) + }) + + it('marks the newly displayed article read when the filter is switched while in article view', async () => { + axios.get.mockResolvedValueOnce(twoFeedsResponse) + axios.put.mockResolvedValue({ status: 200 }) + await fetchData() + viewMode.value = 'article' + + // Switching into Feed B while in article view immediately displays its + // one article's full content — that counts as "viewed", so it's marked + // read the same way toggleViewMode()/nextArticle()/prevArticle() do. + await setFeedFilter('Feed B') + + expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything()) + expect(feeds.value[0].read).toBe(true) + + // It doesn't resurface either, once dropped on the next switch. + await setFeedFilter('Feed A') + await setFeedFilter('Feed B') + expect(feeds.value).toEqual([]) + }) + + it('does not mark the newly displayed article read when the filter is switched in list view', async () => { + axios.get.mockResolvedValueOnce(twoFeedsResponse) + await fetchData() + + // List view only shows titles — switching filters there must not mark + // anything read; that's still driven solely by scroll (handleIntersection). + await setFeedFilter('Feed B') + + expect(axios.put).not.toHaveBeenCalled() + expect(feeds.value[0].read).toBeUndefined() + }) + it('keeps an emptied filtered feed selected and selectable, showing "All caught up"', async () => { axios.get.mockResolvedValueOnce(twoFeedsResponse) axios.put.mockResolvedValue({ status: 200 }) diff --git a/vue/src/composables/useFeeds.js b/vue/src/composables/useFeeds.js index b81ddf3..2f4b8a0 100644 --- a/vue/src/composables/useFeeds.js +++ b/vue/src/composables/useFeeds.js @@ -59,7 +59,6 @@ function refreshUnreadDisplay() { displayedFeedUnreadCounts.value = feedUnreadCounts.value } const message = ref('') -const showModal = ref(false) const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device const currentIndex = ref(0) const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu @@ -308,6 +307,22 @@ function applyFilter() { : allItems.value.slice() } +// Article view marks an article read in place (feed.read = true) without +// removing it — see markCurrentArticleRead — so currentIndex stays valid while +// paging within that same list. Anything switches the list out from under +// that index (leaving article view, or re-filtering while still in it) has to +// drop those read-in-place articles from the master here, or they resurface: +// re-selecting their feed would otherwise re-project them from allItems as if +// still unread. +function dropReadArticles() { + // The feeds.value filter here is redundant for the setFeedFilter() caller + // (applyFilter() overwrites feeds.value again right after) but is what + // leaveArticleView() actually needs — kept shared rather than split into two + // near-identical branches. + feeds.value = feeds.value.filter(feed => !feed.read) + allItems.value = allItems.value.filter(feed => !feed.read) +} + // Changes the active feed filter and re-projects. Mirrors toggleLayout's // observer/scroll-safe pattern: disconnect first, scroll to top, then re-point // the observer at the new .observe nodes after Vue has re-rendered. Works in @@ -316,9 +331,16 @@ function applyFilter() { async function setFeedFilter(title) { disconnectObserver() window.scrollTo(0, 0) + dropReadArticles() feedFilter.value = title // null for "All feeds" currentIndex.value = 0 applyFilter() + // In article view the item at currentIndex is fully displayed, not just + // listed — unlike a plain currentIndex reset from paging, this one really is + // "shown" to the user, so mark it the same way toggleViewMode()/nextArticle()/ + // prevArticle() do. List view only shows titles here; its own read-marking + // happens via scroll (handleIntersection), so this must not fire there. + if (viewMode.value === 'article') markCurrentArticleRead() refreshUnreadDisplay() await nextTick() setupIntersectionObserver() @@ -515,9 +537,7 @@ async function leaveArticleView() { // Articles paged past in article view were marked read but deliberately kept // in place so currentIndex stayed valid — drop them now so they don't keep // showing up in the list view. - feeds.value = feeds.value.filter(feed => !feed.read) - // Shared references — the paged-past objects carry .read on the master too. - allItems.value = allItems.value.filter(feed => !feed.read) + dropReadArticles() currentIndex.value = 0 viewMode.value = 'list' localStorage.setItem('viewMode', viewMode.value) @@ -588,7 +608,6 @@ export function useFeeds() { setFeedFilter, showMessage, message, - showModal, viewMode, currentIndex, toggleViewMode,