3 Commits
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
15 changed files with 2956 additions and 2720 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"] } reqwest = { version = "0.13", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
rss = { version = "2.0.13" } 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" actix-rt = "2.10"
futures = "0.3.31" futures = "0.3.31"
serde = { version = "1.0.228", features = ["alloc", "derive", "serde_derive"] } serde = { version = "1.0.228", features = ["alloc", "derive", "serde_derive"] }
+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", "jsdom": "^29.1.1",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"vite": "^8.0.16", "vite": "^8.0.16",
"vitest": "^4.1.8" "vitest": "^5.0.2"
} }
} }
@@ -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()
})
})
+54 -31
View File
@@ -2,29 +2,27 @@
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router' import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds' import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
import FeedFilter from './modal/FeedFilter.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds() const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
const headerRef = ref(null) 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 function openFilterModal() {
// 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
refreshUnreadDisplay() refreshUnreadDisplay()
filterModalOpen.value = true
} }
function onFilterChange(event) { // setFeedFilter() itself no-ops (order-insensitively) an unchanged selection,
setFeedFilter(event.target.value || null) // so there's nothing to check here before applying it.
filterOpen.value = false 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 // Scroll-driven show/hide: the header slides out of view on scroll-down and
@@ -166,18 +164,31 @@ function handleToggleLayout() {
class="app-nav__sync-spinner" class="app-nav__sync-spinner"
aria-hidden="true" aria-hidden="true"
></span> ></span>
<select <button
v-if="onFeedsPage && feedTitles.length" v-if="onFeedsPage && feedTitles.length"
class="app-nav__filter" ref="filterBtnRef"
:value="feedFilter ?? ''" class="app-nav__filter-btn"
aria-label="Filter by feed" :class="{ 'app-nav__filter-btn--active': feedFilter !== null }"
@focus="onFilterFocus" type="button"
@change="onFilterChange" aria-haspopup="dialog"
@blur="filterOpen = false" :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> <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">
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}<template v-if="filterOpen"> ({{ displayedFeedUnreadCounts[title] ?? 0 }})</template></option> <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</select> </svg>
</button>
<Teleport to="body">
<FeedFilter
:show="filterModalOpen"
:titles="feedTitles"
:counts="displayedFeedUnreadCounts"
:selected="feedFilter"
@close="handleFilterClose"
/>
</Teleport>
<button <button
class="app-nav__hamburger" class="app-nav__hamburger"
type="button" type="button"
@@ -283,25 +294,37 @@ function handleToggleLayout() {
} }
} }
.app-nav__filter { .app-nav__filter-btn {
flex-shrink: 1; display: inline-flex;
min-width: 0; align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
min-height: 44px; min-height: 44px;
max-width: clamp(90px, 40vw, 220px); min-width: 44px;
padding: 0.25rem 0.5rem; padding: 0.5rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 4px; border-radius: 4px;
background: transparent; background: transparent;
color: var(--color-text); color: var(--color-text);
font: inherit;
cursor: pointer; cursor: pointer;
text-overflow: ellipsis;
} }
.app-nav__filter:hover { .app-nav__filter-btn:hover {
border-color: var(--color-border-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 { .app-nav__hamburger {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+1 -3
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue'; import { onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds'; import { useFeeds } from '@/composables/useFeeds';
const { const {
@@ -20,8 +20,6 @@ const {
markCurrentArticleRead, markCurrentArticleRead,
} = useFeeds() } = useFeeds()
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
const shareLabel = navigator.share ? 'Share' : 'Copy link' const shareLabel = navigator.share ? 'Share' : 'Copy link'
function scrollToNextArticle() { function scrollToNextArticle() {
+132 -34
View File
@@ -4,6 +4,7 @@ import { nextTick } from 'vue'
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import axios from 'axios' import axios from 'axios'
import AppNav from '../AppNav.vue' import AppNav from '../AppNav.vue'
import FeedFilter from '../modal/FeedFilter.vue'
import { useFeeds } from '../../composables/useFeeds' import { useFeeds } from '../../composables/useFeeds'
vi.mock('axios') vi.mock('axios')
@@ -218,7 +219,7 @@ describe('AppNav', () => {
expect(wrapper.find('.app-nav__unread').exists()).toBe(false) 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() const { allItems } = useFeeds()
allItems.value = [ allItems.value = [
{ id: 1, feedTitle: 'Feed B', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-02' }, { id: 1, feedTitle: 'Feed B', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-02' },
@@ -228,41 +229,21 @@ describe('AppNav', () => {
const wrapper = mountNav() const wrapper = mountNav()
await flushPromises() await flushPromises()
const select = wrapper.find('.app-nav__filter')
// Counts only show while the dropdown is open (focused) — see AppNav.vue. await wrapper.find('.app-nav__filter-btn').trigger('click')
await select.trigger('focus')
const options = select.findAll('option').map(o => o.text()) const modal = wrapper.findComponent(FeedFilter)
// "All feeds" first, then distinct titles sorted alphabetically, each with expect(modal.props('show')).toBe(true)
// its unread count. // Distinct titles, sorted alphabetically, with per-feed unread counts.
expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)']) expect(modal.props('titles')).toEqual(['Feed A', 'Feed B'])
}) expect(modal.props('counts')).toEqual({ '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')
}) })
it('does not render the feed filter when there are no feeds', async () => { it('does not render the feed filter when there are no feeds', async () => {
const wrapper = mountNav() const wrapper = mountNav()
await flushPromises() 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 () => { it('applies the selected feed to the displayed list, and "All feeds" restores it', async () => {
@@ -275,14 +256,16 @@ describe('AppNav', () => {
const wrapper = mountNav() const wrapper = mountNav()
await flushPromises() await flushPromises()
const select = wrapper.find('.app-nav__filter') await wrapper.find('.app-nav__filter-btn').trigger('click')
await select.setValue('Feed A') const modal = wrapper.findComponent(FeedFilter)
modal.vm.$emit('close', ['Feed A'])
await flushPromises() await flushPromises()
expect(feedFilter.value).toBe('Feed A') expect(feedFilter.value).toEqual(['Feed A'])
expect(feeds.value.map(f => f.id)).toEqual([1]) 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() await flushPromises()
expect(feedFilter.value).toBeNull() expect(feedFilter.value).toBeNull()
@@ -303,13 +286,128 @@ describe('AppNav', () => {
// No filter → global total (3). // No filter → global total (3).
expect(wrapper.find('.app-nav__title').text()).toContain('(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() await flushPromises()
// Filtered → unread in Feed A only (2). // Filtered → unread in Feed A only (2).
expect(wrapper.find('.app-nav__title').text()).toContain('(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', () => { describe('sync spinner', () => {
it('does not render the spinner, and reserves no space for it, when idle', async () => { it('does not render the spinner, and reserves no space for it, when idle', async () => {
const wrapper = mountNav() const wrapper = mountNav()
@@ -351,7 +449,7 @@ describe('AppNav', () => {
const syncPromise = sync(true) const syncPromise = sync(true)
await nextTick() await nextTick()
expect(wrapper.find('.app-nav__filter').exists()).toBe(false) expect(wrapper.find('.app-nav__filter-btn').exists()).toBe(false)
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true) expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
axios.get.mockResolvedValueOnce({ data: { feeds: [] } }) axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
+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>
@@ -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()
})
})
+362 -17
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import axios from 'axios' import axios from 'axios'
import { useFeeds } from '../useFeeds' import { useFeeds, logout, clearFeedFilter } from '../useFeeds'
vi.mock('axios') vi.mock('axios')
@@ -17,6 +17,7 @@ describe('useFeeds', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7') localStorage.setItem('user-id', '7')
localStorage.removeItem('feedFilter')
vi.clearAllMocks() vi.clearAllMocks()
feeds.value = [] feeds.value = []
@@ -118,9 +119,9 @@ describe('useFeeds', () => {
setInitialLoad(false) 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 // 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. // computed so paging through article view doesn't force it to repaint.
// List-view scrolling is a different code path (handleIntersection) and // List-view scrolling is a different code path (handleIntersection) and
// should keep it live regardless. // should keep it live regardless.
@@ -658,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 () => { it('narrows the displayed list to a single feed and restores it on "All feeds"', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse) axios.get.mockResolvedValueOnce(twoFeedsResponse)
await fetchData() await fetchData()
@@ -667,7 +693,7 @@ describe('useFeeds', () => {
// Unfiltered: everything, newest first (Jan 3, Jan 2, Jan 1). // Unfiltered: everything, newest first (Jan 3, Jan 2, Jan 1).
expect(feeds.value.map(f => f.id)).toEqual([3, 2, 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]) expect(feeds.value.map(f => f.id)).toEqual([3, 1])
await setFeedFilter(null) await setFeedFilter(null)
@@ -679,7 +705,7 @@ describe('useFeeds', () => {
axios.put.mockResolvedValue({ status: 200 }) axios.put.mockResolvedValue({ status: 200 })
await fetchData() await fetchData()
await setFeedFilter('Feed A') await setFeedFilter(['Feed A'])
setInitialLoad(true) setInitialLoad(true)
// The first Feed A article scrolls above the viewport → marked read and // The first Feed A article scrolls above the viewport → marked read and
@@ -701,7 +727,7 @@ describe('useFeeds', () => {
axios.put.mockResolvedValue({ status: 200 }) axios.put.mockResolvedValue({ status: 200 })
await fetchData() await fetchData()
await setFeedFilter('Feed B') await setFeedFilter(['Feed B'])
expect(feeds.value.map(f => f.id)).toEqual([2]) expect(feeds.value.map(f => f.id)).toEqual([2])
// Article view marks the displayed article read in place (feed.read = // Article view marks the displayed article read in place (feed.read =
@@ -712,8 +738,8 @@ describe('useFeeds', () => {
// Switching to another feed and back must not resurrect it as if it // Switching to another feed and back must not resurrect it as if it
// were still unread. // were still unread.
await setFeedFilter('Feed A') await setFeedFilter(['Feed A'])
await setFeedFilter('Feed B') await setFeedFilter(['Feed B'])
expect(feeds.value).toEqual([]) expect(feeds.value).toEqual([])
}) })
@@ -727,14 +753,14 @@ describe('useFeeds', () => {
// Switching into Feed B while in article view immediately displays its // Switching into Feed B while in article view immediately displays its
// one article's full content — that counts as "viewed", so it's marked // one article's full content — that counts as "viewed", so it's marked
// read the same way toggleViewMode()/nextArticle()/prevArticle() do. // read the same way toggleViewMode()/nextArticle()/prevArticle() do.
await setFeedFilter('Feed B') await setFeedFilter(['Feed B'])
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything()) expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything())
expect(feeds.value[0].read).toBe(true) expect(feeds.value[0].read).toBe(true)
// It doesn't resurface either, once dropped on the next switch. // It doesn't resurface either, once dropped on the next switch.
await setFeedFilter('Feed A') await setFeedFilter(['Feed A'])
await setFeedFilter('Feed B') await setFeedFilter(['Feed B'])
expect(feeds.value).toEqual([]) expect(feeds.value).toEqual([])
}) })
@@ -744,7 +770,7 @@ describe('useFeeds', () => {
// List view only shows titles — switching filters there must not mark // List view only shows titles — switching filters there must not mark
// anything read; that's still driven solely by scroll (handleIntersection). // anything read; that's still driven solely by scroll (handleIntersection).
await setFeedFilter('Feed B') await setFeedFilter(['Feed B'])
expect(axios.put).not.toHaveBeenCalled() expect(axios.put).not.toHaveBeenCalled()
expect(feeds.value[0].read).toBeUndefined() expect(feeds.value[0].read).toBeUndefined()
@@ -755,7 +781,7 @@ describe('useFeeds', () => {
axios.put.mockResolvedValue({ status: 200 }) axios.put.mockResolvedValue({ status: 200 })
await fetchData() await fetchData()
await setFeedFilter('Feed B') await setFeedFilter(['Feed B'])
expect(feeds.value.map(f => f.id)).toEqual([2]) expect(feeds.value.map(f => f.id)).toEqual([2])
// Mark all (visible = just Feed B) read — Feed B has no unread items left. // Mark all (visible = just Feed B) read — Feed B has no unread items left.
@@ -763,10 +789,10 @@ describe('useFeeds', () => {
await markAllRead() await markAllRead()
// The list empties, but the filter stays put and the feed remains a // 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. // longer has unread items.
expect(feeds.value).toEqual([]) expect(feeds.value).toEqual([])
expect(feedFilter.value).toBe('Feed B') expect(feedFilter.value).toEqual(['Feed B'])
expect(feedTitles.value).toEqual(['Feed A', 'Feed B']) expect(feedTitles.value).toEqual(['Feed A', 'Feed B'])
// Switching away drops the now-empty feed from the options. // Switching away drops the now-empty feed from the options.
@@ -783,9 +809,9 @@ describe('useFeeds', () => {
expect(unreadCount.value).toBe(3) expect(unreadCount.value).toBe(3)
// Filtered: only the selected feed's unread items. // Filtered: only the selected feed's unread items.
await setFeedFilter('Feed B') await setFeedFilter(['Feed B'])
expect(unreadCount.value).toBe(1) expect(unreadCount.value).toBe(1)
await setFeedFilter('Feed A') await setFeedFilter(['Feed A'])
expect(unreadCount.value).toBe(2) expect(unreadCount.value).toBe(2)
await setFeedFilter(null) await setFeedFilter(null)
@@ -803,6 +829,325 @@ describe('useFeeds', () => {
allItems.value.find(i => i.id === 3).read = true allItems.value.find(i => i.id === 3).read = true
expect(feedUnreadCounts.value).toEqual({ 'Feed A': 1, 'Feed B': 1 }) 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 () => { it('strips leftover embedded-video placeholder headings', async () => {
+96 -18
View File
@@ -12,18 +12,38 @@ const showMessage = ref(false)
// the index-based observer / currentIndex logic has to know a filter exists. // the index-based observer / currentIndex logic has to know a filter exists.
const allItems = ref([]); const allItems = ref([]);
const feeds = ref([]); const feeds = ref([]);
const feedFilter = ref(null) // selected feedTitle, or null = all feeds (not persisted) // Selected feed titles, or null = all feeds. Persisted in localStorage (see
// Distinct feed titles present in the loaded (unread) items — the filter options. // setFeedFilter) so the selection survives a reload.
// The active filter is always kept in the list even once its feed runs out of function loadPersistedFeedFilter() {
// unread items, so the <select> never binds to a value that isn't an option const raw = localStorage.getItem('feedFilter')
// (it just shows "All caught up") and the feed stays re-selectable. 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 feedTitles = computed(() => {
const titles = new Set(allItems.value.map(i => i.feedTitle)) 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)) return [...titles].sort((a, b) => a.localeCompare(b))
}) })
// Unread count per feed title, shown next to each option in the filter // Unread count per feed title, shown next to each checkbox in the filter
// <select>. Derived from the master (allItems) so article view's // modal. Derived from the master (allItems) so article view's
// read-but-still-shown items are excluded. A title kept in feedTitles with no // 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. // unread items left has no key here — render it as 0.
const feedUnreadCounts = computed(() => { const feedUnreadCounts = computed(() => {
@@ -38,13 +58,12 @@ const feedUnreadCounts = computed(() => {
// rather than the displayed `feeds` so article view's read-but-still-shown // 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. // items are excluded and the count matches the filter regardless of view.
const unreadCount = computed(() => { const unreadCount = computed(() => {
const items = feedFilter.value if (!feedFilter.value) return allItems.value.filter(i => !i.read).length
? allItems.value.filter(i => i.feedTitle === feedFilter.value) const titles = new Set(feedFilter.value)
: allItems.value return allItems.value.filter(i => !i.read && titles.has(i.feedTitle)).length
return items.filter(i => !i.read).length
}) })
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below) // 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 // feedUnreadCounts directly — by design, this stays frozen while paging
// through article view (markCurrentArticleRead marks one article read per // through article view (markCurrentArticleRead marks one article read per
// page turn, which would otherwise recompute it live on every single swipe). // page turn, which would otherwise recompute it live on every single swipe).
@@ -163,6 +182,7 @@ export async function logout() {
} }
localStorage.removeItem('user-token') localStorage.removeItem('user-token')
localStorage.removeItem('user-id') localStorage.removeItem('user-id')
clearFeedFilter()
} }
// Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src` and various // Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src` and various
@@ -364,9 +384,12 @@ async function markRead(id) {
// feed with no remaining unread items projects to an empty list ("All caught // feed with no remaining unread items projects to an empty list ("All caught
// up") while staying selected — see feedTitles. // up") while staying selected — see feedTitles.
function applyFilter() { function applyFilter() {
feeds.value = feedFilter.value if (!feedFilter.value) {
? allItems.value.filter(i => i.feedTitle === feedFilter.value) feeds.value = allItems.value.slice()
: 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 // Article view marks an article read in place (feed.read = true) without
@@ -385,16 +408,42 @@ function dropReadArticles() {
allItems.value = allItems.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 // Changes the active feed filter and re-projects. Mirrors toggleLayout's
// observer/scroll-safe pattern: disconnect first, scroll to top, then re-point // observer/scroll-safe pattern: disconnect first, scroll to top, then re-point
// the observer at the new .observe nodes after Vue has re-rendered. Works in // the observer at the new .observe nodes after Vue has re-rendered. Works in
// article view too — there are no .observe nodes there so the setup is a no-op, // 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. // 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() disconnectObserver()
window.scrollTo(0, 0) window.scrollTo(0, 0)
dropReadArticles() dropReadArticles()
feedFilter.value = title // null for "All feeds" feedFilter.value = next
persistFeedFilter()
currentIndex.value = 0 currentIndex.value = 0
applyFilter() applyFilter()
// In article view the item at currentIndex is fully displayed, not just // In article view the item at currentIndex is fully displayed, not just
@@ -408,6 +457,34 @@ async function setFeedFilter(title) {
setupIntersectionObserver() setupIntersectionObserver()
} }
// 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 () => { const fetchData = async () => {
try { try {
beginSync() beginSync()
@@ -435,6 +512,7 @@ const fetchData = async () => {
freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
allItems.value = freshItems; allItems.value = freshItems;
applyFilter(); applyFilter();
await pruneFeedFilter();
refreshUnreadDisplay(); refreshUnreadDisplay();
await nextTick(); await nextTick();
setupIntersectionObserver(); setupIntersectionObserver();
+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 { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' 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( axios.interceptors.response.use(
(response) => response, (response) => response,
(error) => { handleUnauthorizedResponse
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)
}
) )
const app = createApp(App) const app = createApp(App)