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:
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user