Author SHA1 Message Date
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
mathiasandClaude Sonnet 5 5f816bb0f5 Fix unread counter stuck after reading all articles
markRead() fired its PUT fire-and-forget with no retry and silently
swallowed failures. If one PUT among a burst (paging through article
view fires one per page turn) failed or lost a race with the app's own
background sync()->fetchData() refetch, that article's read-mark was
permanently lost: the header's unreadCount (derived live from
allItems) would show it again mid-session, and a reload reproduced it
identically since the DB genuinely still had it unread.

- markRead() now retries transient failures (no response / 5xx / 429)
  with backoff and a request timeout, skips retrying non-retryable
  4xx, and surfaces a message on final failure instead of only
  console.log.
- A pendingReadCounts refcount (keyed by article id) tracks in-flight/
  retrying marks; fetchData() excludes those ids - snapshotting before
  its GET is dispatched, not just once the response lands - so a
  concurrent refetch can't resurrect an article whose read-confirmation
  is still in flight or unsettled at request time.
- markAllRead() now reports a partial-failure summary instead of
  unconditionally claiming full success when some marks fail.

Extends vue/src/composables/__tests__/useFeeds.spec.js with coverage
for retry/backoff, non-retryable errors, the dispatch-before-response
race, overlapping concurrent marks for the same id, and markAllRead's
partial-failure message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVw1XNQjodp4B7qiZqjtDE
2026-09-11 17:21:16 +02:00
mathiasandClaude Sonnet 5 67b970df9b Merge branch 'fix/concurrent-sync-duplicate-items'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CHcxDSPbHhe7sLJQBRVC9
2026-09-10 16:11:48 +02:00
mathiasandClaude Sonnet 5 6c8b4e9e32 Fix duplicate feed items from concurrent syncs
A sync button press racing a page-reload sync could run two sync
requests concurrently. create_feed_item used a check-then-insert
pattern (query for an existing item, then insert if none found), so
both requests could pass the check before either inserted, creating
duplicate items.

- Add a UNIQUE (feed_id, url) constraint on feed_item, keyed on the
  article's link rather than its title since that's RSS's stable
  article identity (a feed editing a headline after publishing, same
  link, would otherwise dupe under title-based matching).
- The migration first collapses any duplicates already created by the
  race, propagating read=true onto the surviving row when any
  duplicate in its group was already read, so the cleanup can't make
  an already-read article look unread.
- create_feed_item now does a single atomic
  INSERT ... ON CONFLICT (feed_id, url) DO NOTHING instead of
  select-then-insert, closing the race entirely.
- Add a test that races two threads on separate connections inserting
  the same item and asserts only one row survives.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CHcxDSPbHhe7sLJQBRVC9
