Author SHA1 Message Date
mathias 81f5f0f722 Update dependencies and fix audit findings
Backend:
- cargo update: rustls 0.23.40 -> 0.23.45 (RUSTSEC-2026-0285), plus
  semver-compatible bumps (actix-web 4.15, tokio 1.53, diesel 2.3.13, ...)
- Disable actix-web's `http2` feature: the server binds plain HTTP/1.1,
  and the feature pulls in the unmaintained h2 0.3 line (RUSTSEC-2026-0258)

Frontend:
- Update all packages within their ranges, fixing 8 npm audit findings
  (nanoid, postcss, undici, ...)
- vitest 4 -> 5
- Remove unused unreadCount in RssFeeds.vue (lint error)
2026-09-27 11:41:31 +02:00
mathias 43269bf1d7 Merge pull request 'Replace the feed select with a multi-feed filter modal' (#4) from feature/feed-filter-modal into master
Reviewed-on: #4
2026-09-27 11:31:27 +02:00
mathias 5ed780baa3 Replace the feed select with a multi-feed filter modal
The header's feed <select> is now a funnel button that opens a modal with
an "All feeds" checkbox and one checkbox per feed with unread items. The
selection is either "All feeds" or at least one feed, is applied once when
the modal closes, and is persisted in localStorage.

- feedFilter holds string[] | null; setFeedFilter is a no-op when the
  selection is unchanged (order-insensitive), sparing callers the
  scroll/mark-read side effects.
- Selected feeds stay listed at 0 unread so they can be unchecked; titles
  whose feed no longer exists are pruned after each fetch.
- The persisted filter is cleared on logout and on 401 (handler extracted
  to httpAuthInterceptor.js so it is unit-testable).
- The modal locks body scroll while open so scrolling behind it can't
  mark articles read.
2026-09-27 11:28:48 +02:00
mathias 8f7ddb77c4 Stop reserving layout space for the idle sync spinner
visibility:hidden still occupied the header's flex row, squeezing the
title on narrow screens and clipping the unread counter. Render the
spinner with v-if instead so it takes no space when not syncing.
2026-09-25 13:55:23 +02:00
mathias e51bce4244 added pwa ap manifest 2026-09-23 17:57:44 +02:00
mathias f31fccdd16 fix break header 2026-09-14 19:59:22 +02:00
mathias a7cfd267c6 Strip Al Jazeera's Recommended Stories section from readable articles
Al Jazeera's article pages embed a <section class="more-on"> block (a
'Recommended Stories' heading plus a list of unrelated article teasers)
that was getting pulled into the parsed content by Readability. Remove
it from the fetched DOM before Readability runs, same as the existing
social-bar/abo/teaser stripping in getReadable().
2026-09-14 18:40:34 +02:00
mathias c584ac4f60 Merge pull request 'Show a sync-in-progress spinner in the header next to the feed filter' (#3) from feature/sync-spinner into master
Reviewed-on: #3
2026-09-14 18:08:44 +02:00
mathias 9c042411fb Show a sync-in-progress spinner in the header next to the feed filter
activeSyncCount/isSyncing() in useFeeds.js already refcounted both boot
reload (fetchData) and manual sync() but wasn't reactive. Make it a ref
+ computed and expose it so AppNav can render a small spinner next to
the feed filter whenever a sync is in flight.

The spinner sits outside the filter's own v-if so it also shows during
a manual sync from pages other than /feeds, and its box is always
reserved (visibility toggle, not v-if) so the one-time --app-nav-height
measurement stays correct.
2026-09-14 18:05:24 +02:00
mathias 4d9e899519 Disable mark-as-read while a sync or reload is in progress
useFeeds.js had no concept of a sync/reload being in flight, so two
passive mark-as-read paths could race a wholesale fetchData()/sync()
list replacement:

- handleIntersection() (list-view scroll marking)
- markCurrentArticleRead() (article-view display/paging marking)

Both are now gated by a refcount (activeSyncCount/beginSync/endSync/
isSyncing), mirroring the existing pendingReadCounts pattern for the
same class of overlapping-async problem. A refcount rather than a
boolean because sync() nests fetchData() inside it, and fetchData()
can also run independently while a sync() elsewhere is still in
flight.

Reads suppressed during a sync/reload aren't dropped: their ids are
queued and flushed (marked read, and swept out of the list) once
every in-flight fetchData()/sync() call has finished. The explicit
"Mark all read" action is left ungated, since it's a deliberate,
already-confirmed action rather than a passive side effect.

87/87 frontend tests passing.
2026-09-13 12:11:14 +02:00
mathias c290513ed1 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.
2026-09-12 19:42:22 +02:00
mathias 41a17eba6d Move Add Feed button from hamburger menu to admin area
The Add RSS modal is now opened from a button directly above the feed
list in AdminFeeds.vue instead of the hamburger menu, so feed creation
lives alongside the rest of feed management. AppNav.vue no longer
renders it.

AddUrl.vue emits an `added` event on a successful save; AdminFeeds.vue
listens for it and reloads its feed list, so a newly added feed shows
up immediately without leaving the admin page.

Also fixes a bug this move surfaced: the modal's "Close" button had no
`type`, so inside the form it defaulted to type="submit" — clicking
Close on a filled-in form silently created the feed.
2026-09-12 19:42:08 +02:00
mathiasandClaude Sonnet 5 169c294e62 Mark article read on display in article view, including on boot
markCurrentArticleRead() already marked the displayed article read
immediately at each deliberate navigation point (entering article view,
paging next/prev), but reloading the page while viewMode was already
'article' (persisted in localStorage) booted straight into article view
without marking anything read — the article on screen stayed unread
until the user paged forward/back at least once.

Close that gap with a single explicit call in RssFeeds.vue's onMounted:
once the initial fetch resolves, if we're already in article view, mark
the now-displayed article read the same way toggleViewMode()/
nextArticle()/prevArticle() do.

(A first attempt replaced the explicit call sites with a generic watch
on the displayed article's id, but that also fired on unrelated changes
to it — e.g. switching the feed filter while in article view resets
currentIndex and reprojects feeds, so the watch would silently mark an
article the user never viewed as read. Reverted to the explicit,
call-site-scoped approach and added only the one boot-time call needed.)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2sYVA7rh7N5RYmcCd2KDV
2026-09-12 15:29:32 +02:00
mathias e2df60d112 Merge pull request 'Fix unread counter stuck after reading all articles' (#2) from hotfix/unread-counter-stuck into master
Reviewed-on: #2
2026-09-11 17:22:28 +02:00
25 changed files with 3725 additions and 2758 deletions
Generated
+604 -704
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -10,7 +10,12 @@ anyhow = "1"
reqwest = { version = "0.13", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] }
rss = { version = "2.0.13" }
actix-web = "4.13"
# `http2` is left out: the server binds plain HTTP/1.1 (no TLS/h2c), and the
# feature pulls in the unmaintained h2 0.3 line (RUSTSEC-2026-0258).
actix-web = { version = "4.15", default-features = false, features = [
"macros", "compress-brotli", "compress-gzip", "compress-zstd",
"cookies", "unicode", "compat", "ws",
] }
actix-rt = "2.10"
futures = "0.3.31"
serde = { version = "1.0.228", features = ["alloc", "derive", "serde_derive"] }
+3
View File
@@ -5,6 +5,9 @@
<meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="alternate icon" href="/favicon.ico">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icon-192.png">
<meta name="theme-color" content="#1a8f5e">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RSS-Reader</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
+1127 -1897
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -26,6 +26,6 @@
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"vite": "^8.0.16",
"vitest": "^4.1.8"
"vitest": "^5.0.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

+20
View File
@@ -0,0 +1,20 @@
{
"name": "RSS Reader",
"short_name": "RSS Reader",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1a8f5e",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useFeeds } from '../composables/useFeeds'
// router/index.js's route definitions lazily import(...) the view components,
// which importing the real module would still be safe to do (the imports
// themselves aren't eagerly evaluated) — but mocking it keeps this test from
// depending on the app's actual routes and lets currentRoute/push be
// controlled directly.
vi.mock('../router', () => ({
default: {
currentRoute: { value: { name: 'feeds' } },
push: vi.fn(),
},
}))
import router from '../router'
import { handleUnauthorizedResponse } from '../httpAuthInterceptor'
describe('handleUnauthorizedResponse', () => {
const { feedFilter } = useFeeds()
beforeEach(() => {
localStorage.setItem('user-token', 'abc123')
localStorage.setItem('user-id', '7')
localStorage.setItem('feedFilter', JSON.stringify(['Feed A']))
feedFilter.value = ['Feed A']
router.currentRoute.value.name = 'feeds'
router.push.mockClear()
})
it('clears the session and the feed filter, and redirects to login, on a 401 from a non-login route', async () => {
const error = { response: { status: 401 } }
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
expect(localStorage.getItem('user-token')).toBeNull()
expect(localStorage.getItem('user-id')).toBeNull()
expect(localStorage.getItem('feedFilter')).toBeNull()
expect(feedFilter.value).toBeNull()
expect(router.push).toHaveBeenCalledWith({ name: 'login' })
})
it('still clears the session on a 401 but does not push again when already on the login route', async () => {
router.currentRoute.value.name = 'login'
const error = { response: { status: 401 } }
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
expect(localStorage.getItem('user-token')).toBeNull()
expect(router.push).not.toHaveBeenCalled()
})
it('leaves the session and filter untouched, and still rejects, on a non-401 error response', async () => {
const error = { response: { status: 500 } }
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
expect(localStorage.getItem('user-token')).toBe('abc123')
expect(localStorage.getItem('feedFilter')).toBe(JSON.stringify(['Feed A']))
expect(feedFilter.value).toEqual(['Feed A'])
expect(router.push).not.toHaveBeenCalled()
})
it('leaves the session untouched and still rejects on an error with no response (e.g. a network error)', async () => {
const error = new Error('network')
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
expect(localStorage.getItem('user-token')).toBe('abc123')
expect(router.push).not.toHaveBeenCalled()
})
})
+29
View File
@@ -1,7 +1,9 @@
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
import Modal from './modal/AddUrl.vue'
const showModal = ref(false)
const feeds = ref([])
const error = ref('')
@@ -40,6 +42,7 @@ onMounted(loadFeeds)
<template>
<div class="admin">
<h1 class="admin__heading">Admin</h1>
<button class="admin__add" type="button" @click="showModal = true">Add RSS</button>
<p v-if="error" class="admin__error">{{ error }}</p>
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
<ul v-else class="admin__list">
@@ -51,6 +54,14 @@ onMounted(loadFeeds)
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
</li>
</ul>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false" @added="loadFeeds">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</div>
</template>
@@ -67,6 +78,24 @@ onMounted(loadFeeds)
margin-bottom: 1.25rem;
}
.admin__add {
display: inline-flex;
align-items: center;
min-height: 44px;
margin-bottom: 1.25rem;
padding: 0.5rem 1.1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.admin__add:hover {
border-color: var(--color-border-hover);
}
.admin__error,
.admin__empty {
opacity: 0.6;
+90 -47
View File
@@ -2,30 +2,27 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
import Modal from './modal/AddUrl.vue'
import FeedFilter from './modal/FeedFilter.vue'
const router = useRouter()
const route = useRoute()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds()
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
const headerRef = ref(null)
const filterBtnRef = ref(null)
const filterModalOpen = ref(false)
// Native <select>/<option> has no "is open" state — the closed box just shows
// the selected option's text verbatim, so per-option unread counts can only be
// toggled based on focus, since the popup is only ever shown while focused.
// @focus ~ opening (shows counts); @change (picking an option closes the
// popup but keeps focus, so no blur fires) and @blur (focus left without
// picking) ~ closed (counts hidden, so the closed box just shows the name).
const filterOpen = ref(false)
function onFilterFocus() {
filterOpen.value = true
function openFilterModal() {
refreshUnreadDisplay()
filterModalOpen.value = true
}
function onFilterChange(event) {
setFeedFilter(event.target.value || null)
filterOpen.value = false
// setFeedFilter() itself no-ops (order-insensitively) an unchanged selection,
// so there's nothing to check here before applying it.
async function handleFilterClose(selection) {
filterModalOpen.value = false
await setFeedFilter(selection)
filterBtnRef.value?.focus()
}
// Scroll-driven show/hide: the header slides out of view on scroll-down and
@@ -147,11 +144,6 @@ function handleMarkAllRead() {
closeMenu()
}
function openAddModal() {
showModal.value = true
closeMenu()
}
function handleToggleViewMode() {
toggleViewMode()
closeMenu()
@@ -167,18 +159,36 @@ function handleToggleLayout() {
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
<div class="app-nav__wrapper">
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
<select
<span
v-if="isSyncing"
class="app-nav__sync-spinner"
aria-hidden="true"
></span>
<button
v-if="onFeedsPage && feedTitles.length"
class="app-nav__filter"
:value="feedFilter ?? ''"
aria-label="Filter by feed"
@focus="onFilterFocus"
@change="onFilterChange"
@blur="filterOpen = false"
ref="filterBtnRef"
class="app-nav__filter-btn"
:class="{ 'app-nav__filter-btn--active': feedFilter !== null }"
type="button"
aria-haspopup="dialog"
:aria-expanded="filterModalOpen"
:aria-label="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
:title="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
@click="openFilterModal"
>
<option value="">All feeds</option>
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}<template v-if="filterOpen"> ({{ displayedFeedUnreadCounts[title] ?? 0 }})</template></option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>
</button>
<Teleport to="body">
<FeedFilter
:show="filterModalOpen"
:titles="feedTitles"
:counts="displayedFeedUnreadCounts"
:selected="feedFilter"
@close="handleFilterClose"
/>
</Teleport>
<button
class="app-nav__hamburger"
type="button"
@@ -212,20 +222,11 @@ function handleToggleLayout() {
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
</template>
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
</div>
</nav>
</Transition>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</header>
</template>
@@ -248,7 +249,7 @@ function handleToggleLayout() {
.app-nav__wrapper {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
@@ -256,7 +257,11 @@ function handleToggleLayout() {
}
.app-nav__title {
margin-right: auto;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: bold;
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
}
@@ -266,27 +271,65 @@ function handleToggleLayout() {
opacity: 0.6;
}
.app-nav__filter {
.app-nav__sync-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-text);
border-radius: 50%;
opacity: 0.6;
flex-shrink: 0;
animation: app-nav-spin 0.8s linear infinite;
}
@keyframes app-nav-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.app-nav__sync-spinner {
animation: none;
}
}
.app-nav__filter-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
min-height: 44px;
max-width: clamp(120px, 40vw, 220px);
padding: 0.25rem 0.5rem;
min-width: 44px;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
font: inherit;
cursor: pointer;
text-overflow: ellipsis;
}
.app-nav__filter:hover {
.app-nav__filter-btn:hover {
border-color: var(--color-border-hover);
}
.app-nav__filter-btn--active::after {
content: '';
position: absolute;
top: 6px;
right: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-accent);
}
.app-nav__hamburger {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-height: 44px;
min-width: 44px;
padding: 0.5rem;
+7 -3
View File
@@ -1,5 +1,5 @@
<script setup>
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import { onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds';
const {
@@ -17,10 +17,9 @@ const {
disconnectObserver,
setInitialLoad,
showMessageForXSeconds,
markCurrentArticleRead,
} = useFeeds()
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
const shareLabel = navigator.share ? 'Share' : 'Copy link'
function scrollToNextArticle() {
@@ -122,6 +121,11 @@ onBeforeUnmount(() => {
onMounted(async () => {
setInitialLoad(false)
await fetchData()
// viewMode persists across reloads (see useFeeds.js) — if we're booting
// straight into article view, the article now on screen needs the same
// immediate mark-read that toggleViewMode()/nextArticle()/prevArticle()
// give it when reached by in-session navigation.
if (viewMode.value === 'article') markCurrentArticleRead()
sync(true)
setTimeout(function () {
setInitialLoad(true)
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import axios from 'axios'
import AdminFeeds from '../AdminFeeds.vue'
import AddUrl from '../modal/AddUrl.vue'
vi.mock('axios')
describe('AdminFeeds', () => {
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
})
// Each mount renders the AddUrl modal via <Teleport to="body">, which
// survives outside the wrapper's own element — unmount every mounted
// instance so a leftover doesn't bleed into a later test's document.body.
let mountedWrappers = []
function mountAdmin() {
const wrapper = mount(AdminFeeds)
mountedWrappers.push(wrapper)
return wrapper
}
afterEach(() => {
for (const wrapper of mountedWrappers) {
try {
wrapper.unmount()
} catch {
// already unmounted by the test itself — fine
}
}
mountedWrappers = []
})
it('fetches and renders the feed list on mount', async () => {
axios.get.mockResolvedValueOnce({
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
})
const wrapper = mountAdmin()
await flushPromises()
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/feeds/7', expect.anything())
expect(wrapper.text()).toContain('Feed A')
})
it('shows the empty state when there are no feeds', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mountAdmin()
await flushPromises()
expect(wrapper.text()).toContain('No feeds added yet.')
})
it('shows an error message when the feed list fails to load', async () => {
axios.get.mockRejectedValueOnce(new Error('Network Error'))
const wrapper = mountAdmin()
await flushPromises()
expect(wrapper.text()).toContain('Failed to load feeds.')
})
it('deletes a feed after confirmation', async () => {
axios.get.mockResolvedValueOnce({
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
})
axios.delete.mockResolvedValueOnce({ status: 204 })
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const wrapper = mountAdmin()
await flushPromises()
await wrapper.find('.admin__delete').trigger('click')
await flushPromises()
expect(axios.delete).toHaveBeenCalledWith('/api/v1/article/feed/1', expect.anything())
expect(wrapper.text()).toContain('No feeds added yet.')
confirmSpy.mockRestore()
})
it('opens the add-feed modal from the Add RSS button', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mountAdmin()
await flushPromises()
await wrapper.find('.admin__add').trigger('click')
expect(wrapper.findComponent(AddUrl).props('show')).toBe(true)
})
it('reloads the feed list when the modal reports a feed was added', async () => {
axios.get
.mockResolvedValueOnce({ data: { feeds: [] } })
.mockResolvedValueOnce({ data: { feeds: [{ id: 2, title: 'Feed B', url: 'https://example.test/b.xml' }] } })
const wrapper = mountAdmin()
await flushPromises()
expect(wrapper.text()).toContain('No feeds added yet.')
await wrapper.findComponent(AddUrl).vm.$emit('added')
await flushPromises()
expect(axios.get).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('Feed B')
})
})
+182 -45
View File
@@ -4,6 +4,7 @@ import { nextTick } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import axios from 'axios'
import AppNav from '../AppNav.vue'
import FeedFilter from '../modal/FeedFilter.vue'
import { useFeeds } from '../../composables/useFeeds'
vi.mock('axios')
@@ -25,7 +26,7 @@ describe('AppNav', () => {
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
feeds.value = []
allItems.value = []
feedFilter.value = null
@@ -34,7 +35,6 @@ describe('AppNav', () => {
lastProgrammaticScroll.value = 0
showMessage.value = false
message.value = ''
showModal.value = false
viewMode.value = 'list'
currentIndex.value = 0
layout.value = 'list'
@@ -130,16 +130,6 @@ describe('AppNav', () => {
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('opens the add-feed modal from the menu', async () => {
const wrapper = await mountWithMenuOpen()
const { showModal } = useFeeds()
const addButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Add RSS')
await addButton.trigger('click')
expect(showModal.value).toBe(true)
})
it('switches the view mode from the menu and closes it', async () => {
const wrapper = await mountWithMenuOpen()
const { viewMode } = useFeeds()
@@ -229,7 +219,7 @@ describe('AppNav', () => {
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
})
it('renders a feed filter with an "All feeds" option plus one per distinct feed', async () => {
it('opens a feed filter modal listing "All feeds" plus one entry per distinct feed', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed B', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-02' },
@@ -239,41 +229,21 @@ describe('AppNav', () => {
const wrapper = mountNav()
await flushPromises()
const select = wrapper.find('.app-nav__filter')
// Counts only show while the dropdown is open (focused) — see AppNav.vue.
await select.trigger('focus')
await wrapper.find('.app-nav__filter-btn').trigger('click')
const options = select.findAll('option').map(o => o.text())
// "All feeds" first, then distinct titles sorted alphabetically, each with
// its unread count.
expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)'])
})
it('hides the per-option unread count once the feed filter is closed', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
const wrapper = mountNav()
await flushPromises()
const select = wrapper.find('.app-nav__filter')
await select.trigger('focus')
await select.setValue('Feed A') // fires change, which closes the popup but keeps focus
await flushPromises()
const feedAOption = select.findAll('option').find(o => o.element.value === 'Feed A')
expect(feedAOption.text()).toBe('Feed A')
const modal = wrapper.findComponent(FeedFilter)
expect(modal.props('show')).toBe(true)
// Distinct titles, sorted alphabetically, with per-feed unread counts.
expect(modal.props('titles')).toEqual(['Feed A', 'Feed B'])
expect(modal.props('counts')).toEqual({ 'Feed A': 2, 'Feed B': 1 })
})
it('does not render the feed filter when there are no feeds', async () => {
const wrapper = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__filter').exists()).toBe(false)
expect(wrapper.find('.app-nav__filter-btn').exists()).toBe(false)
})
it('applies the selected feed to the displayed list, and "All feeds" restores it', async () => {
@@ -286,14 +256,16 @@ describe('AppNav', () => {
const wrapper = mountNav()
await flushPromises()
const select = wrapper.find('.app-nav__filter')
await select.setValue('Feed A')
await wrapper.find('.app-nav__filter-btn').trigger('click')
const modal = wrapper.findComponent(FeedFilter)
modal.vm.$emit('close', ['Feed A'])
await flushPromises()
expect(feedFilter.value).toBe('Feed A')
expect(feedFilter.value).toEqual(['Feed A'])
expect(feeds.value.map(f => f.id)).toEqual([1])
await select.setValue('')
await wrapper.find('.app-nav__filter-btn').trigger('click')
modal.vm.$emit('close', null)
await flushPromises()
expect(feedFilter.value).toBeNull()
@@ -314,13 +286,178 @@ describe('AppNav', () => {
// No filter → global total (3).
expect(wrapper.find('.app-nav__title').text()).toContain('(3)')
await wrapper.find('.app-nav__filter').setValue('Feed A')
await wrapper.find('.app-nav__filter-btn').trigger('click')
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed A'])
await flushPromises()
// Filtered → unread in Feed A only (2).
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
})
it('sums the unread count across every selected feed in the title badge', async () => {
const { allItems, feedFilter } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, feedTitle: 'Feed B', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
{ id: 3, feedTitle: 'Feed C', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
]
feedFilter.value = ['Feed A', 'Feed B']
const wrapper = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
})
it('marks the filter button active and swaps its label/title when a filter is set', async () => {
const { allItems, feedFilter } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const wrapper = mountNav()
await flushPromises()
const button = wrapper.find('.app-nav__filter-btn')
expect(button.classes()).not.toContain('app-nav__filter-btn--active')
expect(button.attributes('aria-label')).toBe('Filter feeds')
expect(button.attributes('title')).toBe('Filter feeds')
feedFilter.value = ['Feed A']
await nextTick()
expect(button.classes()).toContain('app-nav__filter-btn--active')
expect(button.attributes('aria-label')).toBe('Filter feeds (active)')
expect(button.attributes('title')).toBe('Filter feeds (active)')
})
it('tracks the modal\'s open state via aria-expanded on the filter button', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const wrapper = mountNav()
await flushPromises()
const button = wrapper.find('.app-nav__filter-btn')
expect(button.attributes('aria-expanded')).toBe('false')
await button.trigger('click')
expect(button.attributes('aria-expanded')).toBe('true')
wrapper.findComponent(FeedFilter).vm.$emit('close', null)
await flushPromises()
expect(button.attributes('aria-expanded')).toBe('false')
})
it('does not re-apply the filter when the emitted selection is order-insensitively unchanged', async () => {
const { allItems, feedFilter } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, feedTitle: 'Feed B', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
feedFilter.value = ['Feed A', 'Feed B']
const wrapper = mountNav()
await flushPromises()
const scrollToSpy = vi.spyOn(window, 'scrollTo')
await wrapper.find('.app-nav__filter-btn').trigger('click')
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed B', 'Feed A'])
await flushPromises()
// setFeedFilter() always scrollTo(0, 0)s — its absence proves the
// no-op path (sameFeedSelection) was taken instead.
expect(scrollToSpy).not.toHaveBeenCalled()
expect(feedFilter.value).toEqual(['Feed A', 'Feed B'])
})
it('does not re-apply the filter when null is emitted while already unfiltered', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const wrapper = mountNav()
await flushPromises()
const scrollToSpy = vi.spyOn(window, 'scrollTo')
await wrapper.find('.app-nav__filter-btn').trigger('click')
wrapper.findComponent(FeedFilter).vm.$emit('close', null)
await flushPromises()
expect(scrollToSpy).not.toHaveBeenCalled()
})
it('returns focus to the filter button once the modal closes', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const wrapper = mountNav({ attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
const button = wrapper.find('.app-nav__filter-btn')
await button.trigger('click')
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed A'])
await flushPromises()
expect(document.activeElement).toBe(button.element)
})
describe('sync spinner', () => {
it('does not render the spinner, and reserves no space for it, when idle', async () => {
const wrapper = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(false)
})
it('renders while a sync triggered through the composable is in flight, and disappears once it resolves', async () => {
const wrapper = mountNav()
await flushPromises()
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const { sync } = useFeeds()
const syncPromise = sync(true)
await nextTick()
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
await nextTick()
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(false)
})
it('renders while syncing even on a page where the feed filter itself does not render', async () => {
await router.push('/login')
await router.isReady()
const wrapper = mountNav()
await flushPromises()
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const { sync } = useFeeds()
const syncPromise = sync(true)
await nextTick()
expect(wrapper.find('.app-nav__filter-btn').exists()).toBe(false)
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
})
})
describe('scroll-driven show/hide', () => {
// The scroll handler is rAF-throttled; run rAF synchronously so a single
// dispatched scroll event resolves before we assert. Per the CLAUDE.md
@@ -37,11 +37,10 @@ describe('RssFeeds', () => {
// useFeeds() returns module-level singleton refs shared across the whole
// app (and this spec file) — reset them so state doesn't leak between tests.
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
const { feeds, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
feeds.value = []
showMessage.value = false
message.value = ''
showModal.value = false
viewMode.value = 'list'
currentIndex.value = 0
layout.value = 'list'
+4 -2
View File
@@ -2,9 +2,10 @@
import '@/assets/modal.css';
import { ref } from 'vue';
import axios from 'axios';
const props = defineProps({
defineProps({
show: Boolean
})
const emit = defineEmits(['close', 'added'])
const submitted = ref(false)
const url = ref('')
const title = ref('')
@@ -30,6 +31,7 @@ async function save() {
console.log(response)
output.value = 'saved successfully'
emit('added')
} catch (error) {
console.error(error.message)
output.value = error.message
@@ -57,7 +59,7 @@ async function save() {
<div class="modal-footer">
<slot name="footer">
<button type="submit">Save</button>
<button class="modal-default-button" @click="$emit('close')">Close</button>
<button class="modal-default-button" type="button" @click="$emit('close')">Close</button>
</slot>
</div>
</form>
+145
View File
@@ -0,0 +1,145 @@
<script setup>
import '@/assets/modal.css';
import { ref, watch, onUnmounted, nextTick } from 'vue'
const props = defineProps({
show: Boolean,
titles: { type: Array, default: () => [] },
counts: { type: Object, default: () => ({}) },
selected: { type: Array, default: null },
})
const emit = defineEmits(['close'])
// Component-local draft: seeded from `selected` each time the modal opens
// (including on initial mount if it's already open — see the `immediate`
// watcher below) and only reported back (via `close`) once it's dismissed —
// see AppNav.vue for how the emitted selection is applied.
const draft = ref(null)
const allFeedsCheckboxRef = ref(null)
function isChecked(title) {
return draft.value !== null && draft.value.includes(title)
}
function onToggleAll(event) {
if (event.target.checked) {
draft.value = null
} else {
// The selection is never allowed to be empty — "All feeds" is the
// fallback state, so it can't be unchecked without checking a feed first.
event.target.checked = true
}
}
function onToggleFeed(title, checked) {
if (checked) {
draft.value = draft.value === null ? [title] : [...draft.value, title]
} else {
const remaining = (draft.value ?? []).filter(t => t !== title)
// Unchecking the last selected feed falls back to "All feeds" — see
// onToggleAll, the selection is never allowed to be empty.
draft.value = remaining.length > 0 ? remaining : null
}
}
function handleClose() {
emit('close', draft.value)
}
function onKeydown(event) {
if (event.key === 'Escape') handleClose()
}
// While the modal is open, scrolling the page behind it would run through
// list view's IntersectionObserver and mark articles read out from under the
// user — lock it, and restore whatever it was before. `''` is a legitimate
// saved overflow value, so `isLocked` (not a null-check on the saved value)
// tracks whether there's actually anything to restore — otherwise unlocking
// on unmount without ever having locked would stomp a value this component
// never touched.
let previousBodyOverflow = ''
let isLocked = false
function lockBodyScroll() {
previousBodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
isLocked = true
}
function unlockBodyScroll() {
if (!isLocked) return
document.body.style.overflow = previousBodyOverflow
isLocked = false
}
watch(() => props.show, async (isOpen) => {
if (isOpen) {
draft.value = props.selected ? [...props.selected] : null
lockBodyScroll()
window.addEventListener('keydown', onKeydown)
await nextTick()
allFeedsCheckboxRef.value?.focus()
} else {
unlockBodyScroll()
window.removeEventListener('keydown', onKeydown)
}
}, { immediate: true })
onUnmounted(() => {
unlockBodyScroll()
window.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<Transition name="modal">
<div v-if="show" class="modal-mask" @click.self="handleClose">
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="feed-filter-heading">
<div class="modal-header">
<h3 id="feed-filter-heading">Filter feeds</h3>
</div>
<div class="modal-body">
<label class="feed-filter__option">
<input
ref="allFeedsCheckboxRef"
type="checkbox"
:checked="draft === null"
@change="onToggleAll"
/>
All feeds
</label>
<label v-for="title in titles" :key="title" class="feed-filter__option">
<input
type="checkbox"
:checked="isChecked(title)"
@change="onToggleFeed(title, $event.target.checked)"
/>
{{ title }} ({{ counts[title] ?? 0 }})
</label>
</div>
<div class="modal-footer">
<button type="button" @click="handleClose">Done</button>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.feed-filter__option {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
cursor: pointer;
}
/* modal.css's global `input` rule (display: block, width: 100%, min-height:
44px, ...) is meant for the text inputs in AddUrl.vue's form — reset it
here so checkboxes don't render full-width and oversized. */
.feed-filter__option input[type='checkbox'] {
display: inline-block;
width: auto;
min-height: 0;
margin: 0;
padding: 0;
}
</style>
@@ -27,6 +27,7 @@ describe('AddUrl', () => {
expect.anything(),
)
expect(wrapper.text()).toContain('saved successfully')
expect(wrapper.emitted('added')).toHaveLength(1)
})
it('surfaces the error message when the request fails', async () => {
@@ -39,5 +40,17 @@ describe('AddUrl', () => {
await flushPromises()
expect(wrapper.text()).toContain('Network Error')
expect(wrapper.emitted('added')).toBeUndefined()
})
it('closes without submitting when Close is clicked, even with fields filled in', async () => {
const wrapper = mount(AddUrl, { props: { show: true } })
await wrapper.find('#url').setValue('https://example.test/feed.xml')
await wrapper.find('#title').setValue('Example feed')
await wrapper.find('.modal-default-button').trigger('click')
expect(axios.post).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toHaveLength(1)
})
})
@@ -0,0 +1,246 @@
import { describe, it, expect, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import FeedFilter from '../FeedFilter.vue'
describe('FeedFilter', () => {
const previousBodyOverflow = document.body.style.overflow
afterEach(() => {
document.body.style.overflow = previousBodyOverflow
})
it('renders nothing when show is false', () => {
const wrapper = mount(FeedFilter, {
props: { show: false, titles: ['Feed A'], counts: { 'Feed A': 2 }, selected: null },
})
expect(wrapper.find('.modal-mask').exists()).toBe(false)
})
it('renders "All feeds" plus one checkbox per title, defaulting a missing count to 0', () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: { 'Feed A': 2 }, selected: null },
})
const options = wrapper.findAll('.feed-filter__option')
expect(options).toHaveLength(3)
expect(options[0].text()).toBe('All feeds')
expect(options[1].text()).toBe('Feed A (2)')
expect(options[2].text()).toBe('Feed B (0)')
})
it('checks "All feeds" and leaves every feed unchecked (but enabled) when nothing is selected', () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
})
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes[0].element.checked).toBe(true)
expect(checkboxes[1].element.checked).toBe(false)
expect(checkboxes[1].element.disabled).toBe(false)
expect(checkboxes[2].element.checked).toBe(false)
})
it('checking a feed unchecks "All feeds"', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
})
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[1].setValue(true)
expect(checkboxes[0].element.checked).toBe(false)
expect(checkboxes[1].element.checked).toBe(true)
expect(checkboxes[2].element.checked).toBe(false)
})
it('checking a second feed keeps both checked, with "All feeds" still unchecked', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
})
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[2].setValue(true)
expect(checkboxes[0].element.checked).toBe(false)
expect(checkboxes[1].element.checked).toBe(true)
expect(checkboxes[2].element.checked).toBe(true)
})
it('unchecking the last selected feed falls back to "All feeds"', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
})
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[1].setValue(false)
expect(checkboxes[0].element.checked).toBe(true)
expect(checkboxes[1].element.checked).toBe(false)
})
it('checking "All feeds" clears every selected feed', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A'], counts: {}, selected: ['Feed A'] },
})
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes[0].element.checked).toBe(false)
expect(checkboxes[1].element.checked).toBe(true)
await checkboxes[0].setValue(true)
expect(checkboxes[0].element.checked).toBe(true)
expect(checkboxes[1].element.checked).toBe(false)
})
it('unchecking "All feeds" while it is active leaves it checked — the selection is never empty', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
})
const allCheckbox = wrapper.findAll('input[type="checkbox"]')[0]
await allCheckbox.setValue(false)
expect(allCheckbox.element.checked).toBe(true)
})
it('emits close with the current draft when Done is clicked', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
})
await wrapper.findAll('input[type="checkbox"]')[1].setValue(true)
await wrapper.find('.modal-footer button').trigger('click')
expect(wrapper.emitted('close')).toEqual([[['Feed A']]])
})
it('emits close with the current draft on Escape, but not again once closed', async () => {
const wrapper = mount(FeedFilter, {
props: { show: false, titles: ['Feed A'], counts: {}, selected: null },
})
await wrapper.setProps({ show: true })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(wrapper.emitted('close')).toEqual([[null]])
await wrapper.setProps({ show: false })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('emits close on a backdrop click, but not on a click inside the modal container', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
})
await wrapper.find('.modal-header').trigger('click')
expect(wrapper.emitted('close')).toBeUndefined()
await wrapper.find('.modal-mask').trigger('click')
expect(wrapper.emitted('close')).toEqual([[null]])
})
it('reseeds the draft from `selected` on reopen, discarding an abandoned edit', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
})
// Edit the draft without confirming via Done/close.
await wrapper.findAll('input[type="checkbox"]')[2].setValue(true)
expect(wrapper.findAll('input[type="checkbox"]')[2].element.checked).toBe(true)
await wrapper.setProps({ show: false })
await wrapper.setProps({ show: true })
await nextTick()
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes[1].element.checked).toBe(true) // Feed A, from `selected`
expect(checkboxes[2].element.checked).toBe(false) // Feed B, abandoned edit dropped
})
it('locks body scroll while open and restores the prior value on close', async () => {
document.body.style.overflow = 'scroll'
const wrapper = mount(FeedFilter, {
props: { show: false, titles: [], counts: {}, selected: null },
})
await wrapper.setProps({ show: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ show: false })
expect(document.body.style.overflow).toBe('scroll')
})
it('restores the prior body scroll value on unmount while still open', async () => {
document.body.style.overflow = 'auto'
const wrapper = mount(FeedFilter, {
props: { show: false, titles: [], counts: {}, selected: null },
})
await wrapper.setProps({ show: true })
expect(document.body.style.overflow).toBe('hidden')
wrapper.unmount()
expect(document.body.style.overflow).toBe('auto')
})
it('focuses the "All feeds" checkbox after opening', async () => {
const wrapper = mount(FeedFilter, {
props: { show: false, titles: ['Feed A'], counts: {}, selected: null },
attachTo: document.body,
})
await wrapper.setProps({ show: true })
await nextTick()
await nextTick()
expect(document.activeElement).toBe(wrapper.findAll('input[type="checkbox"]')[0].element)
wrapper.unmount()
})
it('seeds the draft, locks body scroll, and wires Escape when mounted already open', async () => {
document.body.style.overflow = 'auto'
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
})
await nextTick()
expect(document.body.style.overflow).toBe('hidden')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes[1].element.checked).toBe(true) // Feed A, seeded from `selected`
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(wrapper.emitted('close')).toEqual([[['Feed A']]])
wrapper.unmount()
})
it('leaves a pre-set body overflow untouched when mounted closed and then unmounted', () => {
document.body.style.overflow = 'scroll'
const wrapper = mount(FeedFilter, {
props: { show: false, titles: [], counts: {}, selected: null },
})
wrapper.unmount()
expect(document.body.style.overflow).toBe('scroll')
})
it('renders dialog accessibility attributes tied to the heading', () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
})
const dialog = wrapper.find('.modal-container')
expect(dialog.attributes('role')).toBe('dialog')
expect(dialog.attributes('aria-modal')).toBe('true')
const labelledby = dialog.attributes('aria-labelledby')
expect(wrapper.find(`#${labelledby}`).text()).toBe('Filter feeds')
})
})
@@ -0,0 +1,85 @@
// loadPersistedFeedFilter() (see useFeeds.js) reads localStorage at module
// import time, not lazily — every case here has to vi.resetModules() and
// dynamically re-import so the module-scope ref actually re-initializes
// against the localStorage value set up beforehand. Mirrors the pattern in
// viewModeBoot.spec.js.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('axios')
class FakeIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('feedFilter boot persistence', () => {
beforeEach(() => {
vi.resetModules()
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
it('boots with the persisted array when it is valid', async () => {
localStorage.setItem('feedFilter', JSON.stringify(['Feed A', 'Feed B']))
const { useFeeds } = await import('../useFeeds')
const { feedFilter } = useFeeds()
expect(feedFilter.value).toEqual(['Feed A', 'Feed B'])
})
it('boots into "All feeds" when the key is absent', async () => {
const { useFeeds } = await import('../useFeeds')
const { feedFilter } = useFeeds()
expect(feedFilter.value).toBeNull()
})
it('boots into "All feeds" when the persisted array is empty', async () => {
localStorage.setItem('feedFilter', JSON.stringify([]))
const { useFeeds } = await import('../useFeeds')
const { feedFilter } = useFeeds()
expect(feedFilter.value).toBeNull()
})
it.each([
['a plain object', JSON.stringify({ title: 'Feed A' })],
['a bare string', JSON.stringify('Feed A')],
])('boots into "All feeds" when the persisted value is not an array (%s)', async (_label, raw) => {
localStorage.setItem('feedFilter', raw)
const { useFeeds } = await import('../useFeeds')
const { feedFilter } = useFeeds()
expect(feedFilter.value).toBeNull()
})
it('boots into "All feeds" when the persisted value is not valid JSON', async () => {
localStorage.setItem('feedFilter', 'not json')
const { useFeeds } = await import('../useFeeds')
const { feedFilter } = useFeeds()
expect(feedFilter.value).toBeNull()
})
it.each([
['numbers', [1]],
['null', [null]],
['a mix of strings and non-strings', ['a', 2]],
])('boots into "All feeds" when the array contains non-string elements (%s)', async (_label, value) => {
localStorage.setItem('feedFilter', JSON.stringify(value))
const { useFeeds } = await import('../useFeeds')
const { feedFilter } = useFeeds()
expect(feedFilter.value).toBeNull()
})
})
+735 -12
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import axios from 'axios'
import { useFeeds } from '../useFeeds'
import { useFeeds, logout, clearFeedFilter } from '../useFeeds'
vi.mock('axios')
@@ -12,11 +12,12 @@ 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, currentIndex, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, markRead, markCurrentArticleRead, setupIntersectionObserver, isSyncing } = useFeeds()
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7')
localStorage.removeItem('feedFilter')
vi.clearAllMocks()
feeds.value = []
@@ -24,7 +25,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 () => {
@@ -118,9 +119,9 @@ describe('useFeeds', () => {
setInitialLoad(false)
})
it('refreshes the filter dropdown\'s displayed per-feed counts when list-view scrolling marks an article read', async () => {
it('refreshes the filter modal\'s displayed per-feed counts when list-view scrolling marks an article read', async () => {
// displayedFeedUnreadCounts is a frozen snapshot of feedUnreadCounts (see
// useFeeds.js) — AppNav's filter <select> renders it instead of the live
// useFeeds.js) — AppNav's filter modal renders it instead of the live
// computed so paging through article view doesn't force it to repaint.
// List-view scrolling is a different code path (handleIntersection) and
// should keep it live regardless.
@@ -177,6 +178,279 @@ describe('useFeeds', () => {
setInitialLoad(false)
})
describe('mark-as-read suppressed while syncing', () => {
it('queues list-view scroll marking while a reload (fetchData) is in flight, and flushes it automatically once it finishes', async () => {
feeds.value = [{ id: 701, title: 'First' }]
setInitialLoad(true)
// No .observe nodes exist here, so this deterministically clears
// skipNextObservation rather than relying on whatever an earlier test
// left it as.
setupIntersectionObserver()
axios.put.mockResolvedValue({ status: 200 })
let resolveGet
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
const fetchPromise = fetchData()
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
])
expect(axios.put).not.toHaveBeenCalled()
// Queued, not dropped — untouched while the reload is in flight, unlike
// a normal scroll-past which removes it immediately.
expect(feeds.value.map(f => f.id)).toEqual([701])
// The refetch's response still lists it (its read never committed) —
// the flush that follows must find and remove it by id.
resolveGet({
data: {
feeds: [{
title: 'Feed A',
items: [{ id: 701, title: 'First', content: '', url: 'https://example.test/701', timestamp: '2026-01-01 10:00:00' }],
}],
},
})
await fetchPromise
// Flushed automatically once the reload finished — no second scroll needed.
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/701', null, expect.anything())
expect(feeds.value).toEqual([])
setInitialLoad(false)
})
it('queues list-view scroll marking for the whole duration of sync(), and flushes it once sync finishes', async () => {
feeds.value = [{ id: 702, title: 'First' }]
setInitialLoad(true)
setupIntersectionObserver() // deterministically clears skipNextObservation — see the previous test
axios.put.mockResolvedValue({ status: 200 })
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const syncPromise = sync(true)
// Still mid network round-trip to /api/v1/article/sync — fetchData()
// hasn't even started yet, but scroll-marking must already be queued.
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
])
expect(axios.put).not.toHaveBeenCalled()
expect(feeds.value.map(f => f.id)).toEqual([702])
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/702', null, expect.anything())
expect(feeds.value).toEqual([])
setInitialLoad(false)
})
it('queues article-view display marking while a reload is in flight, and flushes it in place once the reload finishes', async () => {
viewMode.value = 'article' // still on screen when the flush runs — must not be removed out from under it
feeds.value = [{ id: 703, title: 'First' }]
axios.put.mockResolvedValue({ status: 200 })
let resolveGet
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
const fetchPromise = fetchData()
markCurrentArticleRead()
expect(axios.put).not.toHaveBeenCalled()
expect(feeds.value[0].read).toBeFalsy()
// The refetch's response still lists it (its read never committed), same
// id, now at whatever index it re-sorts to — the flush must find it by
// id, not by the currentIndex it was queued under.
resolveGet({
data: {
feeds: [{
title: 'Feed A',
items: [{ id: 703, title: 'First', content: '', url: 'https://example.test/703', timestamp: '2026-01-01 10:00:00' }],
}],
},
})
await fetchPromise
// Flushed automatically once the reload finished — no second call needed.
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/703', null, expect.anything())
// Still in article view — marked in place, not removed (removing it here
// would yank whatever's on screen out from under currentIndex; the same
// invariant markCurrentArticleRead() already keeps while paging).
expect(feeds.value.find(f => f.id === 703)).toMatchObject({ read: true })
})
it('drops a queued article-view read once flushed, if the user already left article view before the reload finished', async () => {
feeds.value = [{ id: 707, title: 'First' }]
viewMode.value = 'article'
axios.put.mockResolvedValue({ status: 200 })
let resolveGet
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
const fetchPromise = fetchData()
markCurrentArticleRead() // queued: isSyncing is true
// The user backs out to list view before the reload finishes —
// leaveArticleView()'s dropReadArticles() runs here, but this article
// isn't flagged read yet (it's only queued), so it survives that pass.
viewMode.value = 'list'
expect(feeds.value.map(f => f.id)).toEqual([707])
resolveGet({ data: { feeds: [] } })
await fetchPromise
// Now in list view with no further transition to rely on — the flush
// must sweep it out itself instead of leaving it stuck looking unread.
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/707', null, expect.anything())
expect(feeds.value).toEqual([])
})
it('queues every article paged past during a sync, flushing all of them once it ends', async () => {
feeds.value = [
{ id: 704, title: 'First' },
{ id: 705, title: 'Second' },
]
axios.put.mockResolvedValue({ status: 200 })
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const syncPromise = sync(true)
markCurrentArticleRead() // pages onto article 704
currentIndex.value = 1
markCurrentArticleRead() // pages onto article 705
expect(axios.put).not.toHaveBeenCalled()
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/704', null, expect.anything())
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/705', null, expect.anything())
currentIndex.value = 0
})
it('does not reopen the mark-as-read window when an independent fetchData() resolves while a sync() is still in flight', async () => {
feeds.value = [{ id: 706, title: 'First' }]
setInitialLoad(true)
setupIntersectionObserver() // deterministically clears skipNextObservation — see above
axios.put.mockResolvedValue({ status: 200 })
// sync() starts and is left mid network round-trip.
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const syncPromise = sync(true)
// An independent fetchData() call (e.g. a separate reload) runs to
// completion while sync()'s POST is still pending.
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
await fetchData()
// sync() itself hasn't finished — scroll-marking must still be
// suppressed, not reopened by the independent call settling first.
feeds.value = [{ id: 706, title: 'First' }]
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
])
expect(axios.put).not.toHaveBeenCalled()
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
// Only now, with sync() truly finished, does the queued entry flush.
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/706', null, expect.anything())
setInitialLoad(false)
})
})
describe('isSyncing', () => {
// Lets a just-resolved deferred promise's `.then` continuations (e.g.
// sync()'s `await fetchData()`) actually run before the next assertion,
// without pulling in fake timers (see the CLAUDE.md Vitest gotcha).
async function flushMicrotasks() {
for (let i = 0; i < 5; i++) await Promise.resolve()
}
it('is false initially, true while a fetchData() call is in flight, and false again once it resolves', async () => {
expect(isSyncing.value).toBe(false)
let resolveGet
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
const fetchPromise = fetchData()
expect(isSyncing.value).toBe(true)
resolveGet({ data: { feeds: [] } })
await fetchPromise
expect(isSyncing.value).toBe(false)
})
it('stays true for the whole duration of sync(), including its trailing fetchData(), and only clears once sync() fully settles', async () => {
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
let resolveGet
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
const syncPromise = sync(true)
expect(isSyncing.value).toBe(true)
// The sync POST settles, handing off into sync()'s own trailing
// fetchData() — isSyncing must not flicker false in between.
resolvePost({ status: 200 })
await flushMicrotasks()
expect(axios.get).toHaveBeenCalled()
expect(isSyncing.value).toBe(true)
resolveGet({ data: { feeds: [] } })
await syncPromise
expect(isSyncing.value).toBe(false)
})
it('leaves isSyncing true while an independent fetchData() completes during a still in-flight sync(), clearing only once both finish', async () => {
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const syncPromise = sync(true)
expect(isSyncing.value).toBe(true)
// An independent fetchData() call runs to completion while sync()'s
// POST is still pending.
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
await fetchData()
// sync() itself hasn't finished yet.
expect(isSyncing.value).toBe(true)
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
expect(isSyncing.value).toBe(false)
})
it('clears on a rejected fetchData()', async () => {
axios.get.mockRejectedValueOnce(new Error('network'))
await fetchData() // caught internally — resolves rather than rejecting
expect(isSyncing.value).toBe(false)
})
it('clears on a rejected sync()', async () => {
axios.post.mockRejectedValueOnce(new Error('network'))
await sync(true) // caught internally — resolves rather than rejecting
expect(isSyncing.value).toBe(false)
})
})
describe('markRead resilience', () => {
// Mirrors useFeeds.js's MARK_READ_RETRY_DELAYS_MS (not exported). Kept in
// sync via this comment: if that backoff changes, update this too — too
@@ -385,6 +659,31 @@ describe('useFeeds', () => {
},
}
const threeFeedsResponse = {
data: {
feeds: [
{
title: 'Feed A',
items: [
{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' },
],
},
{
title: 'Feed B',
items: [
{ id: 2, title: 'B1', content: '', url: 'https://example.test/b1', timestamp: '2026-01-02 10:00:00' },
],
},
{
title: 'Feed C',
items: [
{ id: 3, title: 'C1', content: '', url: 'https://example.test/c1', timestamp: '2026-01-03 10:00:00' },
],
},
],
},
}
it('narrows the displayed list to a single feed and restores it on "All feeds"', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
await fetchData()
@@ -394,7 +693,7 @@ describe('useFeeds', () => {
// Unfiltered: everything, newest first (Jan 3, Jan 2, Jan 1).
expect(feeds.value.map(f => f.id)).toEqual([3, 2, 1])
await setFeedFilter('Feed A')
await setFeedFilter(['Feed A'])
expect(feeds.value.map(f => f.id)).toEqual([3, 1])
await setFeedFilter(null)
@@ -406,7 +705,7 @@ describe('useFeeds', () => {
axios.put.mockResolvedValue({ status: 200 })
await fetchData()
await setFeedFilter('Feed A')
await setFeedFilter(['Feed A'])
setInitialLoad(true)
// The first Feed A article scrolls above the viewport → marked read and
@@ -423,12 +722,66 @@ 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 })
await fetchData()
await setFeedFilter('Feed B')
await setFeedFilter(['Feed B'])
expect(feeds.value.map(f => f.id)).toEqual([2])
// Mark all (visible = just Feed B) read — Feed B has no unread items left.
@@ -436,10 +789,10 @@ describe('useFeeds', () => {
await markAllRead()
// The list empties, but the filter stays put and the feed remains a
// selectable option (so the <select> never dangles) even though it no
// selectable option (so a checked feed never dangles) even though it no
// longer has unread items.
expect(feeds.value).toEqual([])
expect(feedFilter.value).toBe('Feed B')
expect(feedFilter.value).toEqual(['Feed B'])
expect(feedTitles.value).toEqual(['Feed A', 'Feed B'])
// Switching away drops the now-empty feed from the options.
@@ -456,9 +809,9 @@ describe('useFeeds', () => {
expect(unreadCount.value).toBe(3)
// Filtered: only the selected feed's unread items.
await setFeedFilter('Feed B')
await setFeedFilter(['Feed B'])
expect(unreadCount.value).toBe(1)
await setFeedFilter('Feed A')
await setFeedFilter(['Feed A'])
expect(unreadCount.value).toBe(2)
await setFeedFilter(null)
@@ -476,6 +829,325 @@ describe('useFeeds', () => {
allItems.value.find(i => i.id === 3).read = true
expect(feedUnreadCounts.value).toEqual({ 'Feed A': 1, 'Feed B': 1 })
})
it('filters the displayed list and unread count to the union of several selected feeds', async () => {
axios.get.mockResolvedValueOnce(threeFeedsResponse)
await fetchData()
await setFeedFilter(['Feed A', 'Feed C'])
// Newest first, Feed B (id 2) excluded.
expect(feeds.value.map(f => f.id)).toEqual([3, 1])
expect(unreadCount.value).toBe(2)
})
it('keeps every selected feed listed (even at 0 unread) once all of them are emptied', async () => {
axios.get.mockResolvedValueOnce(threeFeedsResponse)
axios.put.mockResolvedValue({ status: 200 })
await fetchData()
await setFeedFilter(['Feed A', 'Feed C'])
vi.spyOn(window, 'confirm').mockReturnValue(true)
await markAllRead()
expect(feeds.value).toEqual([])
expect(feedFilter.value).toEqual(['Feed A', 'Feed C'])
// Feed B is still present because it still has an unread item; A and C
// stay present purely because they're selected — see feedTitles.
expect(feedTitles.value).toEqual(['Feed A', 'Feed B', 'Feed C'])
})
it('limits mark-all-read to the selected feeds, leaving the rest untouched', async () => {
axios.get.mockResolvedValueOnce(threeFeedsResponse)
axios.put.mockResolvedValue({ status: 200 })
await fetchData()
await setFeedFilter(['Feed A', 'Feed C'])
vi.spyOn(window, 'confirm').mockReturnValue(true)
await markAllRead()
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/1', null, expect.anything())
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/3', null, expect.anything())
expect(axios.put).not.toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything())
// Feed B's unread item survives in the master untouched.
expect(allItems.value.map(f => f.id)).toEqual([2])
})
it('collapses an empty array selection to "All feeds" rather than an empty filter', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
await fetchData()
await setFeedFilter(['Feed A'])
await setFeedFilter([])
expect(feedFilter.value).toBeNull()
expect(feeds.value.map(f => f.id)).toEqual([3, 2, 1])
})
})
describe('feed filter persistence', () => {
afterEach(() => {
localStorage.removeItem('feedFilter')
})
it('persists a feed selection to localStorage, and removes the key for "All feeds"', async () => {
await setFeedFilter(['Feed A', 'Feed B'])
expect(localStorage.getItem('feedFilter')).toBe(JSON.stringify(['Feed A', 'Feed B']))
await setFeedFilter(null)
expect(localStorage.getItem('feedFilter')).toBeNull()
})
it('clearFeedFilter resets the ref and removes the persisted key', async () => {
await setFeedFilter(['Feed A'])
clearFeedFilter()
expect(feedFilter.value).toBeNull()
expect(localStorage.getItem('feedFilter')).toBeNull()
})
it('logout clears the persisted feed filter along with the session', async () => {
axios.post.mockResolvedValueOnce({ status: 200 })
await setFeedFilter(['Feed A'])
await logout()
expect(feedFilter.value).toBeNull()
expect(localStorage.getItem('feedFilter')).toBeNull()
})
})
describe('pruneFeedFilter (via fetchData)', () => {
// pruneFeedFilter's own axios.get call is routed by URL, alongside
// fetchData's — vi.clearAllMocks() (see the outer beforeEach) clears call
// history but not a mockImplementation, so each test here resets it
// explicitly rather than leaking a stale router into later tests.
afterEach(() => {
axios.get.mockReset()
})
function mockArticleAndFeedsGets({ articleFeeds, filterFeeds, filterFeedsRejects }) {
axios.get.mockImplementation((url) => {
if (url.startsWith('/api/v1/article/get/')) {
return Promise.resolve({ data: { feeds: articleFeeds } })
}
if (url.startsWith('/api/v1/article/feeds/')) {
if (filterFeedsRejects) return Promise.reject(filterFeedsRejects)
return Promise.resolve({ data: { feeds: filterFeeds } })
}
return Promise.reject(new Error('unexpected axios.get url: ' + url))
})
}
async function flushMicrotasks(times = 10) {
for (let i = 0; i < times; i++) await Promise.resolve()
}
it('does not request the feeds endpoint when no filter is active', async () => {
feedFilter.value = null
mockArticleAndFeedsGets({ articleFeeds: [] })
await fetchData()
expect(axios.get).toHaveBeenCalledTimes(1)
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
})
it('drops a selected title whose feed no longer exists, keeping the one that does', async () => {
feedFilter.value = ['Feed A', 'Gone']
mockArticleAndFeedsGets({
articleFeeds: [
{ title: 'Feed A', items: [{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }] },
{ title: 'Feed B', items: [{ id: 2, title: 'B1', content: '', url: 'https://example.test/b1', timestamp: '2026-01-02 10:00:00' }] },
],
filterFeeds: [{ title: 'Feed A' }],
})
await fetchData()
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/feeds/7', expect.anything())
expect(feedFilter.value).toEqual(['Feed A'])
expect(localStorage.getItem('feedFilter')).toBe(JSON.stringify(['Feed A']))
expect(feeds.value.map(f => f.id)).toEqual([1])
})
it('collapses to "All feeds" when every selected title is gone', async () => {
feedFilter.value = ['Gone']
mockArticleAndFeedsGets({
articleFeeds: [
{ title: 'Feed A', items: [{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }] },
],
filterFeeds: [{ title: 'Feed A' }],
})
await fetchData()
expect(feedFilter.value).toBeNull()
expect(localStorage.getItem('feedFilter')).toBeNull()
expect(feeds.value.map(f => f.id)).toEqual([1])
})
it('keeps a selected feed with 0 unread items selected, without re-persisting', async () => {
feedFilter.value = ['Feed A']
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem')
mockArticleAndFeedsGets({
articleFeeds: [], // Feed A has no unread items right now
filterFeeds: [{ title: 'Feed A' }], // but the feed itself still exists
})
await fetchData()
expect(feedFilter.value).toEqual(['Feed A'])
expect(setItemSpy).not.toHaveBeenCalledWith('feedFilter', expect.anything())
setItemSpy.mockRestore()
})
it('leaves the filter untouched and surfaces no user-facing error when the feeds lookup fails', async () => {
feedFilter.value = ['Feed A']
localStorage.setItem('feedFilter', JSON.stringify(['Feed A']))
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockArticleAndFeedsGets({
articleFeeds: [
{ title: 'Feed A', items: [{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }] },
],
filterFeedsRejects: new Error('network'),
})
await fetchData()
expect(feedFilter.value).toEqual(['Feed A'])
expect(localStorage.getItem('feedFilter')).toBe(JSON.stringify(['Feed A']))
expect(showMessage.value).toBe(false)
expect(message.value).toBe('')
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('does not request the feeds endpoint when the article fetch itself fails', async () => {
feedFilter.value = ['Feed A']
axios.get.mockImplementation((url) => {
if (url.startsWith('/api/v1/article/get/')) return Promise.reject(new Error('network'))
return Promise.reject(new Error('unexpected axios.get url: ' + url))
})
await fetchData()
expect(axios.get).toHaveBeenCalledTimes(1)
})
it('does not resurrect the filter if it was cleared while the feeds lookup was still in flight', async () => {
feedFilter.value = ['Feed A']
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
let resolveFeeds
axios.get.mockImplementation((url) => {
if (url.startsWith('/api/v1/article/get/')) {
return Promise.resolve({
data: { feeds: [{ title: 'Feed A', items: [{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }] }] },
})
}
if (url.startsWith('/api/v1/article/feeds/')) {
return new Promise(resolve => { resolveFeeds = resolve })
}
return Promise.reject(new Error('unexpected axios.get url: ' + url))
})
const fetchPromise = fetchData()
await flushMicrotasks() // let fetchData's own GET resolve and pruneFeedFilter dispatch its GET
clearFeedFilter()
resolveFeeds({ data: { feeds: [{ title: 'Feed A' }] } })
await fetchPromise
expect(feedFilter.value).toBeNull()
expect(consoleErrorSpy).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('keeps a filter selected mid-request rather than reverting it to the prune outcome', async () => {
feedFilter.value = ['Feed A']
let resolveFeeds
axios.get.mockImplementation((url) => {
if (url.startsWith('/api/v1/article/get/')) {
return Promise.resolve({
data: {
feeds: [
{ title: 'Feed A', items: [{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }] },
{ title: 'Feed B', items: [{ id: 2, title: 'B1', content: '', url: 'https://example.test/b1', timestamp: '2026-01-02 10:00:00' }] },
],
},
})
}
if (url.startsWith('/api/v1/article/feeds/')) {
return new Promise(resolve => { resolveFeeds = resolve })
}
return Promise.reject(new Error('unexpected axios.get url: ' + url))
})
const fetchPromise = fetchData()
await flushMicrotasks()
await setFeedFilter(['Feed B'])
// The (stale, already-in-flight) feeds list still includes Feed B, so the
// switch survives the prune that resolves afterwards.
resolveFeeds({ data: { feeds: [{ title: 'Feed A' }, { title: 'Feed B' }] } })
await fetchPromise
expect(feedFilter.value).toEqual(['Feed B'])
})
it('keeps isSyncing true until the feed-filter prune settles, not just the initial fetch', async () => {
feedFilter.value = ['Feed A']
let resolveFeeds
axios.get.mockImplementation((url) => {
if (url.startsWith('/api/v1/article/get/')) {
return Promise.resolve({ data: { feeds: [{ title: 'Feed A', items: [] }] } })
}
if (url.startsWith('/api/v1/article/feeds/')) {
return new Promise(resolve => { resolveFeeds = resolve })
}
return Promise.reject(new Error('unexpected axios.get url: ' + url))
})
const fetchPromise = fetchData()
await flushMicrotasks()
expect(isSyncing.value).toBe(true)
resolveFeeds({ data: { feeds: [{ title: 'Feed A' }] } })
await fetchPromise
expect(isSyncing.value).toBe(false)
})
})
describe('setFeedFilter no-op guard', () => {
it('no-ops on an empty array while already unfiltered', async () => {
feedFilter.value = null
const scrollToSpy = vi.spyOn(window, 'scrollTo')
await setFeedFilter([])
expect(scrollToSpy).not.toHaveBeenCalled()
expect(feedFilter.value).toBeNull()
scrollToSpy.mockRestore()
})
it('does not mark anything read on a no-op call while in article view', async () => {
feedFilter.value = ['Feed A']
viewMode.value = 'article'
feeds.value = [{ id: 1, feedTitle: 'Feed A', title: 'a' }]
await setFeedFilter(['Feed A'])
expect(axios.put).not.toHaveBeenCalled()
viewMode.value = 'list'
})
})
it('strips leftover embedded-video placeholder headings', async () => {
@@ -607,4 +1279,55 @@ describe('useFeeds', () => {
expect(feeds.value[0].content).not.toContain('%7Bsize%7D')
expect(feeds.value[0].content).not.toContain('<img')
})
it('strips Al-Jazeera-style "Recommended Stories" sections', async () => {
feeds.value = [{
id: 1,
title: 'Article one',
url: 'https://www.aljazeera.com/news/2026/9/14/article-one',
content: '',
}]
axios.post.mockResolvedValueOnce({
data: {
content: `<html><body><article>
<p>some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.</p>
<section class="more-on"><h2 class="more-on__heading">Recommended Stories<!-- --> </h2><span>list of 3 items</span><ul>
<li><span>list 1 of 3</span><a href="https://www.aljazeera.com/a">Unrelated story one</a></li>
<li><span>list 2 of 3</span><a href="https://www.aljazeera.com/b">Unrelated story two</a></li>
<li><span>list 3 of 3</span><a href="https://www.aljazeera.com/c">Unrelated story three</a></li>
</ul><span>end of list</span></section>
<p>more article text long enough to survive readability's content-length heuristics as well, since it needs to look like part of the main body too.</p>
</article></body></html>`,
},
})
await getReadable(feeds.value[0], 0)
expect(feeds.value[0].readable).toBe(true)
expect(feeds.value[0].content).toContain('main content body')
expect(feeds.value[0].content).not.toContain('Recommended Stories')
expect(feeds.value[0].content).not.toContain('Unrelated story')
})
it('keeps other sections that are not the "Recommended Stories" block', async () => {
feeds.value = [{
id: 1,
title: 'Article one',
url: 'https://www.aljazeera.com/news/2026/9/14/article-one',
content: '',
}]
axios.post.mockResolvedValueOnce({
data: {
content: `<html><body><article>
<section><p>some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.</p></section>
<p>more article text long enough to survive readability's content-length heuristics as well, since it needs to look like part of the main body too.</p>
</article></body></html>`,
},
})
await getReadable(feeds.value[0], 0)
expect(feeds.value[0].readable).toBe(true)
expect(feeds.value[0].content).toContain('main content body')
})
})
@@ -57,6 +57,10 @@ describe('viewMode boot persistence', () => {
expect(wrapper.find('.article-single').exists()).toBe(true)
expect(wrapper.find('#article').exists()).toBe(false)
// The article shown right after reload must be marked read immediately —
// not only once the user pages forward/back at least once.
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/1', null, expect.anything())
wrapper.unmount()
})
+214 -30
View File
@@ -12,18 +12,38 @@ const showMessage = ref(false)
// the index-based observer / currentIndex logic has to know a filter exists.
const allItems = ref([]);
const feeds = ref([]);
const feedFilter = ref(null) // selected feedTitle, or null = all feeds (not persisted)
// Distinct feed titles present in the loaded (unread) items — the filter options.
// The active filter is always kept in the list even once its feed runs out of
// unread items, so the <select> never binds to a value that isn't an option
// (it just shows "All caught up") and the feed stays re-selectable.
// Selected feed titles, or null = all feeds. Persisted in localStorage (see
// setFeedFilter) so the selection survives a reload.
function loadPersistedFeedFilter() {
const raw = localStorage.getItem('feedFilter')
if (!raw) return null
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) && parsed.length > 0 && parsed.every(title => typeof title === 'string') ? parsed : null
} catch {
return null
}
}
const feedFilter = ref(loadPersistedFeedFilter())
// Feed titles are specific to the account that was logged in — call this
// wherever a session ends (explicit logout, or a 401 dropping a stale/expired
// one in main.js) so they can't leak (in storage or in the still-live module
// singleton) into whichever account is active next on this device.
export function clearFeedFilter() {
localStorage.removeItem('feedFilter')
feedFilter.value = null
}
// Distinct feed titles present in the loaded (unread) items — the filter
// checkbox options. Every currently selected title is always kept in the
// list even once its feed runs out of unread items, so a checked feed never
// disappears out from under the user before they get a chance to uncheck it.
const feedTitles = computed(() => {
const titles = new Set(allItems.value.map(i => i.feedTitle))
if (feedFilter.value) titles.add(feedFilter.value)
for (const title of feedFilter.value ?? []) titles.add(title)
return [...titles].sort((a, b) => a.localeCompare(b))
})
// Unread count per feed title, shown next to each option in the filter
// <select>. Derived from the master (allItems) so article view's
// Unread count per feed title, shown next to each checkbox in the filter
// modal. Derived from the master (allItems) so article view's
// read-but-still-shown items are excluded. A title kept in feedTitles with no
// unread items left has no key here — render it as 0.
const feedUnreadCounts = computed(() => {
@@ -38,13 +58,12 @@ const feedUnreadCounts = computed(() => {
// rather than the displayed `feeds` so article view's read-but-still-shown
// items are excluded and the count matches the filter regardless of view.
const unreadCount = computed(() => {
const items = feedFilter.value
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
: allItems.value
return items.filter(i => !i.read).length
if (!feedFilter.value) return allItems.value.filter(i => !i.read).length
const titles = new Set(feedFilter.value)
return allItems.value.filter(i => !i.read && titles.has(i.feedTitle)).length
})
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below)
// for the filter <select>'s per-feed option counts, instead of binding
// for the filter modal's per-feed checkbox counts, instead of binding
// feedUnreadCounts directly — by design, this stays frozen while paging
// through article view (markCurrentArticleRead marks one article read per
// page turn, which would otherwise recompute it live on every single swipe).
@@ -59,7 +78,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
@@ -76,6 +94,63 @@ let initialLoad = false
// genuine scroll-driven exits mark read after that.
let skipNextObservation = false
// Count of fetchData()/sync() calls currently in flight — a refcount rather
// than a plain boolean for the same reason as pendingReadCounts below: sync()
// awaits its own trailing fetchData(), so one call nests inside the other, and
// fetchData() can also be invoked independently (RssFeeds.vue's boot reload)
// while a sync() elsewhere is still mid network round-trip. A bare boolean
// would have whichever call finishes first clear it out from under the other
// still-running one. Both rebuild allItems/feeds from the server, so the
// passive/paging mark-as-read paths — handleIntersection's scroll-marking and
// markCurrentArticleRead's display-marking — must not fire while any is in
// flight. markAllRead is a deliberate, already-confirmed action and is left
// ungated.
const activeSyncCount = ref(0)
function beginSync() {
activeSyncCount.value += 1
}
// Ids handleIntersection()/markCurrentArticleRead() couldn't mark while
// isSyncing (see below) — flushed once every in-flight fetchData()/sync()
// has finished, rather than left unread forever. Flushing by id (not by
// re-deriving an index) sidesteps fetchData() having replaced
// feeds.value/allItems.value wholesale in the meantime — the id is still
// valid, wherever the item now sits.
const pendingSuppressedReadIds = new Set()
function endSync() {
activeSyncCount.value -= 1
if (activeSyncCount.value === 0) {
flushPendingSuppressedReads()
}
}
const isSyncing = computed(() => activeSyncCount.value > 0)
function flushPendingSuppressedReads() {
if (pendingSuppressedReadIds.size === 0) return
const ids = [...pendingSuppressedReadIds]
pendingSuppressedReadIds.clear()
for (const id of ids) {
// feeds.value is always a filter()/slice() projection of allItems.value
// (see applyFilter), so every item in it is the same object reference as
// one already in allItems.value — one lookup is enough.
const feed = allItems.value.find(f => f.id === id)
if (feed) feed.read = true
markRead(id)
}
// A still-open article view marks in place and relies on dropReadArticles()
// running at the next leaveArticleView()/setFeedFilter() to clean up — same
// invariant markCurrentArticleRead() already keeps while paging, and
// removing one here could shift currentIndex out from under whatever's on
// screen. List view has no such upcoming transition to rely on (the user
// may just keep scrolling), so sweep these out now: this is exactly what a
// scroll-triggered mark would already have done immediately, had a sync not
// been in flight.
if (viewMode.value !== 'article') {
dropReadArticles()
}
refreshUnreadDisplay()
}
// Timestamp (performance.now()) of the most recent programmatic scroll / list
// mutation that moves the page without user intent — currently the list-view
// read-correction below. AppNav's auto-hide handler resyncs its scroll baseline
@@ -107,6 +182,7 @@ export async function logout() {
}
localStorage.removeItem('user-token')
localStorage.removeItem('user-id')
clearFeedFilter()
}
// Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src` and various
@@ -215,6 +291,11 @@ async function getReadable(feed, index) {
const container = el.closest('section') ?? el.closest('article')
if (container) container.remove()
})
// Al Jazeera embeds a "Recommended Stories" section (a heading followed by
// a list of unrelated article teasers), marked up as <section class="more-on">.
// It's not part of the article, so strip it before Readability pulls it
// into the parsed content.
doc.querySelectorAll('section.more-on').forEach(el => el.remove())
const article = new Readability(doc).parse();
if (!article) {
showMessageForXSeconds('Could not extract readable content.', 5)
@@ -303,9 +384,48 @@ async function markRead(id) {
// feed with no remaining unread items projects to an empty list ("All caught
// up") while staying selected — see feedTitles.
function applyFilter() {
feeds.value = feedFilter.value
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
: allItems.value.slice()
if (!feedFilter.value) {
feeds.value = allItems.value.slice()
return
}
const titles = new Set(feedFilter.value)
feeds.value = allItems.value.filter(i => titles.has(i.feedTitle))
}
// 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)
}
// Order-insensitive: callers building the next selection from checkbox
// toggles (see FeedFilter.vue) can produce an array in a different order than
// what's already active — that's not a real change.
function sameFeedSelection(a, b) {
if (a === null || b === null) return a === b
if (a.length !== b.length) return false
const setA = new Set(a)
return b.every(title => setA.has(title))
}
// Shared by setFeedFilter() and pruneFeedFilter() — both assign feedFilter.value
// then need the same localStorage write/removal to keep it in sync.
function persistFeedFilter() {
if (feedFilter.value) {
localStorage.setItem('feedFilter', JSON.stringify(feedFilter.value))
} else {
localStorage.removeItem('feedFilter')
}
}
// Changes the active feed filter and re-projects. Mirrors toggleLayout's
@@ -313,20 +433,62 @@ function applyFilter() {
// the observer at the new .observe nodes after Vue has re-rendered. Works in
// article view too — there are no .observe nodes there so the setup is a no-op,
// and resetting currentIndex keeps paging valid against the new list.
async function setFeedFilter(title) {
// A no-op (order-insensitively unchanged) selection returns early, before any
// of the scrollTo/dropReadArticles/mark-read side effects below — every
// caller gets this protection for free rather than having to check first.
async function setFeedFilter(titles) {
const next = titles && titles.length > 0 ? titles : null // null for "All feeds"
if (sameFeedSelection(feedFilter.value, next)) return
disconnectObserver()
window.scrollTo(0, 0)
feedFilter.value = title // null for "All feeds"
dropReadArticles()
feedFilter.value = next
persistFeedFilter()
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()
}
const fetchData = async () => {
const user_id = localStorage.getItem("user-id")
// A persisted/selected title can outlive its feed (deleted or renamed from
// Admin) — the modal then has no way to un-list a title the user never got a
// chance to see and uncheck. `allItems` can't tell "the feed still exists"
// from "the feed has 0 unread items right now" (feedTitles deliberately keeps
// the latter listed), so it's not a usable source of truth here; the feed
// list itself is, and AdminFeeds.vue already fetches exactly that from this
// same endpoint. Only requested when a filter is actually active, since
// that's the only case with anything to prune.
async function pruneFeedFilter() {
if (!feedFilter.value) return
try {
const user_id = localStorage.getItem("user-id")
const response = await axios.get("/api/v1/article/feeds/" + user_id, authHeaders())
// The filter may have been cleared (logout / 401) while the request was in flight.
if (!feedFilter.value) return
const existingTitles = new Set(response.data.feeds.map(feed => feed.title))
const pruned = feedFilter.value.filter(title => existingTitles.has(title))
if (pruned.length === feedFilter.value.length) return
feedFilter.value = pruned.length > 0 ? pruned : null
persistFeedFilter()
applyFilter()
} catch (error) {
// Best-effort: a stale title stuck in the modal until the next successful
// prune is harmless, so don't fail the reload over it.
console.error('Error pruning feed filter', error)
}
}
const fetchData = async () => {
try {
beginSync()
const user_id = localStorage.getItem("user-id")
// Snapshot ids pending *before* the GET goes out, not just when its
// response comes back. A markRead() PUT that commits while this GET is
// in flight is invisible to the check below (its id has already been
@@ -350,16 +512,20 @@ const fetchData = async () => {
freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
allItems.value = freshItems;
applyFilter();
await pruneFeedFilter();
refreshUnreadDisplay();
await nextTick();
setupIntersectionObserver();
} catch (error) {
console.error('Error fetching data:', error)
showMessageForXSeconds(error, 5)
} finally {
endSync()
}
};
async function sync(silent = false) {
beginSync()
try {
const response = await axios.post('/api/v1/article/sync', {
user_id: parseInt(localStorage.getItem("user-id"))
@@ -368,12 +534,14 @@ async function sync(silent = false) {
if (response.status == 200 && !silent) {
showMessageForXSeconds('Sync successful.', 5)
}
fetchData();
await fetchData();
} catch (error) {
console.error('Error sync', error)
if (!silent) {
showMessageForXSeconds(error, 5)
}
} finally {
endSync()
}
}
@@ -422,6 +590,17 @@ function handleIntersection(entries, topbarHeight = 0) {
if (readFeeds.length === 0) return
if (isSyncing.value) {
// Don't mutate a list that's mid-reload — queue these ids instead of
// dropping them; flushPendingSuppressedReads() marks (and removes) them
// once every in-flight fetchData()/sync() has finished. Nothing here
// touched the DOM, so there's no observer disconnect/reconnect to do.
for (const feed of readFeeds) {
pendingSuppressedReadIds.add(feed.id)
}
return
}
// Disconnect before the DOM mutation. In card layout the cards are short
// enough that the shift caused by removing one can push the next card above
// the header, which the observer would immediately treat as another read —
@@ -502,22 +681,26 @@ async function markAllRead() {
function markCurrentArticleRead() {
const feed = feeds.value[currentIndex.value]
if (!feed) return
// Don't mark against a list that's mid-reload — see isSyncing/activeSyncCount.
// Queue this article's id instead of dropping it: flushPendingSuppressedReads()
// marks it once every in-flight fetchData()/sync() has finished.
if (isSyncing.value) {
pendingSuppressedReadIds.add(feed.id)
return
}
// Marking read here (rather than via removeFeed, as the scroll-based list
// view does) keeps the array stable so currentIndex stays valid while paging.
// The local `read` flag lets leaveArticleView() drop these once we're done.
if (feed) {
feed.read = true
markRead(feed.id)
}
feed.read = true
markRead(feed.id)
}
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,11 +771,11 @@ export function useFeeds() {
setFeedFilter,
showMessage,
message,
showModal,
viewMode,
currentIndex,
toggleViewMode,
leaveArticleView,
markCurrentArticleRead,
layout,
toggleLayout,
nextArticle,
@@ -609,5 +792,6 @@ export function useFeeds() {
handleIntersection,
lastProgrammaticScroll,
markProgrammaticScroll,
isSyncing,
}
}
+23
View File
@@ -0,0 +1,23 @@
import router from './router'
import { clearFeedFilter } from './composables/useFeeds'
// Extracted out of main.js so it's unit-testable in isolation: importing
// main.js itself boots the whole app (createApp(...).mount('#app')) and
// eagerly touches the router's lazy-loaded route components, neither of
// which a test can do safely.
//
// A 401 means the server has rejected the token (missing, expired, or
// revoked via logout/token_version bump elsewhere). Drop the stale session
// and send the user back to login rather than leaving them on a page where
// every request silently fails.
export function handleUnauthorizedResponse(error) {
if (error.response?.status === 401) {
localStorage.removeItem('user-token')
localStorage.removeItem('user-id')
clearFeedFilter()
if (router.currentRoute.value.name !== 'login') {
router.push({ name: 'login' })
}
}
return Promise.reject(error)
}
+2 -14
View File
@@ -4,23 +4,11 @@ import axios from 'axios'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { handleUnauthorizedResponse } from './httpAuthInterceptor'
// A 401 means the server has rejected the token (missing, expired, or
// revoked via logout/token_version bump elsewhere). Drop the stale session
// and send the user back to login rather than leaving them on a page where
// every request silently fails.
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('user-token')
localStorage.removeItem('user-id')
if (router.currentRoute.value.name !== 'login') {
router.push({ name: 'login' })
}
}
return Promise.reject(error)
}
handleUnauthorizedResponse
)
const app = createApp(App)