Author SHA1 Message Date
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
19 changed files with 1498 additions and 758 deletions
Generated
+73 -243
View File
@@ -251,14 +251,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "ammonia" name = "ammonia"
version = "4.1.2" version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
dependencies = [ dependencies = [
"cssparser 0.35.0", "cssparser",
"html5ever 0.35.0", "html5ever",
"maplit", "maplit",
"tendril 0.4.3",
"url", "url",
] ]
@@ -323,15 +322,15 @@ dependencies = [
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]] [[package]]
name = "atom_syndication" name = "atom_syndication"
version = "0.12.8" version = "0.12.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39d8b8ef99e33deb2a51504888222225797f48bace2f0cab01975746a39c05bb" checksum = "d48d93a31c932d58b9fd68664a904ae5b74bea37f06c5f68d5bec92f0cdd106d"
dependencies = [ dependencies = [
"chrono", "chrono",
"derive_builder", "derive_builder",
@@ -386,12 +385,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bcrypt" name = "base64"
version = "0.19.1" version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" 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 = [ dependencies = [
"base64 0.22.1", "base64 0.23.1",
"blowfish", "blowfish",
"getrandom 0.4.2", "getrandom 0.4.2",
"subtle", "subtle",
@@ -506,9 +511,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.10.0" version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.3.0", "cpufeatures 0.3.0",
@@ -668,42 +673,19 @@ dependencies = [
"hybrid-array", "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]] [[package]]
name = "cssparser" name = "cssparser"
version = "0.37.0" version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [ dependencies = [
"cssparser-macros 0.7.0", "cssparser-macros",
"dtoa-short", "dtoa-short",
"itoa", "itoa",
"phf 0.13.1", "phf",
"smallvec", "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]] [[package]]
name = "cssparser-macros" name = "cssparser-macros"
version = "0.7.0" version = "0.7.0"
@@ -963,10 +945,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "dotenv" name = "dotenvy"
version = "0.15.0" version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]] [[package]]
name = "downcast-rs" name = "downcast-rs"
@@ -1124,16 +1106,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" 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]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@@ -1281,11 +1253,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1", "rand_core 0.10.1",
"wasip2", "wasip2",
"wasip3", "wasip3",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1332,9 +1306,9 @@ dependencies = [
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.14" version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -1396,17 +1370,6 @@ dependencies = [
"digest 0.10.7", "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]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.39.0" version = "0.39.0"
@@ -1414,7 +1377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [ dependencies = [
"log", "log",
"markup5ever 0.39.0", "markup5ever",
] ]
[[package]] [[package]]
@@ -1492,7 +1455,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2 0.4.14", "h2 0.4.19",
"http 1.4.1", "http 1.4.1",
"http-body", "http-body",
"httparse", "httparse",
@@ -1899,29 +1862,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "maplit" name = "maplit"
version = "1.0.2" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" 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]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.39.0" version = "0.39.0"
@@ -1929,19 +1875,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [ dependencies = [
"log", "log",
"tendril 0.5.0", "tendril",
"web_atoms 0.2.4", "web_atoms",
]
[[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",
] ]
[[package]] [[package]]
@@ -2073,55 +2008,25 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 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]] [[package]]
name = "phf" name = "phf"
version = "0.13.1" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [ dependencies = [
"phf_macros 0.13.1", "phf_macros",
"phf_shared 0.13.1", "phf_shared",
"serde", "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]] [[package]]
name = "phf_codegen" name = "phf_codegen"
version = "0.13.1" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [ dependencies = [
"phf_generator 0.13.1", "phf_generator",
"phf_shared 0.13.1", "phf_shared",
]
[[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",
] ]
[[package]] [[package]]
@@ -2131,20 +2036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"phf_shared 0.13.1", "phf_shared",
]
[[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",
] ]
[[package]] [[package]]
@@ -2153,22 +2045,13 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [ dependencies = [
"phf_generator 0.13.1", "phf_generator",
"phf_shared 0.13.1", "phf_shared",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn",
] ]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "phf_shared" name = "phf_shared"
version = "0.13.1" version = "0.13.1"
@@ -2282,9 +2165,9 @@ dependencies = [
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.39.4" version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [ dependencies = [
"encoding_rs", "encoding_rs",
"memchr", "memchr",
@@ -2312,15 +2195,16 @@ dependencies = [
[[package]] [[package]]
name = "quinn-proto" name = "quinn-proto"
version = "0.11.14" version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.4.2",
"lru-slab", "lru-slab",
"rand 0.9.4", "rand 0.10.1",
"rand_pcg",
"ring", "ring",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
@@ -2367,15 +2251,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 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]] [[package]]
name = "rand" name = "rand"
version = "0.9.4" version = "0.9.4"
@@ -2407,12 +2282,6 @@ dependencies = [
"rand_core 0.9.5", "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]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.9.5"
@@ -2428,6 +2297,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" 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]] [[package]]
name = "raw-cpuid" name = "raw-cpuid"
version = "11.6.0" version = "11.6.0"
@@ -2493,7 +2371,7 @@ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2 0.4.14", "h2 0.4.19",
"http 1.4.1", "http 1.4.1",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -2539,9 +2417,9 @@ dependencies = [
[[package]] [[package]]
name = "rss" name = "rss"
version = "2.0.13" version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e38781082c53bdde56e6081698c06831f69a27eac2593ed6b6cb2511424ad5" checksum = "dc13570823abc675c60d7837f1fe7daaf18dd181a3cacb3c6ba7a062eef581c0"
dependencies = [ dependencies = [
"atom_syndication", "atom_syndication",
"derive_builder", "derive_builder",
@@ -2564,7 +2442,7 @@ dependencies = [
"dateparser", "dateparser",
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
"dotenv", "dotenvy",
"env_logger", "env_logger",
"futures", "futures",
"hmac", "hmac",
@@ -2713,13 +2591,13 @@ version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
dependencies = [ dependencies = [
"cssparser 0.37.0", "cssparser",
"ego-tree", "ego-tree",
"getopts", "getopts",
"html5ever 0.39.0", "html5ever",
"precomputed-hash", "precomputed-hash",
"selectors", "selectors",
"tendril 0.5.0", "tendril",
] ]
[[package]] [[package]]
@@ -2752,12 +2630,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"cssparser 0.37.0", "cssparser",
"derive_more", "derive_more",
"log", "log",
"new_debug_unreachable", "new_debug_unreachable",
"phf 0.13.1", "phf",
"phf_codegen 0.13.1", "phf_codegen",
"precomputed-hash", "precomputed-hash",
"rustc-hash", "rustc-hash",
"servo_arc", "servo_arc",
@@ -2956,19 +2834,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 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]] [[package]]
name = "string_cache" name = "string_cache"
version = "0.9.0" version = "0.9.0"
@@ -2977,30 +2842,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [ dependencies = [
"new_debug_unreachable", "new_debug_unreachable",
"parking_lot", "parking_lot",
"phf_shared 0.13.1", "phf_shared",
"precomputed-hash", "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]] [[package]]
name = "string_cache_codegen" name = "string_cache_codegen"
version = "0.6.1" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [ dependencies = [
"phf_generator 0.13.1", "phf_generator",
"phf_shared 0.13.1", "phf_shared",
"proc-macro2", "proc-macro2",
"quote", "quote",
] ]
@@ -3069,17 +2922,6 @@ dependencies = [
"libc", "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]] [[package]]
name = "tendril" name = "tendril"
version = "0.5.0" version = "0.5.0"
@@ -3573,28 +3415,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.4" version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [ dependencies = [
"phf 0.13.1", "phf",
"phf_codegen 0.13.1", "phf_codegen",
"string_cache 0.9.0", "string_cache",
"string_cache_codegen 0.6.1", "string_cache_codegen",
] ]
[[package]] [[package]]
+1 -1
View File
@@ -18,7 +18,7 @@ serde_derive = "1.0.228"
actix-service = "2.0.3" actix-service = "2.0.3"
diesel = { version = "2.3", features = ["postgres", "chrono"] } diesel = { version = "2.3", features = ["postgres", "chrono"] }
diesel_migrations = "2.3" diesel_migrations = "2.3"
dotenv = "0.15.0" dotenvy = "0.15"
bcrypt = "0.19" bcrypt = "0.19"
uuid = {version = "1.23", features=["serde", "v4"]} uuid = {version = "1.23", features=["serde", "v4"]}
jwt = "0.16.0" 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 actix_web::HttpRequest;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use dotenv::dotenv; use dotenvy::dotenv;
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use jwt::{Header, SignWithKey, Token, VerifyWithKey}; use jwt::{Header, SignWithKey, Token, VerifyWithKey};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
+1 -1
View File
@@ -1,7 +1,7 @@
use diesel::pg::PgConnection; use diesel::pg::PgConnection;
use diesel::prelude::*; use diesel::prelude::*;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use dotenv::dotenv; use dotenvy::dotenv;
use std::env; use std::env;
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
+1 -2
View File
@@ -1,10 +1,9 @@
extern crate diesel; extern crate diesel;
extern crate dotenv;
use actix_cors::Cors; use actix_cors::Cors;
use actix_service::Service; use actix_service::Service;
use actix_web::{App, HttpMessage, HttpResponse, HttpServer}; use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
use dotenv::dotenv; use dotenvy::dotenv;
use futures::future::{ok, Either}; use futures::future::{ok, Either};
use std::env; use std::env;
mod auth; mod auth;
+76 -19
View File
@@ -4,8 +4,7 @@ use crate::error::AppError;
use crate::json_serialization::user::JsonUser; use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed; use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::new_feed_item::NewFeedItem; 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, url};
use crate::schema::feed_item::{feed_id, title};
use crate::{ use crate::{
database::establish_connection, database::establish_connection,
schema::{ 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 let new_feed_item = NewFeedItem::new(
.filter(feed_id.eq(feed.id)) feed.id,
.filter(title.eq(&item_title)) content.clone(),
.load(connection)?; item_title.clone(),
item.link.expect("checked above"),
Some(time),
);
if existing_item.is_empty() { // `on_conflict` on the (feed_id, url) unique constraint makes this
let new_feed_item = NewFeedItem::new( // insert idempotent, so two syncs for the same feed running concurrently
feed.id, // (e.g. a sync button press racing a page-reload sync) can't both pass a
content.clone(), // check-then-insert race and create duplicate items. Keying on the
item_title.clone(), // article's link rather than its title also handles feeds that edit a
item.link.expect("checked above"), // headline after publishing while keeping the same link — the link is
Some(time), // the stable identity.
); let inserted_rows = diesel::insert_into(feed_item::table)
let insert_result = diesel::insert_into(feed_item::table) .values(&new_feed_item)
.values(&new_feed_item) .on_conflict((feed_id, url))
.execute(connection); .do_nothing()
.execute(connection)?;
log::info!("Insert Result: {:?}", insert_result); if inserted_rows > 0 {
log::info!("Inserted item: {}", item_title);
} else { } else {
log::info!("Item {} already exists.", item_title); log::info!("Item {} already exists.", item_title);
} }
@@ -210,10 +214,11 @@ pub async fn sync(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::models::feed::new_feed::NewFeed; 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::new_user::NewUser;
use crate::models::user::rss_user::User; use crate::models::user::rss_user::User;
use crate::schema::users; 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 chrono::Duration;
use super::*; use super::*;
@@ -440,6 +445,58 @@ mod tests {
.ok(); .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] #[actix_web::test]
async fn create_feed_item_strips_onerror_from_feed_image() { async fn create_feed_item_strips_onerror_from_feed_image() {
let mut connection = establish_connection(); let mut connection = establish_connection();
+29
View File
@@ -1,7 +1,9 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import axios from 'axios' import axios from 'axios'
import Modal from './modal/AddUrl.vue'
const showModal = ref(false)
const feeds = ref([]) const feeds = ref([])
const error = ref('') const error = ref('')
@@ -40,6 +42,7 @@ onMounted(loadFeeds)
<template> <template>
<div class="admin"> <div class="admin">
<h1 class="admin__heading">Admin</h1> <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-if="error" class="admin__error">{{ error }}</p>
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p> <p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
<ul v-else class="admin__list"> <ul v-else class="admin__list">
@@ -51,6 +54,14 @@ onMounted(loadFeeds)
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button> <button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
</li> </li>
</ul> </ul>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false" @added="loadFeeds">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</div> </div>
</template> </template>
@@ -67,6 +78,24 @@ onMounted(loadFeeds)
margin-bottom: 1.25rem; 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__error,
.admin__empty { .admin__empty {
opacity: 0.6; opacity: 0.6;
+68 -28
View File
@@ -2,20 +2,40 @@
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router' import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds' import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
import Modal from './modal/AddUrl.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() 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 headerRef = ref(null)
// Native <select>/<option> has no "is open" state — the closed box just shows
// the selected option's text verbatim, so per-option unread counts can only be
// toggled based on focus, since the popup is only ever shown while focused.
// @focus ~ opening (shows counts); @change (picking an option closes the
// popup but keeps focus, so no blur fires) and @blur (focus left without
// picking) ~ closed (counts hidden, so the closed box just shows the name).
const filterOpen = ref(false)
function onFilterFocus() {
filterOpen.value = true
refreshUnreadDisplay()
}
function onFilterChange(event) {
setFeedFilter(event.target.value || null)
filterOpen.value = false
}
// Scroll-driven show/hide: the header slides out of view on scroll-down and // Scroll-driven show/hide: the header slides out of view on scroll-down and
// back in on scroll-up. This is show/hide via `transform` (not the old // back in only on scroll-up — never merely by proximity to the top. This is
// resize behaviour) — the header is position:fixed, so translating it never // show/hide via `transform` (not the old resize behaviour) — the header is
// reflows content, and the app's programmatic scrolls resolve to sensible // position:fixed, so translating it never reflows content. The app's
// states: scrollTo(0, 0) lands near the top → shown; the list-view // programmatic scrolls resolve to sensible states without any top-of-page
// read-correction scrollBy moves only a few px → stays under the threshold. // 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 hidden = ref(false)
const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
// When the feed list mutates itself (read-correction scrollBy + array-splice // When the feed list mutates itself (read-correction scrollBy + array-splice
@@ -30,12 +50,10 @@ let accumulated = 0
function onScroll() { function onScroll() {
const y = Math.max(0, window.scrollY) 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 // Keep the header visible while the menu is open (the dropdown is anchored
// open (the dropdown is anchored to the header, so hiding it would slide the // to the header, so hiding it would slide the open menu off-screen).
// open menu off-screen). if (menuOpen.value) {
if (y <= headerH || menuOpen.value) {
hidden.value = false hidden.value = false
accumulated = 0 accumulated = 0
lastY = y lastY = y
@@ -78,6 +96,7 @@ onMounted(() => {
lastY = Math.max(0, window.scrollY) lastY = Math.max(0, window.scrollY)
window.addEventListener('scroll', onScrollRaf, { passive: true }) window.addEventListener('scroll', onScrollRaf, { passive: true })
document.addEventListener('click', onDocumentClick) document.addEventListener('click', onDocumentClick)
refreshUnreadDisplay()
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -127,11 +146,6 @@ function handleMarkAllRead() {
closeMenu() closeMenu()
} }
function openAddModal() {
showModal.value = true
closeMenu()
}
function handleToggleViewMode() { function handleToggleViewMode() {
toggleViewMode() toggleViewMode()
closeMenu() closeMenu()
@@ -147,15 +161,22 @@ function handleToggleLayout() {
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }"> <header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
<div class="app-nav__wrapper"> <div class="app-nav__wrapper">
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span> <span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
<span
class="app-nav__sync-spinner"
:class="{ 'app-nav__sync-spinner--active': isSyncing }"
aria-hidden="true"
></span>
<select <select
v-if="onFeedsPage && feedTitles.length" v-if="onFeedsPage && feedTitles.length"
class="app-nav__filter" class="app-nav__filter"
:value="feedFilter ?? ''" :value="feedFilter ?? ''"
aria-label="Filter by feed" aria-label="Filter by feed"
@change="setFeedFilter($event.target.value || null)" @focus="onFilterFocus"
@change="onFilterChange"
@blur="filterOpen = false"
> >
<option value="">All feeds</option> <option value="">All feeds</option>
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }} ({{ feedUnreadCounts[title] ?? 0 }})</option> <option v-for="title in feedTitles" :key="title" :value="title">{{ title }}<template v-if="filterOpen"> ({{ displayedFeedUnreadCounts[title] ?? 0 }})</template></option>
</select> </select>
<button <button
class="app-nav__hamburger" class="app-nav__hamburger"
@@ -190,20 +211,11 @@ function handleToggleLayout() {
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button> <button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
</template> </template>
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button> <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> <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> <button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
</div> </div>
</nav> </nav>
</Transition> </Transition>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</header> </header>
</template> </template>
@@ -244,6 +256,34 @@ function handleToggleLayout() {
opacity: 0.6; opacity: 0.6;
} }
.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;
visibility: hidden;
}
.app-nav__sync-spinner--active {
visibility: visible;
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--active {
animation: none;
}
}
.app-nav__filter { .app-nav__filter {
min-height: 44px; min-height: 44px;
max-width: clamp(120px, 40vw, 220px); max-width: clamp(120px, 40vw, 220px);
+47 -175
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue'; import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds'; import { useFeeds } from '@/composables/useFeeds';
const { const {
@@ -17,6 +17,7 @@ const {
disconnectObserver, disconnectObserver,
setInitialLoad, setInitialLoad,
showMessageForXSeconds, showMessageForXSeconds,
markCurrentArticleRead,
} = useFeeds() } = useFeeds()
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length) 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 // Swipe-to-navigate in article view. Transient per-gesture state, not shared
// while dragging (dragOffsetX/isDragging below), then either completes the // app state, so it's kept as plain component-local variables rather than in
// page-turn or springs back to center on release. This is transient // useFeeds() — nothing outside this component needs it.
// 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
let touchStartX = null let touchStartX = null
let touchStartY = 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 SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe
const COMMIT_DISTANCE_RATIO = 0.35 // fraction of the article's width dragged that counts as "far enough" to complete the page-turn const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe
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()
}
function onArticleTouchStart(event) { function onArticleTouchStart(event) {
// Ignore multi-touch (pinch-zoom) and swipes starting inside a // 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. // — those should scroll the block, not page the article.
if (event.touches.length !== 1 || event.target.closest('pre')) { if (event.touches.length !== 1 || event.target.closest('pre')) {
touchStartX = null touchStartX = null
touchStartY = null
return return
} }
const touch = event.touches[0] touchStartX = event.touches[0].clientX
touchStartX = touch.clientX touchStartY = event.touches[0].clientY
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
})
} }
function onArticleTouchEnd(event) { function onArticleTouchEnd(event) {
if (touchStartX === null) return if (touchStartX === null) return
const wasDraggingHorizontally = gestureAxis === 'horizontal' const touch = event.changedTouches[0]
// Computed fresh from the release position rather than read back from const deltaX = touch.clientX - touchStartX
// dragOffsetX, which is rAF-throttled and so can be a frame stale. const deltaY = touch.clientY - touchStartY
const deltaX = event.changedTouches[0].clientX - touchStartX
const elapsed = Date.now() - touchStartTime
touchStartX = null touchStartX = null
touchStartY = null touchStartY = null
gestureAxis = null
isDragging.value = false
if (!wasDraggingHorizontally) return if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) 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 (deltaX < 0) { if (deltaX < 0) {
navigateToNextArticle() nextArticle()
} else { } else {
navigateToPrevArticle() prevArticle()
} }
} }
@@ -203,6 +123,11 @@ onBeforeUnmount(() => {
onMounted(async () => { onMounted(async () => {
setInitialLoad(false) setInitialLoad(false)
await fetchData() 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) sync(true)
setTimeout(function () { setTimeout(function () {
setInitialLoad(true) setInitialLoad(true)
@@ -246,13 +171,7 @@ onMounted(async () => {
>&darr;</button> >&darr;</button>
</div> </div>
<div <div v-else class="article-single" @touchstart.passive="onArticleTouchStart" @touchend.passive="onArticleTouchEnd">
v-else
class="article-single"
@touchstart.passive="onArticleTouchStart"
@touchmove="onArticleTouchMove"
@touchend.passive="onArticleTouchEnd"
>
<div v-if="feeds.length == 0" class="empty-state"> <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"> <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"/> <circle cx="12" cy="12" r="10"/>
@@ -260,25 +179,22 @@ onMounted(async () => {
</svg> </svg>
<p class="empty-state__label">All caught up</p> <p class="empty-state__label">All caught up</p>
</div> </div>
<template v-else> <!-- Deliberately no enter/leave transition here: Vue's <Transition>
<Transition :name="`slide-${slideDirection}`"> component forces a synchronous whole-document reflow
<article (document.body.offsetHeight, in @vue/runtime-dom's forceReflow) on
class="article-feature" every enter/leave, regardless of the app's own CSS — confirmed (via
:class="{ 'article-feature--dragging': isDragging }" a DevTools performance trace) to be what was corrupting the header's
:style="dragOffsetX ? { transform: `translateX(${dragOffsetX}px)` } : null" native <select>/<button> rendering during article paging. -->
:key="currentIndex" <article v-else class="article-feature" :key="currentIndex">
> <p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p> <h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2> <h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3> <p class="feed-original-link">
<p class="feed-original-link"> <a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
<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>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button> </p>
</p> <p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p> </article>
</article>
</Transition>
</template>
<div class="article-nav"> <div class="article-nav">
<button <button
@@ -286,14 +202,14 @@ onMounted(async () => {
class="article-nav__btn" class="article-nav__btn"
:disabled="currentIndex === 0" :disabled="currentIndex === 0"
aria-label="Previous article" aria-label="Previous article"
@click="navigateToPrevArticle" @click="prevArticle"
>&uarr;</button> >&uarr;</button>
<button <button
type="button" type="button"
class="article-nav__btn" class="article-nav__btn"
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1" :disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
aria-label="Next article" aria-label="Next article"
@click="navigateToNextArticle" @click="nextArticle"
>&darr;</button> >&darr;</button>
</div> </div>
</div> </div>
@@ -443,62 +359,18 @@ onMounted(async () => {
align-items: center; align-items: center;
padding-top: 1em; padding-top: 1em;
padding-bottom: 5rem; 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 { .article-feature {
width: 100%; width: 100%;
max-width: 720px; 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 { .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')
})
})
+99 -18
View File
@@ -25,7 +25,7 @@ describe('AppNav', () => {
localStorage.setItem('user-id', '7') localStorage.setItem('user-id', '7')
vi.clearAllMocks() 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 = [] feeds.value = []
allItems.value = [] allItems.value = []
feedFilter.value = null feedFilter.value = null
@@ -34,7 +34,6 @@ describe('AppNav', () => {
lastProgrammaticScroll.value = 0 lastProgrammaticScroll.value = 0
showMessage.value = false showMessage.value = false
message.value = '' message.value = ''
showModal.value = false
viewMode.value = 'list' viewMode.value = 'list'
currentIndex.value = 0 currentIndex.value = 0
layout.value = 'list' layout.value = 'list'
@@ -130,16 +129,6 @@ describe('AppNav', () => {
expect(wrapper.find('.app-nav__menu').exists()).toBe(false) 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 () => { it('switches the view mode from the menu and closes it', async () => {
const wrapper = await mountWithMenuOpen() const wrapper = await mountWithMenuOpen()
const { viewMode } = useFeeds() const { viewMode } = useFeeds()
@@ -239,13 +228,36 @@ describe('AppNav', () => {
const wrapper = mountNav() const wrapper = mountNav()
await flushPromises() await flushPromises()
const select = wrapper.find('.app-nav__filter')
const options = wrapper.find('.app-nav__filter').findAll('option').map(o => o.text()) // Counts only show while the dropdown is open (focused) — see AppNav.vue.
await select.trigger('focus')
const options = select.findAll('option').map(o => o.text())
// "All feeds" first, then distinct titles sorted alphabetically, each with // "All feeds" first, then distinct titles sorted alphabetically, each with
// its unread count. // its unread count.
expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)']) expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)'])
}) })
it('hides the per-option unread count once the feed filter is closed', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
const wrapper = mountNav()
await flushPromises()
const select = wrapper.find('.app-nav__filter')
await select.trigger('focus')
await select.setValue('Feed A') // fires change, which closes the popup but keeps focus
await flushPromises()
const feedAOption = select.findAll('option').find(o => o.element.value === 'Feed A')
expect(feedAOption.text()).toBe('Feed A')
})
it('does not render the feed filter when there are no feeds', async () => { it('does not render the feed filter when there are no feeds', async () => {
const wrapper = mountNav() const wrapper = mountNav()
await flushPromises() await flushPromises()
@@ -298,6 +310,48 @@ describe('AppNav', () => {
expect(wrapper.find('.app-nav__title').text()).toContain('(2)') expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
}) })
describe('sync spinner', () => {
it('always renders the spinner, inactive, when idle', async () => {
const wrapper = mountNav()
await flushPromises()
const spinner = wrapper.find('.app-nav__sync-spinner')
expect(spinner.exists()).toBe(true)
expect(spinner.classes()).not.toContain('app-nav__sync-spinner--active')
})
it('activates while a sync triggered through the composable is in flight, and deactivates 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').classes()).toContain('app-nav__sync-spinner--active')
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
await nextTick()
expect(wrapper.find('.app-nav__sync-spinner').classes()).not.toContain('app-nav__sync-spinner--active')
})
it('renders 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()
expect(wrapper.find('.app-nav__filter').exists()).toBe(false)
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
})
})
describe('scroll-driven show/hide', () => { describe('scroll-driven show/hide', () => {
// The scroll handler is rAF-throttled; run rAF synchronously so a single // The scroll handler is rAF-throttled; run rAF synchronously so a single
// dispatched scroll event resolves before we assert. Per the CLAUDE.md // dispatched scroll event resolves before we assert. Per the CLAUDE.md
@@ -306,9 +360,17 @@ describe('AppNav', () => {
// Reset scroll position so each mount's lastY baseline starts at 0. // Reset scroll position so each mount's lastY baseline starts at 0.
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true }) Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 }) vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
// offsetHeight is 0 in jsdom; give the header a real height so the // getBoundingClientRect() is all-zero in jsdom; give the header a real
// "near the top" guard (scrollY <= headerH) has something to compare to. // height so onMounted()'s one-time measurement (used for the
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(50) // --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(() => { afterEach(() => {
@@ -341,19 +403,38 @@ describe('AppNav', () => {
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden') 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() const wrapper = mountNav()
scrollTo(400) scrollTo(400)
await nextTick() await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden') 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) scrollTo(10)
await nextTick() await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden') 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 () => { it('does not let a programmatic upward jump reveal the header mid-read', async () => {
const { markProgrammaticScroll } = useFeeds() const { markProgrammaticScroll } = useFeeds()
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000) 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 // useFeeds() returns module-level singleton refs shared across the whole
// app (and this spec file) — reset them so state doesn't leak between tests. // 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 = [] feeds.value = []
showMessage.value = false showMessage.value = false
message.value = '' message.value = ''
showModal.value = false
viewMode.value = 'list' viewMode.value = 'list'
currentIndex.value = 0 currentIndex.value = 0
layout.value = 'list' layout.value = 'list'
@@ -360,261 +359,178 @@ describe('RssFeeds', () => {
expect(titles).toEqual(['Article three']) expect(titles).toEqual(['Article three'])
}) })
describe('swipe navigation in article view', () => { it('swipes left to navigate to the next article', async () => {
// The drag-follow handler is rAF-throttled (dragOffsetX only updates once axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
// per frame) and reads the article's real offsetWidth for the axios.put.mockResolvedValue({ status: 200 })
// commit-distance check — stub both so gestures are deterministic. Per
// the CLAUDE.md Vitest gotcha, avoid bare fake timers here since they'd const wrapper = mount(RssFeeds)
// also fake rAF and clobber this manual stub. (Faking Date, even scoped, await flushPromises()
// was tried for the release-velocity check below and broke test-utils'
// event dispatch outright — real small setTimeout delays instead.) useFeeds().toggleViewMode()
beforeEach(() => { await flushPromises()
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(400) 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(() => { expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
// Not vi.unstubAllGlobals(): it would also remove the file-level
// IntersectionObserver stub (stubGlobal isn't describe-scoped), and wrapper.unmount()
// every test in here re-stubs requestAnimationFrame in beforeEach })
// anyway, so there's nothing to reset it back to for.
vi.restoreAllMocks() 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 const wrapper = mount(RssFeeds)
// a genuine wall-clock wait before releasing — it's how tests control the await flushPromises()
// 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()
}
it('drags the article to follow the finger while swiping', async () => { useFeeds().toggleViewMode()
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) await flushPromises()
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds) await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await flushPromises() await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
useFeeds().toggleViewMode() await flushPromises()
await flushPromises()
const article = wrapper.find('.article-single') expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchmove', { touches: [{ clientX: 150, clientY: 100 }] })
const dragged = wrapper.find('.article-feature') wrapper.unmount()
expect(dragged.classes()).toContain('article-feature--dragging') })
expect(dragged.attributes('style')).toContain('translateX(-50px)')
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 () => { const wrapper = mount(RssFeeds)
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) await flushPromises()
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds) useFeeds().toggleViewMode()
await flushPromises() await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
// 400px-wide article (mocked above), 35% commit ratio -> needs >140px. const article = wrapper.find('.article-single')
await swipe(wrapper, { startX: 300, endX: 80 }) 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() 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()
})
}) })
}) })
+4 -2
View File
@@ -2,9 +2,10 @@
import '@/assets/modal.css'; import '@/assets/modal.css';
import { ref } from 'vue'; import { ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
const props = defineProps({ defineProps({
show: Boolean show: Boolean
}) })
const emit = defineEmits(['close', 'added'])
const submitted = ref(false) const submitted = ref(false)
const url = ref('') const url = ref('')
const title = ref('') const title = ref('')
@@ -30,6 +31,7 @@ async function save() {
console.log(response) console.log(response)
output.value = 'saved successfully' output.value = 'saved successfully'
emit('added')
} catch (error) { } catch (error) {
console.error(error.message) console.error(error.message)
output.value = error.message output.value = error.message
@@ -57,7 +59,7 @@ async function save() {
<div class="modal-footer"> <div class="modal-footer">
<slot name="footer"> <slot name="footer">
<button type="submit">Save</button> <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> </slot>
</div> </div>
</form> </form>
@@ -27,6 +27,7 @@ describe('AddUrl', () => {
expect.anything(), expect.anything(),
) )
expect(wrapper.text()).toContain('saved successfully') expect(wrapper.text()).toContain('saved successfully')
expect(wrapper.emitted('added')).toHaveLength(1)
}) })
it('surfaces the error message when the request fails', async () => { it('surfaces the error message when the request fails', async () => {
@@ -39,5 +40,17 @@ describe('AddUrl', () => {
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('Network Error') 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)
}) })
}) })
+543 -4
View File
@@ -1,5 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import axios from 'axios' import axios from 'axios'
import { useFeeds } from '../useFeeds' import { useFeeds } from '../useFeeds'
@@ -13,7 +12,7 @@ class FakeIntersectionObserver {
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('useFeeds', () => { describe('useFeeds', () => {
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds() const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, viewMode, currentIndex, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, markRead, markCurrentArticleRead, setupIntersectionObserver, isSyncing } = useFeeds()
beforeEach(() => { beforeEach(() => {
localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-token', 'test-token')
@@ -25,7 +24,7 @@ describe('useFeeds', () => {
feedFilter.value = null feedFilter.value = null
showMessage.value = false showMessage.value = false
message.value = '' message.value = ''
showModal.value = false viewMode.value = 'list'
}) })
it('fetches and flattens articles for the current user', async () => { it('fetches and flattens articles for the current user', async () => {
@@ -119,6 +118,32 @@ describe('useFeeds', () => {
setInitialLoad(false) setInitialLoad(false)
}) })
it('refreshes the filter dropdown\'s displayed per-feed counts when list-view scrolling marks an article read', async () => {
// displayedFeedUnreadCounts is a frozen snapshot of feedUnreadCounts (see
// useFeeds.js) — AppNav's filter <select> renders it instead of the live
// computed so paging through article view doesn't force it to repaint.
// List-view scrolling is a different code path (handleIntersection) and
// should keep it live regardless.
allItems.value = [
{ id: 301, feedTitle: 'Feed A', read: false },
{ id: 302, feedTitle: 'Feed A', read: false },
]
feeds.value = [...allItems.value]
refreshUnreadDisplay()
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 2 })
setInitialLoad(true)
axios.put.mockResolvedValue({ status: 200 })
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
])
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 1 })
setInitialLoad(false)
})
it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => { it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => {
feeds.value = [ feeds.value = [
{ id: 201, title: 'First' }, { id: 201, title: 'First' },
@@ -152,6 +177,466 @@ describe('useFeeds', () => {
setInitialLoad(false) setInitialLoad(false)
}) })
describe('mark-as-read suppressed while syncing', () => {
it('queues list-view scroll marking while a reload (fetchData) is in flight, and flushes it automatically once it finishes', async () => {
feeds.value = [{ id: 701, title: 'First' }]
setInitialLoad(true)
// No .observe nodes exist here, so this deterministically clears
// skipNextObservation rather than relying on whatever an earlier test
// left it as.
setupIntersectionObserver()
axios.put.mockResolvedValue({ status: 200 })
let resolveGet
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
const fetchPromise = fetchData()
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
])
expect(axios.put).not.toHaveBeenCalled()
// Queued, not dropped — untouched while the reload is in flight, unlike
// a normal scroll-past which removes it immediately.
expect(feeds.value.map(f => f.id)).toEqual([701])
// The refetch's response still lists it (its read never committed) —
// the flush that follows must find and remove it by id.
resolveGet({
data: {
feeds: [{
title: 'Feed A',
items: [{ id: 701, title: 'First', content: '', url: 'https://example.test/701', timestamp: '2026-01-01 10:00:00' }],
}],
},
})
await fetchPromise
// Flushed automatically once the reload finished — no second scroll needed.
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/701', null, expect.anything())
expect(feeds.value).toEqual([])
setInitialLoad(false)
})
it('queues list-view scroll marking for the whole duration of sync(), and flushes it once sync finishes', async () => {
feeds.value = [{ id: 702, title: 'First' }]
setInitialLoad(true)
setupIntersectionObserver() // deterministically clears skipNextObservation — see the previous test
axios.put.mockResolvedValue({ status: 200 })
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const syncPromise = sync(true)
// Still mid network round-trip to /api/v1/article/sync — fetchData()
// hasn't even started yet, but scroll-marking must already be queued.
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
])
expect(axios.put).not.toHaveBeenCalled()
expect(feeds.value.map(f => f.id)).toEqual([702])
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/702', null, expect.anything())
expect(feeds.value).toEqual([])
setInitialLoad(false)
})
it('queues article-view display marking while a reload is in flight, and flushes it in place once the reload finishes', async () => {
viewMode.value = 'article' // still on screen when the flush runs — must not be removed out from under it
feeds.value = [{ id: 703, title: 'First' }]
axios.put.mockResolvedValue({ status: 200 })
let resolveGet
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
const fetchPromise = fetchData()
markCurrentArticleRead()
expect(axios.put).not.toHaveBeenCalled()
expect(feeds.value[0].read).toBeFalsy()
// The refetch's response still lists it (its read never committed), same
// id, now at whatever index it re-sorts to — the flush must find it by
// id, not by the currentIndex it was queued under.
resolveGet({
data: {
feeds: [{
title: 'Feed A',
items: [{ id: 703, title: 'First', content: '', url: 'https://example.test/703', timestamp: '2026-01-01 10:00:00' }],
}],
},
})
await fetchPromise
// Flushed automatically once the reload finished — no second call needed.
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/703', null, expect.anything())
// Still in article view — marked in place, not removed (removing it here
// would yank whatever's on screen out from under currentIndex; the same
// invariant markCurrentArticleRead() already keeps while paging).
expect(feeds.value.find(f => f.id === 703)).toMatchObject({ read: true })
})
it('drops a queued article-view read once flushed, if the user already left article view before the reload finished', async () => {
feeds.value = [{ id: 707, title: 'First' }]
viewMode.value = 'article'
axios.put.mockResolvedValue({ status: 200 })
let resolveGet
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
const fetchPromise = fetchData()
markCurrentArticleRead() // queued: isSyncing is true
// The user backs out to list view before the reload finishes —
// leaveArticleView()'s dropReadArticles() runs here, but this article
// isn't flagged read yet (it's only queued), so it survives that pass.
viewMode.value = 'list'
expect(feeds.value.map(f => f.id)).toEqual([707])
resolveGet({ data: { feeds: [] } })
await fetchPromise
// Now in list view with no further transition to rely on — the flush
// must sweep it out itself instead of leaving it stuck looking unread.
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/707', null, expect.anything())
expect(feeds.value).toEqual([])
})
it('queues every article paged past during a sync, flushing all of them once it ends', async () => {
feeds.value = [
{ id: 704, title: 'First' },
{ id: 705, title: 'Second' },
]
axios.put.mockResolvedValue({ status: 200 })
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const syncPromise = sync(true)
markCurrentArticleRead() // pages onto article 704
currentIndex.value = 1
markCurrentArticleRead() // pages onto article 705
expect(axios.put).not.toHaveBeenCalled()
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/704', null, expect.anything())
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/705', null, expect.anything())
currentIndex.value = 0
})
it('does not reopen the mark-as-read window when an independent fetchData() resolves while a sync() is still in flight', async () => {
feeds.value = [{ id: 706, title: 'First' }]
setInitialLoad(true)
setupIntersectionObserver() // deterministically clears skipNextObservation — see above
axios.put.mockResolvedValue({ status: 200 })
// sync() starts and is left mid network round-trip.
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const syncPromise = sync(true)
// An independent fetchData() call (e.g. a separate reload) runs to
// completion while sync()'s POST is still pending.
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
await fetchData()
// sync() itself hasn't finished — scroll-marking must still be
// suppressed, not reopened by the independent call settling first.
feeds.value = [{ id: 706, title: 'First' }]
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
])
expect(axios.put).not.toHaveBeenCalled()
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
// Only now, with sync() truly finished, does the queued entry flush.
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/706', null, expect.anything())
setInitialLoad(false)
})
})
describe('isSyncing', () => {
// Lets a just-resolved deferred promise's `.then` continuations (e.g.
// sync()'s `await fetchData()`) actually run before the next assertion,
// without pulling in fake timers (see the CLAUDE.md Vitest gotcha).
async function flushMicrotasks() {
for (let i = 0; i < 5; i++) await Promise.resolve()
}
it('is false initially, true while a fetchData() call is in flight, and false again once it resolves', async () => {
expect(isSyncing.value).toBe(false)
let resolveGet
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
const fetchPromise = fetchData()
expect(isSyncing.value).toBe(true)
resolveGet({ data: { feeds: [] } })
await fetchPromise
expect(isSyncing.value).toBe(false)
})
it('stays true for the whole duration of sync(), including its trailing fetchData(), and only clears once sync() fully settles', async () => {
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
let resolveGet
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
const syncPromise = sync(true)
expect(isSyncing.value).toBe(true)
// The sync POST settles, handing off into sync()'s own trailing
// fetchData() — isSyncing must not flicker false in between.
resolvePost({ status: 200 })
await flushMicrotasks()
expect(axios.get).toHaveBeenCalled()
expect(isSyncing.value).toBe(true)
resolveGet({ data: { feeds: [] } })
await syncPromise
expect(isSyncing.value).toBe(false)
})
it('leaves isSyncing true while an independent fetchData() completes during a still in-flight sync(), clearing only once both finish', async () => {
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const syncPromise = sync(true)
expect(isSyncing.value).toBe(true)
// An independent fetchData() call runs to completion while sync()'s
// POST is still pending.
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
await fetchData()
// sync() itself hasn't finished yet.
expect(isSyncing.value).toBe(true)
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
expect(isSyncing.value).toBe(false)
})
it('clears on a rejected fetchData()', async () => {
axios.get.mockRejectedValueOnce(new Error('network'))
await fetchData() // caught internally — resolves rather than rejecting
expect(isSyncing.value).toBe(false)
})
it('clears on a rejected sync()', async () => {
axios.post.mockRejectedValueOnce(new Error('network'))
await sync(true) // caught internally — resolves rather than rejecting
expect(isSyncing.value).toBe(false)
})
})
describe('markRead resilience', () => {
// Mirrors useFeeds.js's MARK_READ_RETRY_DELAYS_MS (not exported). Kept in
// sync via this comment: if that backoff changes, update this too — too
// small a value just makes the affected test's `await` hang until it
// times out, rather than pass incorrectly.
const RETRY_BACKOFF_SUM_MS = 300 + 1000
// A failed assertion before a test's own vi.useRealTimers() call would
// otherwise leak fake timers into every later test in this file.
afterEach(() => {
vi.useRealTimers()
})
it('retries a failed mark-read PUT before giving up, with a request timeout set', async () => {
vi.useFakeTimers()
axios.put
.mockRejectedValueOnce(new Error('network'))
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce({ status: 200 })
const done = markRead(601)
await vi.runAllTimersAsync()
expect(await done).toBe(true)
expect(axios.put).toHaveBeenCalledTimes(3)
expect(axios.put).toHaveBeenCalledWith(
'/api/v1/article/read/601',
null,
expect.objectContaining({ timeout: expect.any(Number) }),
)
expect(showMessage.value).toBe(false)
})
it('reports failure once retries are exhausted, and stops withholding the item from a refetch', async () => {
vi.useFakeTimers()
axios.put.mockRejectedValue(new Error('network'))
const done = markRead(602)
// Advance past just the two retry backoffs — not runAllTimersAsync(),
// which would also fire showMessageForXSeconds's own 5s auto-hide timer
// and reset showMessage before the assertion below.
await vi.advanceTimersByTimeAsync(RETRY_BACKOFF_SUM_MS)
expect(await done).toBe(false)
expect(axios.put).toHaveBeenCalledTimes(3)
expect(showMessage.value).toBe(true)
expect(message.value).toMatch(/could not mark/i)
// The mark never committed, so the item genuinely is still unread
// server-side — a refetch must be free to show it again, not hide it
// forever as "pending".
axios.get.mockResolvedValueOnce({
data: {
feeds: [{
title: 'Feed A',
items: [{ id: 602, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
}],
},
})
await fetchData()
expect(feeds.value.map(f => f.id)).toEqual([602])
})
it('does not retry a non-retryable 4xx mark-read failure', async () => {
const error = new Error('Not Found')
error.response = { status: 404 }
axios.put.mockRejectedValueOnce(error)
const result = await markRead(605)
expect(result).toBe(false)
expect(axios.put).toHaveBeenCalledTimes(1)
expect(showMessage.value).toBe(true)
})
it('keeps a still-unconfirmed read article out of a concurrent background refetch', async () => {
let resolvePut
axios.put.mockReturnValueOnce(new Promise(resolve => { resolvePut = resolve }))
const done = markRead(603) // simulates a page-turn's fire-and-forget mark-read, still in flight
// A background sync's trailing fetchData() resolves while that PUT is
// still pending — the server hasn't committed the read yet, so it's
// still in the response.
axios.get.mockResolvedValueOnce({
data: {
feeds: [{
title: 'Feed A',
items: [{ id: 603, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
}],
},
})
await fetchData()
expect(feeds.value).toEqual([])
resolvePut({ status: 200 })
await done
})
it('excludes an item whose mark-read PUT settles between a refetch\'s request and its response', async () => {
// The PUT commits (and clears the pending id) while the GET below is
// still in flight — the GET's response nonetheless reflects the DB as
// of before that commit, still reporting the item unread. A snapshot
// taken only once the GET resolves would miss this; one taken before
// it's dispatched (unioned with the live state) catches it too.
axios.put.mockResolvedValueOnce({ status: 200 })
const markDone = markRead(604)
let resolveGet
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
const fetchPromise = fetchData()
await markDone // the PUT (and pending-id cleanup) settles first...
resolveGet({ // ...then the already-in-flight GET resolves, still unread.
data: {
feeds: [{
title: 'Feed A',
items: [{ id: 604, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
}],
},
})
await fetchPromise
expect(feeds.value).toEqual([])
})
it('keeps an item withheld until every overlapping markRead() call for it settles', async () => {
// nextArticle()/prevArticle() can both re-mark the same article (page
// forward, back, forward again) — two overlapping calls for one id.
let resolveFirst
let resolveSecond
axios.put
.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve }))
.mockReturnValueOnce(new Promise(resolve => { resolveSecond = resolve }))
const first = markRead(606)
const second = markRead(606)
resolveSecond({ status: 200 }) // the second (later) call settles first
await second
// The first call is still in flight — a refetch must still withhold it.
axios.get.mockResolvedValueOnce({
data: {
feeds: [{
title: 'Feed A',
items: [{ id: 606, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
}],
},
})
await fetchData()
expect(feeds.value).toEqual([])
resolveFirst({ status: 200 })
await first
})
})
describe('markAllRead resilience', () => {
const RETRY_BACKOFF_SUM_MS = 300 + 1000
afterEach(() => {
vi.useRealTimers()
})
it('reports a partial failure instead of claiming full success', async () => {
feeds.value = [
{ id: 701, title: 'First' },
{ id: 702, title: 'Second' },
]
vi.spyOn(window, 'confirm').mockReturnValue(true)
axios.put
.mockResolvedValueOnce({ status: 200 }) // 701 succeeds
.mockRejectedValue(new Error('network')) // 702 fails every attempt
vi.useFakeTimers()
const done = markAllRead()
await vi.advanceTimersByTimeAsync(RETRY_BACKOFF_SUM_MS)
await done
expect(message.value).toContain('1 of 2')
expect(message.value).not.toBe('All articles marked as read.')
})
})
describe('feed filter', () => { describe('feed filter', () => {
const twoFeedsResponse = { const twoFeedsResponse = {
data: { data: {
@@ -211,6 +696,60 @@ describe('useFeeds', () => {
expect(feeds.value.map(f => f.id)).toEqual([2, 1]) expect(feeds.value.map(f => f.id)).toEqual([2, 1])
}) })
it('does not resurrect an article-view read-in-place article when re-selecting its feed', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
axios.put.mockResolvedValue({ status: 200 })
await fetchData()
await setFeedFilter('Feed B')
expect(feeds.value.map(f => f.id)).toEqual([2])
// Article view marks the displayed article read in place (feed.read =
// true) without removing it, so currentIndex stays valid while paging —
// see markCurrentArticleRead. It's still in the list at this point.
markCurrentArticleRead()
expect(feeds.value.map(f => f.id)).toEqual([2])
// Switching to another feed and back must not resurrect it as if it
// were still unread.
await setFeedFilter('Feed A')
await setFeedFilter('Feed B')
expect(feeds.value).toEqual([])
})
it('marks the newly displayed article read when the filter is switched while in article view', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
axios.put.mockResolvedValue({ status: 200 })
await fetchData()
viewMode.value = 'article'
// Switching into Feed B while in article view immediately displays its
// one article's full content — that counts as "viewed", so it's marked
// read the same way toggleViewMode()/nextArticle()/prevArticle() do.
await setFeedFilter('Feed B')
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything())
expect(feeds.value[0].read).toBe(true)
// It doesn't resurface either, once dropped on the next switch.
await setFeedFilter('Feed A')
await setFeedFilter('Feed B')
expect(feeds.value).toEqual([])
})
it('does not mark the newly displayed article read when the filter is switched in list view', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
await fetchData()
// List view only shows titles — switching filters there must not mark
// anything read; that's still driven solely by scroll (handleIntersection).
await setFeedFilter('Feed B')
expect(axios.put).not.toHaveBeenCalled()
expect(feeds.value[0].read).toBeUndefined()
})
it('keeps an emptied filtered feed selected and selectable, showing "All caught up"', async () => { it('keeps an emptied filtered feed selected and selectable, showing "All caught up"', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse) axios.get.mockResolvedValueOnce(twoFeedsResponse)
axios.put.mockResolvedValue({ status: 200 }) axios.put.mockResolvedValue({ status: 200 })
@@ -57,6 +57,10 @@ describe('viewMode boot persistence', () => {
expect(wrapper.find('.article-single').exists()).toBe(true) expect(wrapper.find('.article-single').exists()).toBe(true)
expect(wrapper.find('#article').exists()).toBe(false) 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() wrapper.unmount()
}) })
+227 -20
View File
@@ -43,8 +43,22 @@ const unreadCount = computed(() => {
: allItems.value : allItems.value
return items.filter(i => !i.read).length return items.filter(i => !i.read).length
}) })
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below)
// for the filter <select>'s per-feed option 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 message = ref('')
const showModal = ref(false)
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
const currentIndex = ref(0) const currentIndex = ref(0)
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
@@ -61,6 +75,63 @@ let initialLoad = false
// genuine scroll-driven exits mark read after that. // genuine scroll-driven exits mark read after that.
let skipNextObservation = false 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 // Timestamp (performance.now()) of the most recent programmatic scroll / list
// mutation that moves the page without user intent — currently the list-view // mutation that moves the page without user intent — currently the list-view
// read-correction below. AppNav's auto-hide handler resyncs its scroll baseline // read-correction below. AppNav's auto-hide handler resyncs its scroll baseline
@@ -213,12 +284,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) { async function markRead(id) {
addPendingRead(id)
try { try {
const response = await axios.put("/api/v1/article/read/" + id, null, authHeaders()) for (let attempt = 0; ; attempt++) {
console.log(response.status) try {
} catch (error) { await axios.put("/api/v1/article/read/" + id, null, { ...authHeaders(), timeout: MARK_READ_TIMEOUT_MS })
console.log(error) 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)
} }
} }
@@ -231,6 +364,22 @@ function applyFilter() {
: allItems.value.slice() : allItems.value.slice()
} }
// Article view marks an article read in place (feed.read = true) without
// removing it — see markCurrentArticleRead — so currentIndex stays valid while
// paging within that same list. Anything switches the list out from under
// that index (leaving article view, or re-filtering while still in it) has to
// drop those read-in-place articles from the master here, or they resurface:
// re-selecting their feed would otherwise re-project them from allItems as if
// still unread.
function dropReadArticles() {
// The feeds.value filter here is redundant for the setFeedFilter() caller
// (applyFilter() overwrites feeds.value again right after) but is what
// leaveArticleView() actually needs — kept shared rather than split into two
// near-identical branches.
feeds.value = feeds.value.filter(feed => !feed.read)
allItems.value = allItems.value.filter(feed => !feed.read)
}
// Changes the active feed filter and re-projects. Mirrors toggleLayout's // Changes the active feed filter and re-projects. Mirrors toggleLayout's
// observer/scroll-safe pattern: disconnect first, scroll to top, then re-point // observer/scroll-safe pattern: disconnect first, scroll to top, then re-point
// the observer at the new .observe nodes after Vue has re-rendered. Works in // the observer at the new .observe nodes after Vue has re-rendered. Works in
@@ -239,35 +388,61 @@ function applyFilter() {
async function setFeedFilter(title) { async function setFeedFilter(title) {
disconnectObserver() disconnectObserver()
window.scrollTo(0, 0) window.scrollTo(0, 0)
dropReadArticles()
feedFilter.value = title // null for "All feeds" feedFilter.value = title // null for "All feeds"
currentIndex.value = 0 currentIndex.value = 0
applyFilter() applyFilter()
// In article view the item at currentIndex is fully displayed, not just
// listed — unlike a plain currentIndex reset from paging, this one really is
// "shown" to the user, so mark it the same way toggleViewMode()/nextArticle()/
// prevArticle() do. List view only shows titles here; its own read-marking
// happens via scroll (handleIntersection), so this must not fire there.
if (viewMode.value === 'article') markCurrentArticleRead()
refreshUnreadDisplay()
await nextTick() await nextTick()
setupIntersectionObserver() setupIntersectionObserver()
} }
const fetchData = async () => { const fetchData = async () => {
const user_id = localStorage.getItem("user-id")
try { 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 response = await axios.get("/api/v1/article/get/" + user_id, authHeaders());
const items = []; const items = [];
response.data.feeds.forEach(feed => { response.data.feeds.forEach(feed => {
feed.items.forEach(item => items.push({ ...item, feedTitle: feed.title })); 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 // timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
// lexicographic comparison sorts them chronologically. // lexicographic comparison sorts them chronologically.
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
allItems.value = items; allItems.value = freshItems;
applyFilter(); applyFilter();
refreshUnreadDisplay();
await nextTick(); await nextTick();
setupIntersectionObserver(); setupIntersectionObserver();
} catch (error) { } catch (error) {
console.error('Error fetching data:', error) console.error('Error fetching data:', error)
showMessageForXSeconds(error, 5) showMessageForXSeconds(error, 5)
} finally {
endSync()
} }
}; };
async function sync(silent = false) { async function sync(silent = false) {
beginSync()
try { try {
const response = await axios.post('/api/v1/article/sync', { const response = await axios.post('/api/v1/article/sync', {
user_id: parseInt(localStorage.getItem("user-id")) user_id: parseInt(localStorage.getItem("user-id"))
@@ -276,12 +451,14 @@ async function sync(silent = false) {
if (response.status == 200 && !silent) { if (response.status == 200 && !silent) {
showMessageForXSeconds('Sync successful.', 5) showMessageForXSeconds('Sync successful.', 5)
} }
fetchData(); await fetchData();
} catch (error) { } catch (error) {
console.error('Error sync', error) console.error('Error sync', error)
if (!silent) { if (!silent) {
showMessageForXSeconds(error, 5) showMessageForXSeconds(error, 5)
} }
} finally {
endSync()
} }
} }
@@ -330,6 +507,17 @@ function handleIntersection(entries, topbarHeight = 0) {
if (readFeeds.length === 0) return 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 // 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 // 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 — // the header, which the observer would immediately treat as another read —
@@ -346,6 +534,7 @@ function handleIntersection(entries, topbarHeight = 0) {
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id)) feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
// Mirror into the master so cleared filters don't resurrect read articles. // Mirror into the master so cleared filters don't resurrect read articles.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id)) allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
refreshUnreadDisplay()
for (const feed of readFeeds) { for (const feed of readFeeds) {
markRead(feed.id) markRead(feed.id)
@@ -392,32 +581,47 @@ async function markAllRead() {
// is active) — drop exactly those from the master too. // is active) — drop exactly those from the master too.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id)) allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
currentIndex.value = 0 currentIndex.value = 0
// markRead swallows its own errors, so Promise.all can't reject here. refreshUnreadDisplay()
await Promise.all(ids.map(id => markRead(id))) // markRead() resolves to true/false rather than rejecting, so Promise.all
showMessageForXSeconds('All articles marked as read.', 5) // 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() { function markCurrentArticleRead() {
const feed = feeds.value[currentIndex.value] 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 // Marking read here (rather than via removeFeed, as the scroll-based list
// view does) keeps the array stable so currentIndex stays valid while paging. // view does) keeps the array stable so currentIndex stays valid while paging.
// The local `read` flag lets leaveArticleView() drop these once we're done. // The local `read` flag lets leaveArticleView() drop these once we're done.
if (feed) { feed.read = true
feed.read = true markRead(feed.id)
markRead(feed.id)
}
} }
async function leaveArticleView() { async function leaveArticleView() {
// Articles paged past in article view were marked read but deliberately kept // Articles paged past in article view were marked read but deliberately kept
// in place so currentIndex stayed valid — drop them now so they don't keep // in place so currentIndex stayed valid — drop them now so they don't keep
// showing up in the list view. // showing up in the list view.
feeds.value = feeds.value.filter(feed => !feed.read) dropReadArticles()
// Shared references — the paged-past objects carry .read on the master too.
allItems.value = allItems.value.filter(feed => !feed.read)
currentIndex.value = 0 currentIndex.value = 0
viewMode.value = 'list' viewMode.value = 'list'
localStorage.setItem('viewMode', viewMode.value) localStorage.setItem('viewMode', viewMode.value)
refreshUnreadDisplay()
// The v-if on the list container tears down and recreates all .observe DOM // The v-if on the list container tears down and recreates all .observe DOM
// nodes when switching views, so the intersection observer must be // nodes when switching views, so the intersection observer must be
// re-pointed at the new elements after Vue has finished rendering. // re-pointed at the new elements after Vue has finished rendering.
@@ -479,14 +683,16 @@ export function useFeeds() {
feedTitles, feedTitles,
feedUnreadCounts, feedUnreadCounts,
unreadCount, unreadCount,
displayedFeedUnreadCounts,
refreshUnreadDisplay,
setFeedFilter, setFeedFilter,
showMessage, showMessage,
message, message,
showModal,
viewMode, viewMode,
currentIndex, currentIndex,
toggleViewMode, toggleViewMode,
leaveArticleView, leaveArticleView,
markCurrentArticleRead,
layout, layout,
toggleLayout, toggleLayout,
nextArticle, nextArticle,
@@ -503,5 +709,6 @@ export function useFeeds() {
handleIntersection, handleIntersection,
lastProgrammaticScroll, lastProgrammaticScroll,
markProgrammaticScroll, markProgrammaticScroll,
isSyncing,
} }
} }