2026-09-10 16:04:34 +02:00
mathias e2fb32f898 fix cargo audit reports and upgraded cargo dependencies 2026-08-29 14:28:59 +02:00
mathias a2c947f870 Change top bar to only appear whene scrolling up 2026-08-28 07:42:59 +02:00
mathias abb6002078 remove counter of selected feed 2026-08-28 07:37:42 +02:00
mathias f447a9a163 remove slide effect 2026-08-27 21:33:58 +02:00
mathias a26f31a150 bugfix: extend swipe listener to swipe on empty space 2026-08-27 14:25:38 +02:00
mathias c911258578 Revert "Merge branch 'swipe-drag-follow'"
This reverts commit d52fa90b6b, reversing
changes made to 77438d0532.
2026-08-26 22:07:58 +02:00
mathiasandClaude Sonnet 5 d52fa90b6b Merge branch 'swipe-drag-follow'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 22:04:01 +02:00
29 changed files with 2754 additions and 822 deletions
Generated
+73 -243
View File
@@ -251,14 +251,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.2"
version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
dependencies = [
"cssparser 0.35.0",
"html5ever 0.35.0",
"cssparser",
"html5ever",
"maplit",
"tendril 0.4.3",
"url",
]
@@ -323,15 +322,15 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "atom_syndication"
version = "0.12.8"
version = "0.12.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39d8b8ef99e33deb2a51504888222225797f48bace2f0cab01975746a39c05bb"
checksum = "d48d93a31c932d58b9fd68664a904ae5b74bea37f06c5f68d5bec92f0cdd106d"
dependencies = [
"chrono",
"derive_builder",
@@ -386,12 +385,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bcrypt"
version = "0.19.1"
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bcrypt"
version = "0.19.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"blowfish",
"getrandom 0.4.2",
"subtle",
@@ -506,9 +511,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -668,42 +673,19 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
dependencies = [
"cssparser-macros 0.6.1",
"dtoa-short",
"itoa",
"phf 0.11.3",
"smallvec",
]
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"cssparser-macros 0.7.0",
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "cssparser-macros"
version = "0.7.0"
@@ -963,10 +945,10 @@ dependencies = [
]
[[package]]
name = "dotenv"
version = "0.15.0"
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
@@ -1124,16 +1106,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.32"
@@ -1281,11 +1253,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasip2",
"wasip3",
"wasm-bindgen",
]
[[package]]
@@ -1332,9 +1306,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.14"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -1396,17 +1370,6 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "html5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"markup5ever 0.35.0",
"match_token",
]
[[package]]
name = "html5ever"
version = "0.39.0"
@@ -1414,7 +1377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever 0.39.0",
"markup5ever",
]
[[package]]
@@ -1492,7 +1455,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2 0.4.14",
"h2 0.4.19",
"http 1.4.1",
"http-body",
"httparse",
@@ -1899,29 +1862,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [
"log",
"tendril 0.4.3",
"web_atoms 0.1.3",
]
[[package]]
name = "markup5ever"
version = "0.39.0"
@@ -1929,19 +1875,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril 0.5.0",
"web_atoms 0.2.4",
]
[[package]]
name = "match_token"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn",
"tendril",
"web_atoms",
]
[[package]]
@@ -2073,55 +2008,25 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"phf_macros",
"phf_shared",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.6",
"phf_generator",
"phf_shared",
]
[[package]]
@@ -2131,20 +2036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
"phf_shared",
]
[[package]]
@@ -2153,22 +2045,13 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -2282,9 +2165,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.39.4"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"encoding_rs",
"memchr",
@@ -2312,15 +2195,16 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"getrandom 0.4.2",
"lru-slab",
"rand 0.9.4",
"rand 0.10.1",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
@@ -2367,15 +2251,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
@@ -2407,12 +2282,6 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
@@ -2428,6 +2297,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
@@ -2493,7 +2371,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2 0.4.14",
"h2 0.4.19",
"http 1.4.1",
"http-body",
"http-body-util",
@@ -2539,9 +2417,9 @@ dependencies = [
[[package]]
name = "rss"
version = "2.0.13"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e38781082c53bdde56e6081698c06831f69a27eac2593ed6b6cb2511424ad5"
checksum = "dc13570823abc675c60d7837f1fe7daaf18dd181a3cacb3c6ba7a062eef581c0"
dependencies = [
"atom_syndication",
"derive_builder",
@@ -2564,7 +2442,7 @@ dependencies = [
"dateparser",
"diesel",
"diesel_migrations",
"dotenv",
"dotenvy",
"env_logger",
"futures",
"hmac",
@@ -2713,13 +2591,13 @@ version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
dependencies = [
"cssparser 0.37.0",
"cssparser",
"ego-tree",
"getopts",
"html5ever 0.39.0",
"html5ever",
"precomputed-hash",
"selectors",
"tendril 0.5.0",
"tendril",
]
[[package]]
@@ -2752,12 +2630,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [
"bitflags",
"cssparser 0.37.0",
"cssparser",
"derive_more",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen 0.13.1",
"phf",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
@@ -2956,19 +2834,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -2977,30 +2842,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"phf_shared",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
@@ -3069,17 +2922,6 @@ dependencies = [
"libc",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]]
name = "tendril"
version = "0.5.0"
@@ -3573,28 +3415,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf 0.11.3",
"phf_codegen 0.11.3",
"string_cache 0.8.9",
"string_cache_codegen 0.5.4",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
"string_cache 0.9.0",
"string_cache_codegen 0.6.1",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
+1 -1
View File
@@ -18,7 +18,7 @@ serde_derive = "1.0.228"
actix-service = "2.0.3"
diesel = { version = "2.3", features = ["postgres", "chrono"] }
diesel_migrations = "2.3"
dotenv = "0.15.0"
dotenvy = "0.15"
bcrypt = "0.19"
uuid = {version = "1.23", features=["serde", "v4"]}
jwt = "0.16.0"
@@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
ALTER TABLE feed_item
DROP CONSTRAINT feed_item_feed_id_url_unique;
@@ -0,0 +1,36 @@
-- Your SQL goes here
-- Concurrent syncs for the same feed could previously race past the
-- application-level "does this article already exist" check and both
-- insert, producing duplicate feed items. Before enforcing uniqueness,
-- collapse any duplicates that already exist, keyed on the article's link
-- (rather than its title) since that's the stable identity RSS items are
-- built around — some feeds edit a headline after publishing while keeping
-- the same link, which title-based matching would treat as a new article.
-- If any duplicate in a group was already read, propagate that onto the
-- surviving (lowest-id) row first, so this one-time cleanup can't make an
-- already-read article look unread again.
UPDATE feed_item AS keep
SET read = true
FROM feed_item AS dupe
WHERE keep.feed_id = dupe.feed_id
AND keep.url = dupe.url
AND keep.id <> dupe.id
AND keep.id = (
SELECT MIN(candidate.id)
FROM feed_item AS candidate
WHERE candidate.feed_id = dupe.feed_id
AND candidate.url = dupe.url
)
AND dupe.read = true;
-- Keep the oldest (lowest id) row of each duplicate set.
DELETE FROM feed_item a
USING feed_item b
WHERE a.feed_id = b.feed_id
AND a.url = b.url
AND a.id > b.id;
ALTER TABLE feed_item
ADD CONSTRAINT feed_item_feed_id_url_unique UNIQUE (feed_id, url);
+1 -1
View File
@@ -6,7 +6,7 @@ use std::env;
use actix_web::HttpRequest;
use chrono::{Duration, Utc};
use dotenv::dotenv;
use dotenvy::dotenv;
use hmac::{Hmac, Mac};
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
use serde::{Deserialize, Serialize};
+1 -1
View File
@@ -1,7 +1,7 @@
use diesel::pg::PgConnection;
use diesel::prelude::*;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use dotenv::dotenv;
use dotenvy::dotenv;
use std::env;
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
+1 -2
View File
@@ -1,10 +1,9 @@
extern crate diesel;
extern crate dotenv;
use actix_cors::Cors;
use actix_service::Service;
use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
use dotenv::dotenv;
use dotenvy::dotenv;
use futures::future::{ok, Either};
use std::env;
mod auth;
+76 -19
View File
@@ -4,8 +4,7 @@ use crate::error::AppError;
use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::new_feed_item::NewFeedItem;
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::schema::feed_item::{feed_id, title};
use crate::schema::feed_item::{feed_id, url};
use crate::{
database::establish_connection,
schema::{
@@ -143,24 +142,29 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
}
}
let existing_item: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.filter(title.eq(&item_title))
.load(connection)?;
let new_feed_item = NewFeedItem::new(
feed.id,
content.clone(),
item_title.clone(),
item.link.expect("checked above"),
Some(time),
);
if existing_item.is_empty() {
let new_feed_item = NewFeedItem::new(
feed.id,
content.clone(),
item_title.clone(),
item.link.expect("checked above"),
Some(time),
);
let insert_result = diesel::insert_into(feed_item::table)
.values(&new_feed_item)
.execute(connection);
// `on_conflict` on the (feed_id, url) unique constraint makes this
// insert idempotent, so two syncs for the same feed running concurrently
// (e.g. a sync button press racing a page-reload sync) can't both pass a
// check-then-insert race and create duplicate items. Keying on the
// article's link rather than its title also handles feeds that edit a
// headline after publishing while keeping the same link — the link is
// the stable identity.
let inserted_rows = diesel::insert_into(feed_item::table)
.values(&new_feed_item)
.on_conflict((feed_id, url))
.do_nothing()
.execute(connection)?;
log::info!("Insert Result: {:?}", insert_result);
if inserted_rows > 0 {
log::info!("Inserted item: {}", item_title);
} else {
log::info!("Item {} already exists.", item_title);
}
@@ -210,10 +214,11 @@ pub async fn sync(
#[cfg(test)]
mod tests {
use crate::models::feed::new_feed::NewFeed;
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::models::user::new_user::NewUser;
use crate::models::user::rss_user::User;
use crate::schema::users;
use crate::test_helpers::unique_suffix;
use crate::test_helpers::{delete_feed, delete_user, insert_feed, insert_user, unique_suffix};
use chrono::Duration;
use super::*;
@@ -440,6 +445,58 @@ mod tests {
.ok();
}
#[actix_web::test]
async fn create_feed_item_is_race_safe_under_concurrent_syncs() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let user = insert_user(&mut connection, "secret");
let feed = insert_feed(&mut connection, user.id);
let mut item = Item::default();
item.set_title(Some(format!("Race test article {suffix}")));
item.set_link(Some(format!("https://example.test/race/{suffix}")));
item.set_content(Some("<p>Hello world</p>".to_string()));
// Simulate two concurrent syncs for the same feed (e.g. a sync
// button press racing a page-reload sync) — each on its own
// connection/thread, released together so they genuinely overlap —
// both inserting the same item.
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let handles: Vec<_> = (0..2)
.map(|_| {
let feed = feed.clone();
let item = item.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
let mut connection = establish_connection();
barrier.wait();
create_feed_item(item, &feed, &mut connection)
})
})
.collect();
for handle in handles {
handle.join().unwrap().unwrap();
}
let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.load(&mut connection)
.unwrap();
assert_eq!(
1,
items.len(),
"concurrent syncs must not create duplicate feed items"
);
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection)
.ok();
delete_feed(&mut connection, feed.id);
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn create_feed_item_strips_onerror_from_feed_image() {
let mut connection = establish_connection();
+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">
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;
+107 -42
View File
@@ -2,20 +2,38 @@
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, feedUnreadCounts, setFeedFilter, unreadCount, 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)
function openFilterModal() {
refreshUnreadDisplay()
filterModalOpen.value = true
}
// 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
// back in on scroll-up. This is show/hide via `transform` (not the old
// resize behaviour) — the header is position:fixed, so translating it never
// reflows content, and the app's programmatic scrolls resolve to sensible
// states: scrollTo(0, 0) lands near the top → shown; the list-view
// read-correction scrollBy moves only a few px → stays under the threshold.
// back in only on scroll-up — never merely by proximity to the top. This is
// show/hide via `transform` (not the old resize behaviour) — the header is
// position:fixed, so translating it never reflows content. The app's
// programmatic scrolls resolve to sensible states without any top-of-page
// special case: scrollTo(0, 0) (article paging, layout toggle) is itself a
// large upward jump, so it crosses REVEAL_THRESHOLD like a real scroll-up and
// reveals the header that way; the list-view read-correction scrollBy moves
// only a few px → stays under the threshold and leaves a hidden header hidden.
const hidden = ref(false)
const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
// When the feed list mutates itself (read-correction scrollBy + array-splice
@@ -30,12 +48,10 @@ let accumulated = 0
function onScroll() {
const y = Math.max(0, window.scrollY)
const headerH = headerRef.value?.offsetHeight ?? 0
// Always reveal near the very top, and keep it visible while the menu is
// open (the dropdown is anchored to the header, so hiding it would slide the
// open menu off-screen).
if (y <= headerH || menuOpen.value) {
// Keep the header visible while the menu is open (the dropdown is anchored
// to the header, so hiding it would slide the open menu off-screen).
if (menuOpen.value) {
hidden.value = false
accumulated = 0
lastY = y
@@ -78,6 +94,7 @@ onMounted(() => {
lastY = Math.max(0, window.scrollY)
window.addEventListener('scroll', onScrollRaf, { passive: true })
document.addEventListener('click', onDocumentClick)
refreshUnreadDisplay()
})
onUnmounted(() => {
@@ -127,11 +144,6 @@ function handleMarkAllRead() {
closeMenu()
}
function openAddModal() {
showModal.value = true
closeMenu()
}
function handleToggleViewMode() {
toggleViewMode()
closeMenu()
@@ -147,16 +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"
@change="setFeedFilter($event.target.value || null)"
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 }} ({{ feedUnreadCounts[title] ?? 0 }})</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"
@@ -190,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>
@@ -226,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;
@@ -234,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);
}
@@ -244,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;
+47 -175
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds';
const {
@@ -17,6 +17,7 @@ const {
disconnectObserver,
setInitialLoad,
showMessageForXSeconds,
markCurrentArticleRead,
} = useFeeds()
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
@@ -34,43 +35,14 @@ function scrollToNextArticle() {
}
}
// Swipe-to-navigate in article view. The article visually tracks the finger
// while dragging (dragOffsetX/isDragging below), then either completes the
// page-turn or springs back to center on release. This is transient
// per-gesture state, not shared app state, so it's kept local to this
// component rather than in useFeeds() — nothing outside this component needs
// it.
const dragOffsetX = ref(0) // live translateX (px) applied to the current article while dragging or springing back
const isDragging = ref(false) // true only while actively tracking touchmove — disables the CSS transition so the article follows the finger with zero lag; the spring-back and the eventual page-turn both happen after this flips back to false, so they use the (slower, already-tuned) CSS transitions instead
// Swipe-to-navigate in article view. Transient per-gesture state, not shared
// app state, so it's kept as plain component-local variables rather than in
// useFeeds() — nothing outside this component needs it.
let touchStartX = null
let touchStartY = null
let touchStartTime = null
let gestureAxis = null // 'horizontal' | 'vertical' | null (undecided) — locked in after DIRECTION_LOCK_THRESHOLD of movement, so a single gesture can't both page the article and scroll the page
let articleWidth = 0 // captured on touchstart; the live drag and the commit-distance check below are relative to it
let dragRafPending = false
let pendingDragOffset = 0
const DIRECTION_LOCK_THRESHOLD = 10 // px of movement before an ambiguous drag commits to horizontal (swipe) or vertical (scroll)
const COMMIT_DISTANCE_RATIO = 0.35 // fraction of the article's width dragged that counts as "far enough" to complete the page-turn
const COMMIT_VELOCITY = 0.5 // px/ms — a flick at least this fast completes the page-turn even short of the distance ratio
const BOUNDARY_DAMPING = 0.35 // drag multiplier applied when dragging past the first/last article, so it still moves a little (feedback) but resists
// Drives which slide-in/slide-out transition ('slide-next' / 'slide-prev') the
// article view uses — set right before the index actually changes, by both
// the nav buttons and the swipe handler below, so the two triggers animate
// identically.
const slideDirection = ref('next')
function navigateToNextArticle() {
slideDirection.value = 'next'
nextArticle()
}
function navigateToPrevArticle() {
slideDirection.value = 'prev'
prevArticle()
}
const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe
const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe
function onArticleTouchStart(event) {
// Ignore multi-touch (pinch-zoom) and swipes starting inside a
@@ -78,79 +50,27 @@ function onArticleTouchStart(event) {
// — those should scroll the block, not page the article.
if (event.touches.length !== 1 || event.target.closest('pre')) {
touchStartX = null
touchStartY = null
return
}
const touch = event.touches[0]
touchStartX = touch.clientX
touchStartY = touch.clientY
touchStartTime = Date.now()
gestureAxis = null
const article = event.currentTarget.querySelector('.article-feature')
articleWidth = article ? article.offsetWidth : window.innerWidth
}
function onArticleTouchMove(event) {
if (touchStartX === null || event.touches.length !== 1) return
const touch = event.touches[0]
const deltaX = touch.clientX - touchStartX
const deltaY = touch.clientY - touchStartY
if (gestureAxis === null) {
if (Math.abs(deltaX) < DIRECTION_LOCK_THRESHOLD && Math.abs(deltaY) < DIRECTION_LOCK_THRESHOLD) return
gestureAxis = Math.abs(deltaX) > Math.abs(deltaY) ? 'horizontal' : 'vertical'
if (gestureAxis === 'horizontal') isDragging.value = true
}
if (gestureAxis !== 'horizontal') return // let the page scroll normally instead
// Stop the page from also scrolling vertically (and the browser's own
// horizontal-overscroll gesture) while we're driving the drag ourselves.
// Requires this listener to not be passive.
event.preventDefault()
const atBoundary = (deltaX < 0 && currentIndex.value === feeds.value.length - 1) ||
(deltaX > 0 && currentIndex.value === 0)
pendingDragOffset = atBoundary ? deltaX * BOUNDARY_DAMPING : deltaX
// rAF-throttled: touchmove can fire faster than the display refreshes. If a
// frame is already scheduled, later events in that window just update
// pendingDragOffset above — the callback always reads the latest value
// once it actually runs, rather than whatever triggered the first one.
if (dragRafPending) return
dragRafPending = true
requestAnimationFrame(() => {
dragOffsetX.value = pendingDragOffset
dragRafPending = false
})
touchStartX = event.touches[0].clientX
touchStartY = event.touches[0].clientY
}
function onArticleTouchEnd(event) {
if (touchStartX === null) return
const wasDraggingHorizontally = gestureAxis === 'horizontal'
// Computed fresh from the release position rather than read back from
// dragOffsetX, which is rAF-throttled and so can be a frame stale.
const deltaX = event.changedTouches[0].clientX - touchStartX
const elapsed = Date.now() - touchStartTime
const touch = event.changedTouches[0]
const deltaX = touch.clientX - touchStartX
const deltaY = touch.clientY - touchStartY
touchStartX = null
touchStartY = null
gestureAxis = null
isDragging.value = false
if (!wasDraggingHorizontally) return
// Date.now() only has ~1ms resolution, so a genuinely fast flick often
// measures as elapsed === 0 — treat that as "as fast as it gets" (clears
// the flick check) rather than dividing by zero into "no velocity at all".
const isFastFlick = elapsed > 0 ? Math.abs(deltaX / elapsed) > COMMIT_VELOCITY : deltaX !== 0
const shouldComplete = Math.abs(deltaX) > articleWidth * COMMIT_DISTANCE_RATIO || isFastFlick
dragOffsetX.value = 0 // springs back to center — or, if completing, the (now off-screen-bound) article being replaced
if (!shouldComplete) return
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
if (deltaX < 0) {
navigateToNextArticle()
nextArticle()
} else {
navigateToPrevArticle()
prevArticle()
}
}
@@ -203,6 +123,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)
@@ -246,13 +171,7 @@ onMounted(async () => {
>&darr;</button>
</div>
<div
v-else
class="article-single"
@touchstart.passive="onArticleTouchStart"
@touchmove="onArticleTouchMove"
@touchend.passive="onArticleTouchEnd"
>
<div v-else class="article-single" @touchstart.passive="onArticleTouchStart" @touchend.passive="onArticleTouchEnd">
<div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
@@ -260,25 +179,22 @@ onMounted(async () => {
</svg>
<p class="empty-state__label">All caught up</p>
</div>
<template v-else>
<Transition :name="`slide-${slideDirection}`">
<article
class="article-feature"
:class="{ 'article-feature--dragging': isDragging }"
:style="dragOffsetX ? { transform: `translateX(${dragOffsetX}px)` } : null"
:key="currentIndex"
>
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
<p class="feed-original-link">
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
</p>
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
</article>
</Transition>
</template>
<!-- Deliberately no enter/leave transition here: Vue's <Transition>
component forces a synchronous whole-document reflow
(document.body.offsetHeight, in @vue/runtime-dom's forceReflow) on
every enter/leave, regardless of the app's own CSS — confirmed (via
a DevTools performance trace) to be what was corrupting the header's
native <select>/<button> rendering during article paging. -->
<article v-else class="article-feature" :key="currentIndex">
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
<p class="feed-original-link">
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
</p>
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
</article>
<div class="article-nav">
<button
@@ -286,14 +202,14 @@ onMounted(async () => {
class="article-nav__btn"
:disabled="currentIndex === 0"
aria-label="Previous article"
@click="navigateToPrevArticle"
@click="prevArticle"
>&uarr;</button>
<button
type="button"
class="article-nav__btn"
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
aria-label="Next article"
@click="navigateToNextArticle"
@click="nextArticle"
>&darr;</button>
</div>
</div>
@@ -443,62 +359,18 @@ onMounted(async () => {
align-items: center;
padding-top: 1em;
padding-bottom: 5rem;
/* Fill the viewport below the fixed header even when the article content
is short, so swipe-to-navigate works anywhere on screen instead of only
over the (possibly small) content area. dvh is the accurate unit on
mobile, where this matters most; vh is the fallback for browsers
without dvh support. */
min-height: calc(100vh - var(--app-nav-height, 4.5rem));
min-height: calc(100dvh - var(--app-nav-height, 4.5rem));
}
.article-feature {
width: 100%;
max-width: 720px;
/* Governs the drag-release spring-back (dragOffsetX snapping to 0 after a
cancelled swipe). Overridden by .article-feature--dragging (below, so it
wins the tie) while actively tracking a touchmove, and by the
.slide-next/.slide-prev *-active rules (further below, so they win too)
during an actual page-turn — both need their own transform transition
timing instead. */
transition: transform 0.2s ease;
}
.article-feature--dragging {
transition: none;
}
/* Slide-in/out when paging between articles (swipe or nav buttons). The
outgoing article is pulled out of flex flow during its leave transition so
the incoming one can take its place immediately, instead of the two
stacking and doubling the page height for the transition's duration. */
.slide-next-enter-active,
.slide-next-leave-active,
.slide-prev-enter-active,
.slide-prev-leave-active {
transition: transform 0.28s ease, opacity 0.28s ease;
}
.slide-next-leave-active,
.slide-prev-leave-active {
position: absolute;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
}
.slide-next-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-next-leave-to {
transform: translateX(-100%);
opacity: 0;
}
.slide-prev-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.slide-prev-leave-to {
transform: translateX(100%);
opacity: 0;
}
.article-feature__source {
@@ -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')
})
})
+215 -28
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' },
@@ -240,17 +230,20 @@ describe('AppNav', () => {
const wrapper = mountNav()
await flushPromises()
const options = wrapper.find('.app-nav__filter').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)'])
await wrapper.find('.app-nav__filter-btn').trigger('click')
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 () => {
@@ -263,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()
@@ -291,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
@@ -306,9 +466,17 @@ describe('AppNav', () => {
// Reset scroll position so each mount's lastY baseline starts at 0.
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
// offsetHeight is 0 in jsdom; give the header a real height so the
// "near the top" guard (scrollY <= headerH) has something to compare to.
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(50)
// getBoundingClientRect() is all-zero in jsdom; give the header a real
// height so onMounted()'s one-time measurement (used for the
// --app-nav-height CSS var — see AppNav.vue) has a realistic value.
// This is a prototype-wide spy (scoped to this describe block only, restored in
// afterEach), so it returns a full DOMRect-shaped object rather than a
// partial one — no test here happens to call getBoundingClientRect() on
// anything but the header, but a bare `{ height: 50 }` would silently
// hand back `undefined` for top/left/width/etc. to any code that did.
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
height: 50, width: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {},
})
})
afterEach(() => {
@@ -341,19 +509,38 @@ describe('AppNav', () => {
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
})
it('always shows the header near the top of the page', async () => {
it('reveals the header on a large upward jump that lands near the top', async () => {
const wrapper = mountNav()
scrollTo(400)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// Back within the header's own height of the top → always revealed.
// A jump this large (e.g. article paging's scrollTo(0, 0)) crosses
// REVEAL_THRESHOLD on its own — no top-of-page special case needed.
scrollTo(10)
await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
})
it('does not reveal the header merely for landing near the top', async () => {
const wrapper = mountNav()
// Hide it with a jump just past the header's own height (50px, mocked
// above) plus the reveal threshold.
scrollTo(62)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// Move back to right at the header's own height — within the "near the
// top" zone the old code always revealed for — but the upward delta
// (12px) doesn't exceed REVEAL_THRESHOLD, so a hidden header stays
// hidden: proximity to the top is no longer sufficient on its own.
scrollTo(50)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
})
it('does not let a programmatic upward jump reveal the header mid-read', async () => {
const { markProgrammaticScroll } = useFeeds()
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000)
+160 -244
View File
@@ -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'
@@ -360,261 +359,178 @@ describe('RssFeeds', () => {
expect(titles).toEqual(['Article three'])
})
describe('swipe navigation in article view', () => {
// The drag-follow handler is rAF-throttled (dragOffsetX only updates once
// per frame) and reads the article's real offsetWidth for the
// commit-distance check — stub both so gestures are deterministic. Per
// the CLAUDE.md Vitest gotcha, avoid bare fake timers here since they'd
// also fake rAF and clobber this manual stub. (Faking Date, even scoped,
// was tried for the release-velocity check below and broke test-utils'
// event dispatch outright — real small setTimeout delays instead.)
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(400)
it('swipes left to navigate to the next article', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
wrapper.unmount()
})
it('swipes right to navigate to the previous article', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 130, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 200, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a swipe shorter than the horizontal threshold', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 180, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a mostly-vertical drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 250 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a diagonal drag that exceeds the vertical restraint, even with a qualifying horizontal delta', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 200 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('ignores a multi-touch gesture (pinch-zoom)', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', {
touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }],
})
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
afterEach(() => {
// Not vi.unstubAllGlobals(): it would also remove the file-level
// IntersectionObserver stub (stubGlobal isn't describe-scoped), and
// every test in here re-stubs requestAnimationFrame in beforeEach
// anyway, so there's nothing to reset it back to for.
vi.restoreAllMocks()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate when a swipe starts inside a code block', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{ id: 1, title: 'Article one', content: '<pre>some code</pre>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
],
},
],
},
})
axios.put.mockResolvedValue({ status: 200 })
// Drives one full touchstart -> touchmove -> touchend gesture. delayMs is
// a genuine wall-clock wait before releasing — it's how tests control the
// release velocity (deltaX / elapsed) the component computes, alongside
// the distance dragged.
async function swipe(wrapper, { startX, startY = 100, endX, endY = 100, delayMs = 0 }) {
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: startX, clientY: startY }] })
await article.trigger('touchmove', { touches: [{ clientX: endX, clientY: endY }] })
if (delayMs > 0) {
await new Promise(resolve => setTimeout(resolve, delayMs))
}
await article.trigger('touchend', { changedTouches: [{ clientX: endX, clientY: endY }] })
await flushPromises()
}
const wrapper = mount(RssFeeds)
await flushPromises()
it('drags the article to follow the finger while swiping', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
useFeeds().toggleViewMode()
await flushPromises()
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchmove', { touches: [{ clientX: 150, clientY: 100 }] })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
const dragged = wrapper.find('.article-feature')
expect(dragged.classes()).toContain('article-feature--dragging')
expect(dragged.attributes('style')).toContain('translateX(-50px)')
wrapper.unmount()
})
wrapper.unmount()
})
it('does not throw when swiping with an empty feed list', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
it('completes the page-turn on a swipe past the distance threshold', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
// 400px-wide article (mocked above), 35% commit ratio -> needs >140px.
await swipe(wrapper, { startX: 300, endX: 80 })
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
expect(wrapper.text()).toContain('All caught up')
wrapper.unmount()
})
it('completes the page-turn backwards on a swipe past the distance threshold', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
await swipe(wrapper, { startX: 80, endX: 300 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('springs back without navigating on a short, slow drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
// Only 20px of a 400px-wide article (well under the 140px distance
// threshold), released slowly enough that velocity doesn't qualify
// either (well under the 0.5px/ms flick threshold).
await swipe(wrapper, { startX: 200, endX: 180, delayMs: 150 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
expect(wrapper.find('.article-feature').classes()).not.toContain('article-feature--dragging')
wrapper.unmount()
})
it('completes the page-turn on a fast flick even under the distance threshold', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
// Only 60px (well under the 140px distance threshold), released with
// no extra delay. Date.now() only has ~1ms resolution, so a gesture
// this fast typically measures as 0ms elapsed — the component treats
// that as clearing the flick check outright (see the comment by
// isFastFlick in onArticleTouchEnd) rather than as "no velocity".
await swipe(wrapper, { startX: 200, endX: 140 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
wrapper.unmount()
})
it('does not drag or navigate on a mostly-vertical drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await swipe(wrapper, { startX: 200, startY: 100, endX: 205, endY: 250 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate when the vertical component dominates a diagonal drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
// More horizontal travel (70px) than in the previous test, but the
// vertical component (100px) is still larger — locks as a vertical
// scroll, not a swipe, regardless of the sizeable deltaX.
await swipe(wrapper, { startX: 200, startY: 100, endX: 130, endY: 200 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('applies resistance when dragging past the last article', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.findAll('.article-nav__btn')[1].trigger('click') // now on the last article
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 300, clientY: 100 }] })
await article.trigger('touchmove', { touches: [{ clientX: 200, clientY: 100 }] }) // still swiping towards "next"
// Damped to 35% of the raw -100px drag, not the full amount.
expect(wrapper.find('.article-feature').attributes('style')).toContain('translateX(-35px)')
wrapper.unmount()
})
it('ignores a multi-touch gesture (pinch-zoom)', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', {
touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }],
})
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not drag or navigate when a swipe starts inside a code block', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{ id: 1, title: 'Article one', content: '<pre>some code</pre>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
],
},
],
},
})
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await wrapper.find('.article-single').trigger('touchmove', { touches: [{ clientX: 130, clientY: 100 }] })
await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
expect(wrapper.find('.article-feature').classes()).not.toContain('article-feature--dragging')
wrapper.unmount()
})
it('does not throw when swiping with an empty feed list', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await swipe(wrapper, { startX: 200, endX: 80 })
expect(wrapper.text()).toContain('All caught up')
wrapper.unmount()
})
wrapper.unmount()
})
})
+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()
})
})
File diff suppressed because it is too large Load Diff
@@ -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()
})
+328 -38
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,26 @@ 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 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).
// The header's own unread badge stays bound to the live unreadCount (it
// should update in real time regardless of view). Everywhere else that marks
// articles read — list-view scrolling (handleIntersection), opening/changing
// the filter, syncing, mark-all-read, leaving article view — refreshes this
// snapshot immediately, so it only ever looks stale during article-view
// paging itself.
const displayedFeedUnreadCounts = ref({})
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
@@ -61,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
@@ -92,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
@@ -200,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)
@@ -213,12 +309,74 @@ async function getReadable(feed, index) {
}
}
// Ids with a mark-read PUT in flight (or being retried) — see markRead() and
// fetchData() below. A refcount rather than a plain Set: nextArticle()/
// prevArticle() can both re-mark the same article (page forward, back,
// forward again), so two overlapping markRead() calls for one id must both
// finish before fetchData() is allowed to trust the server's word on it —
// otherwise the first call to settle would clear the id out from under the
// second, still-in-flight one.
const pendingReadCounts = new Map()
function addPendingRead(id) {
pendingReadCounts.set(id, (pendingReadCounts.get(id) ?? 0) + 1)
}
function removePendingRead(id) {
const count = pendingReadCounts.get(id) ?? 0
if (count <= 1) {
pendingReadCounts.delete(id)
} else {
pendingReadCounts.set(id, count - 1)
}
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
// Short backoff between retries of a failed mark-read PUT — each entry is the
// wait before that retry attempt.
const MARK_READ_RETRY_DELAYS_MS = [300, 1000]
// Bounds how long a hung request (dead connection, captive portal — axios has
// no default timeout) can keep an id "pending": without this, a request that
// never settles would never leave pendingReadCounts, silently withholding
// that article from every fetchData() for the rest of the session.
const MARK_READ_TIMEOUT_MS = 10000
// 4xx responses (expired token, item not found/not owned — see
// src/reader/mark_read.rs) mean the request itself is wrong and won't
// succeed on retry; only a missing response (network error, timeout) or a
// server-side/rate-limit status is worth retrying.
function isRetryableMarkReadError(error) {
const status = error.response?.status
return status === undefined || status >= 500 || status === 429
}
// Resolves to true once the article is confirmed read server-side, or false
// once retries (if any) are exhausted / the error isn't retryable.
async function markRead(id) {
addPendingRead(id)
try {
const response = await axios.put("/api/v1/article/read/" + id, null, authHeaders())
console.log(response.status)
} catch (error) {
console.log(error)
for (let attempt = 0; ; attempt++) {
try {
await axios.put("/api/v1/article/read/" + id, null, { ...authHeaders(), timeout: MARK_READ_TIMEOUT_MS })
return true
} catch (error) {
console.error('Error marking article read:', error)
if (attempt >= MARK_READ_RETRY_DELAYS_MS.length || !isRetryableMarkReadError(error)) {
// Out of retries, or a retry can't help — this mark never committed
// server-side, so the item genuinely is still unread there. Surface
// it rather than letting it silently vanish from the local list now
// only to mysteriously reappear as unread later.
showMessageForXSeconds('Could not mark an article as read. It may reappear as unread.', 5)
return false
}
await delay(MARK_READ_RETRY_DELAYS_MS[attempt])
}
}
} finally {
removePendingRead(id)
}
}
@@ -226,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
@@ -236,38 +433,99 @@ 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
// cleared by the time the response arrives) even though the server read
// the DB before that commit and so still reported the item unread — this
// snapshot catches that case too.
const pendingBeforeFetch = new Set(pendingReadCounts.keys())
const response = await axios.get("/api/v1/article/get/" + user_id, authHeaders());
const items = [];
response.data.feeds.forEach(feed => {
feed.items.forEach(item => items.push({ ...item, feedTitle: feed.title }));
});
// An item pending at either end of this request may not have committed
// server-side yet, so the server can still report it unread here. Drop it
// from this snapshot too — otherwise a concurrent background sync's
// refetch (see sync()) would resurrect it as unread out from under the
// user while markRead() is still confirming or retrying it.
const freshItems = items.filter(item => !pendingBeforeFetch.has(item.id) && !pendingReadCounts.has(item.id));
// timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
// lexicographic comparison sorts them chronologically.
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
allItems.value = items;
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"))
@@ -276,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()
}
}
@@ -330,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 —
@@ -346,6 +617,7 @@ function handleIntersection(entries, topbarHeight = 0) {
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
// Mirror into the master so cleared filters don't resurrect read articles.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
refreshUnreadDisplay()
for (const feed of readFeeds) {
markRead(feed.id)
@@ -392,32 +664,47 @@ async function markAllRead() {
// is active) — drop exactly those from the master too.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
currentIndex.value = 0
// markRead swallows its own errors, so Promise.all can't reject here.
await Promise.all(ids.map(id => markRead(id)))
showMessageForXSeconds('All articles marked as read.', 5)
refreshUnreadDisplay()
// markRead() resolves to true/false rather than rejecting, so Promise.all
// can't reject here — but a false means that article is still unread
// server-side, which the message below must not contradict.
const results = await Promise.all(ids.map(id => markRead(id)))
const failedCount = results.filter(ok => !ok).length
if (failedCount === 0) {
showMessageForXSeconds('All articles marked as read.', 5)
} else {
// markRead() already surfaced each individual failure — this summarizes
// the batch outcome instead of claiming full success over it.
showMessageForXSeconds(`Marked ${ids.length - failedCount} of ${ids.length} as read; ${failedCount} failed and may reappear.`, 5)
}
}
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)
refreshUnreadDisplay()
// The v-if on the list container tears down and recreates all .observe DOM
// nodes when switching views, so the intersection observer must be
// re-pointed at the new elements after Vue has finished rendering.
@@ -479,14 +766,16 @@ export function useFeeds() {
feedTitles,
feedUnreadCounts,
unreadCount,
displayedFeedUnreadCounts,
refreshUnreadDisplay,
setFeedFilter,
showMessage,
message,
showModal,
viewMode,
currentIndex,
toggleViewMode,
leaveArticleView,
markCurrentArticleRead,
layout,
toggleLayout,
nextArticle,
@@ -503,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)