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