Fix read-article handling across feed filter switches in article view

Article view marks the displayed article read in place (feed.read =
true) without removing it, so currentIndex stays valid while paging.
leaveArticleView() already dropped those afterward, but switching the
feed filter while still in article view never did — so an
already-read article could resurface (re-selecting its feed
re-projected it from allItems as if still unread). Extracted the drop
into a shared dropReadArticles() and call it from setFeedFilter() too.

Also, switching the filter while in article view immediately displays
the new feed's article in full, which is a genuine view — same as
paging or toggling into article view — but nothing marked it read, so
a single-article feed could never be marked read that way at all. This
was deliberately excluded (see commit 169c294) after a generic watch
proved too broad; setFeedFilter() now marks it explicitly, scoped to
article view only, so list view's scroll-driven read-marking is
unaffected.
This commit is contained in:
2026-09-12 19:42:22 +02:00
parent 41a17eba6d
commit c290513ed1
2 changed files with 80 additions and 7 deletions
+56 -2
View File
@@ -12,7 +12,7 @@ class FakeIntersectionObserver {
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('useFeeds', () => { 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(() => { beforeEach(() => {
localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-token', 'test-token')
@@ -24,7 +24,7 @@ describe('useFeeds', () => {
feedFilter.value = null feedFilter.value = null
showMessage.value = false showMessage.value = false
message.value = '' message.value = ''
showModal.value = false viewMode.value = 'list'
}) })
it('fetches and flattens articles for the current user', async () => { 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]) 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 () => { it('keeps an emptied filtered feed selected and selectable, showing "All caught up"', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse) axios.get.mockResolvedValueOnce(twoFeedsResponse)
axios.put.mockResolvedValue({ status: 200 }) axios.put.mockResolvedValue({ status: 200 })
+24 -5
View File
@@ -59,7 +59,6 @@ function refreshUnreadDisplay() {
displayedFeedUnreadCounts.value = feedUnreadCounts.value displayedFeedUnreadCounts.value = feedUnreadCounts.value
} }
const message = ref('') 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 viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
const currentIndex = ref(0) const currentIndex = ref(0)
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu 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() : 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 // Changes the active feed filter and re-projects. Mirrors toggleLayout's
// observer/scroll-safe pattern: disconnect first, scroll to top, then re-point // 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 // the observer at the new .observe nodes after Vue has re-rendered. Works in
@@ -316,9 +331,16 @@ function applyFilter() {
async function setFeedFilter(title) { async function setFeedFilter(title) {
disconnectObserver() disconnectObserver()
window.scrollTo(0, 0) window.scrollTo(0, 0)
dropReadArticles()
feedFilter.value = title // null for "All feeds" feedFilter.value = title // null for "All feeds"
currentIndex.value = 0 currentIndex.value = 0
applyFilter() 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() refreshUnreadDisplay()
await nextTick() await nextTick()
setupIntersectionObserver() setupIntersectionObserver()
@@ -515,9 +537,7 @@ async function leaveArticleView() {
// Articles paged past in article view were marked read but deliberately kept // 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 // in place so currentIndex stayed valid — drop them now so they don't keep
// showing up in the list view. // showing up in the list view.
feeds.value = feeds.value.filter(feed => !feed.read) dropReadArticles()
// Shared references — the paged-past objects carry .read on the master too.
allItems.value = allItems.value.filter(feed => !feed.read)
currentIndex.value = 0 currentIndex.value = 0
viewMode.value = 'list' viewMode.value = 'list'
localStorage.setItem('viewMode', viewMode.value) localStorage.setItem('viewMode', viewMode.value)
@@ -588,7 +608,6 @@ export function useFeeds() {
setFeedFilter, setFeedFilter,
showMessage, showMessage,
message, message,
showModal,
viewMode, viewMode,
currentIndex, currentIndex,
toggleViewMode, toggleViewMode,