Author SHA1 Message Date
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
mathiasandClaude Sonnet 5 bfb926cec6 Replace threshold-based swipe with live drag-follow in article view
The article now visually tracks the finger in real time while swiping
(via a rAF-throttled touchmove handler), instead of only reacting
after the fact on touchend. On release it completes the page-turn if
dragged past ~35% of the article's width, or on a fast-enough flick
even short of that distance; otherwise it springs back to center.
Dragging past the first/last article is damped rather than free, for
tactile feedback that there's nothing further that way. A vertical
drag is detected within the first ~10px of movement and left alone so
it can't fight normal page scrolling.

Also fixes a real bug found while testing the flick path: Date.now()
has ~1ms resolution, so a genuinely fast flick often measures as
elapsed === 0. The velocity calculation was treating that as "zero
velocity" (dividing 0 elapsed into "no flick") instead of "as fast as
it gets" — now a zero-elapsed gesture with real movement counts as a
flick outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 22:03:41 +02:00
mathiasandClaude Sonnet 5 77438d0532 Merge branch 'fix-android-swipe-gesture'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:28:18 +02:00
mathiasandClaude Sonnet 5 49ceec5125 Fix article-view swipe being swallowed by Chrome-on-Android back gesture
On real Android touchscreens, a horizontal drag on content with nothing
left to scroll horizontally gets claimed by Chrome's native
swipe-to-go-back/forward gesture before our own touchend listener
(article-view swipe navigation) ever sees it — silently, with no
touchend firing at all. Synthetic touch testing (mouse-drag emulation
in devtools) doesn't go through that gesture recognizer, so it worked
there while doing nothing on an actual phone.

Fix: overscroll-behavior-x: contain on body stops Chrome from claiming
the gesture for its own navigation, letting our touchend fire normally.

Also drops the overflow-x: hidden added to .article-single for the
slide transition — body already has it (for the same full-bleed-image
reason), so it was redundant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:27:23 +02:00
mathiasandClaude Sonnet 5 15ac8c287b Merge branch 'article-view-swipe-nav'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:16:26 +02:00
mathiasandClaude Sonnet 5 13d9c2c908 Add swipe navigation and slide transition to article view
Swiping left/right in the single-article view now pages to the
next/previous article, same as the up/down nav buttons. Touch handlers
live on .article-single: a swipe needs >=50px horizontal travel and
<=75px vertical travel to count (so scrolling/diagonal drags don't
trigger it), and multi-touch or swipes starting inside a horizontally
scrollable <pre> block are ignored so code blocks keep their own
scroll behavior.

Also adds a direction-aware slide/fade transition (via Vue's
<Transition>, keyed on currentIndex) when paging between articles,
triggered by both the swipe gesture and the nav buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:14:52 +02:00
mathias 01f8a30505 autorestrat images 2026-08-03 14:58:45 +02:00
mathias a898e7ac33 counter on feed select 2026-07-13 17:46:49 +02:00
mathias 671ed86b48 added test 2026-07-13 09:47:10 +02:00
mathias af5e2b8137 upgrade packages 2026-07-12 17:27:16 +02:00
mathias 2b1d1c57ff keep state of aritcle/list view 2026-07-12 17:22:30 +02:00
19 changed files with 806 additions and 349 deletions
Generated
+73 -243
View File
@@ -251,14 +251,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.2"
version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
dependencies = [
"cssparser 0.35.0",
"html5ever 0.35.0",
"cssparser",
"html5ever",
"maplit",
"tendril 0.4.3",
"url",
]
@@ -323,15 +322,15 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "atom_syndication"
version = "0.12.8"
version = "0.12.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39d8b8ef99e33deb2a51504888222225797f48bace2f0cab01975746a39c05bb"
checksum = "d48d93a31c932d58b9fd68664a904ae5b74bea37f06c5f68d5bec92f0cdd106d"
dependencies = [
"chrono",
"derive_builder",
@@ -386,12 +385,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bcrypt"
version = "0.19.1"
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bcrypt"
version = "0.19.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"blowfish",
"getrandom 0.4.2",
"subtle",
@@ -506,9 +511,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -668,42 +673,19 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
dependencies = [
"cssparser-macros 0.6.1",
"dtoa-short",
"itoa",
"phf 0.11.3",
"smallvec",
]
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"cssparser-macros 0.7.0",
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "cssparser-macros"
version = "0.7.0"
@@ -963,10 +945,10 @@ dependencies = [
]
[[package]]
name = "dotenv"
version = "0.15.0"
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
@@ -1124,16 +1106,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.32"
@@ -1281,11 +1253,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasip2",
"wasip3",
"wasm-bindgen",
]
[[package]]
@@ -1332,9 +1306,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.14"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -1396,17 +1370,6 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "html5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"markup5ever 0.35.0",
"match_token",
]
[[package]]
name = "html5ever"
version = "0.39.0"
@@ -1414,7 +1377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever 0.39.0",
"markup5ever",
]
[[package]]
@@ -1492,7 +1455,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2 0.4.14",
"h2 0.4.19",
"http 1.4.1",
"http-body",
"httparse",
@@ -1899,29 +1862,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [
"log",
"tendril 0.4.3",
"web_atoms 0.1.3",
]
[[package]]
name = "markup5ever"
version = "0.39.0"
@@ -1929,19 +1875,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril 0.5.0",
"web_atoms 0.2.4",
]
[[package]]
name = "match_token"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn",
"tendril",
"web_atoms",
]
[[package]]
@@ -2073,55 +2008,25 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"phf_macros",
"phf_shared",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.6",
"phf_generator",
"phf_shared",
]
[[package]]
@@ -2131,20 +2036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
"phf_shared",
]
[[package]]
@@ -2153,22 +2045,13 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -2282,9 +2165,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.39.4"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"encoding_rs",
"memchr",
@@ -2312,15 +2195,16 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"getrandom 0.4.2",
"lru-slab",
"rand 0.9.4",
"rand 0.10.1",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
@@ -2367,15 +2251,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
@@ -2407,12 +2282,6 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
@@ -2428,6 +2297,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
@@ -2493,7 +2371,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2 0.4.14",
"h2 0.4.19",
"http 1.4.1",
"http-body",
"http-body-util",
@@ -2539,9 +2417,9 @@ dependencies = [
[[package]]
name = "rss"
version = "2.0.13"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e38781082c53bdde56e6081698c06831f69a27eac2593ed6b6cb2511424ad5"
checksum = "dc13570823abc675c60d7837f1fe7daaf18dd181a3cacb3c6ba7a062eef581c0"
dependencies = [
"atom_syndication",
"derive_builder",
@@ -2564,7 +2442,7 @@ dependencies = [
"dateparser",
"diesel",
"diesel_migrations",
"dotenv",
"dotenvy",
"env_logger",
"futures",
"hmac",
@@ -2713,13 +2591,13 @@ version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
dependencies = [
"cssparser 0.37.0",
"cssparser",
"ego-tree",
"getopts",
"html5ever 0.39.0",
"html5ever",
"precomputed-hash",
"selectors",
"tendril 0.5.0",
"tendril",
]
[[package]]
@@ -2752,12 +2630,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [
"bitflags",
"cssparser 0.37.0",
"cssparser",
"derive_more",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen 0.13.1",
"phf",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
@@ -2956,19 +2834,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -2977,30 +2842,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"phf_shared",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
@@ -3069,17 +2922,6 @@ dependencies = [
"libc",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]]
name = "tendril"
version = "0.5.0"
@@ -3573,28 +3415,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf 0.11.3",
"phf_codegen 0.11.3",
"string_cache 0.8.9",
"string_cache_codegen 0.5.4",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
"string_cache 0.9.0",
"string_cache_codegen 0.6.1",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
+1 -1
View File
@@ -18,7 +18,7 @@ serde_derive = "1.0.228"
actix-service = "2.0.3"
diesel = { version = "2.3", features = ["postgres", "chrono"] }
diesel_migrations = "2.3"
dotenv = "0.15.0"
dotenvy = "0.15"
bcrypt = "0.19"
uuid = {version = "1.23", features=["serde", "v4"]}
jwt = "0.16.0"
+3
View File
@@ -2,6 +2,7 @@ services:
postgres:
container_name: "rss-postgres"
image: "postgres:18"
restart: unless-stopped
ports:
- "5432:5432"
environment:
@@ -16,6 +17,7 @@ services:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- postgres
environment:
@@ -31,6 +33,7 @@ services:
build:
context: ./vue
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- backend
ports:
@@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
ALTER TABLE feed_item
DROP CONSTRAINT feed_item_feed_id_url_unique;
@@ -0,0 +1,36 @@
-- Your SQL goes here
-- Concurrent syncs for the same feed could previously race past the
-- application-level "does this article already exist" check and both
-- insert, producing duplicate feed items. Before enforcing uniqueness,
-- collapse any duplicates that already exist, keyed on the article's link
-- (rather than its title) since that's the stable identity RSS items are
-- built around — some feeds edit a headline after publishing while keeping
-- the same link, which title-based matching would treat as a new article.
-- If any duplicate in a group was already read, propagate that onto the
-- surviving (lowest-id) row first, so this one-time cleanup can't make an
-- already-read article look unread again.
UPDATE feed_item AS keep
SET read = true
FROM feed_item AS dupe
WHERE keep.feed_id = dupe.feed_id
AND keep.url = dupe.url
AND keep.id <> dupe.id
AND keep.id = (
SELECT MIN(candidate.id)
FROM feed_item AS candidate
WHERE candidate.feed_id = dupe.feed_id
AND candidate.url = dupe.url
)
AND dupe.read = true;
-- Keep the oldest (lowest id) row of each duplicate set.
DELETE FROM feed_item a
USING feed_item b
WHERE a.feed_id = b.feed_id
AND a.url = b.url
AND a.id > b.id;
ALTER TABLE feed_item
ADD CONSTRAINT feed_item_feed_id_url_unique UNIQUE (feed_id, url);
+1 -1
View File
@@ -6,7 +6,7 @@ use std::env;
use actix_web::HttpRequest;
use chrono::{Duration, Utc};
use dotenv::dotenv;
use dotenvy::dotenv;
use hmac::{Hmac, Mac};
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
use serde::{Deserialize, Serialize};
+1 -1
View File
@@ -1,7 +1,7 @@
use diesel::pg::PgConnection;
use diesel::prelude::*;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use dotenv::dotenv;
use dotenvy::dotenv;
use std::env;
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
+1 -2
View File
@@ -1,10 +1,9 @@
extern crate diesel;
extern crate dotenv;
use actix_cors::Cors;
use actix_service::Service;
use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
use dotenv::dotenv;
use dotenvy::dotenv;
use futures::future::{ok, Either};
use std::env;
mod auth;
+76 -19
View File
@@ -4,8 +4,7 @@ use crate::error::AppError;
use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::new_feed_item::NewFeedItem;
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::schema::feed_item::{feed_id, title};
use crate::schema::feed_item::{feed_id, url};
use crate::{
database::establish_connection,
schema::{
@@ -143,24 +142,29 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
}
}
let existing_item: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.filter(title.eq(&item_title))
.load(connection)?;
let new_feed_item = NewFeedItem::new(
feed.id,
content.clone(),
item_title.clone(),
item.link.expect("checked above"),
Some(time),
);
if existing_item.is_empty() {
let new_feed_item = NewFeedItem::new(
feed.id,
content.clone(),
item_title.clone(),
item.link.expect("checked above"),
Some(time),
);
let insert_result = diesel::insert_into(feed_item::table)
.values(&new_feed_item)
.execute(connection);
// `on_conflict` on the (feed_id, url) unique constraint makes this
// insert idempotent, so two syncs for the same feed running concurrently
// (e.g. a sync button press racing a page-reload sync) can't both pass a
// check-then-insert race and create duplicate items. Keying on the
// article's link rather than its title also handles feeds that edit a
// headline after publishing while keeping the same link — the link is
// the stable identity.
let inserted_rows = diesel::insert_into(feed_item::table)
.values(&new_feed_item)
.on_conflict((feed_id, url))
.do_nothing()
.execute(connection)?;
log::info!("Insert Result: {:?}", insert_result);
if inserted_rows > 0 {
log::info!("Inserted item: {}", item_title);
} else {
log::info!("Item {} already exists.", item_title);
}
@@ -210,10 +214,11 @@ pub async fn sync(
#[cfg(test)]
mod tests {
use crate::models::feed::new_feed::NewFeed;
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::models::user::new_user::NewUser;
use crate::models::user::rss_user::User;
use crate::schema::users;
use crate::test_helpers::unique_suffix;
use crate::test_helpers::{delete_feed, delete_user, insert_feed, insert_user, unique_suffix};
use chrono::Duration;
use super::*;
@@ -440,6 +445,58 @@ mod tests {
.ok();
}
#[actix_web::test]
async fn create_feed_item_is_race_safe_under_concurrent_syncs() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let user = insert_user(&mut connection, "secret");
let feed = insert_feed(&mut connection, user.id);
let mut item = Item::default();
item.set_title(Some(format!("Race test article {suffix}")));
item.set_link(Some(format!("https://example.test/race/{suffix}")));
item.set_content(Some("<p>Hello world</p>".to_string()));
// Simulate two concurrent syncs for the same feed (e.g. a sync
// button press racing a page-reload sync) — each on its own
// connection/thread, released together so they genuinely overlap —
// both inserting the same item.
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let handles: Vec<_> = (0..2)
.map(|_| {
let feed = feed.clone();
let item = item.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
let mut connection = establish_connection();
barrier.wait();
create_feed_item(item, &feed, &mut connection)
})
})
.collect();
for handle in handles {
handle.join().unwrap().unwrap();
}
let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.load(&mut connection)
.unwrap();
assert_eq!(
1,
items.len(),
"concurrent syncs must not create duplicate feed items"
);
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection)
.ok();
delete_feed(&mut connection, feed.id);
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn create_feed_item_strips_onerror_from_feed_image() {
let mut connection = establish_connection();
+3 -1
View File
@@ -1,5 +1,7 @@
# --- builder ---
FROM node:20-alpine AS builder
# Node 24 (active LTS): Node 20 is past EOL (April 2026), and vue-router 5's
# Babel 8 toolchain requires ^22.18.0 || >=24.11.0 (EBADENGINE on node 20).
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
+50 -40
View File
@@ -1275,9 +1275,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -1427,9 +1427,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0",
@@ -1437,12 +1437,12 @@
}
},
"node_modules/braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"dependencies": {
"fill-range": "^7.0.1"
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
@@ -2205,9 +2205,9 @@
}
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -2247,9 +2247,9 @@
}
},
"node_modules/flatted": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true
},
"node_modules/follow-redirects": {
@@ -2300,15 +2300,15 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -2812,10 +2812,20 @@
"dev": true
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"dependencies": {
"argparse": "^2.0.1"
},
@@ -3224,9 +3234,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true
},
"node_modules/lodash.merge": {
@@ -3299,12 +3309,12 @@
}
},
"node_modules/micromatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"dependencies": {
"braces": "^3.0.2",
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
@@ -3343,9 +3353,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -3673,9 +3683,9 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"engines": {
"node": ">=8.6"
@@ -4450,9 +4460,9 @@
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="
},
"node_modules/undici": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true,
"engines": {
"node": ">=20.18.1"
+6
View File
@@ -81,6 +81,12 @@ body {
wider than the visible content area (scrollbar) and would otherwise
introduce a horizontal scrollbar. */
overflow-x: hidden;
/* Without this, a horizontal touch drag on content with nothing left to
scroll (true here, given overflow-x: hidden above) is claimed by
Chrome-on-Android's native swipe-to-go-back/forward gesture before our
own touchend listener (article-view swipe navigation) ever sees it —
the gesture is silently swallowed with no error, no touchend fires. */
overscroll-behavior-x: contain;
color: var(--color-text);
background: var(--color-background);
transition: color 0.5s, background-color 0.5s;
+35 -13
View File
@@ -6,16 +6,37 @@ import Modal from './modal/AddUrl.vue'
const router = useRouter()
const route = useRoute()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, lastProgrammaticScroll } = useFeeds()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds()
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
// back in on scroll-up. This is show/hide via `transform` (not the old
// resize behaviour) — the header is position:fixed, so translating it never
// reflows content, and the app's programmatic scrolls resolve to sensible
// states: scrollTo(0, 0) lands near the top → shown; the list-view
// read-correction scrollBy moves only a few px → stays under the threshold.
// back in only on scroll-up — never merely by proximity to the top. This is
// show/hide via `transform` (not the old resize behaviour) — the header is
// position:fixed, so translating it never reflows content. The app's
// programmatic scrolls resolve to sensible states without any top-of-page
// special case: scrollTo(0, 0) (article paging, layout toggle) is itself a
// large upward jump, so it crosses REVEAL_THRESHOLD like a real scroll-up and
// reveals the header that way; the list-view read-correction scrollBy moves
// only a few px → stays under the threshold and leaves a hidden header hidden.
const hidden = ref(false)
const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
// When the feed list mutates itself (read-correction scrollBy + array-splice
@@ -30,12 +51,10 @@ let accumulated = 0
function onScroll() {
const y = Math.max(0, window.scrollY)
const headerH = headerRef.value?.offsetHeight ?? 0
// Always reveal near the very top, and keep it visible while the menu is
// open (the dropdown is anchored to the header, so hiding it would slide the
// open menu off-screen).
if (y <= headerH || menuOpen.value) {
// Keep the header visible while the menu is open (the dropdown is anchored
// to the header, so hiding it would slide the open menu off-screen).
if (menuOpen.value) {
hidden.value = false
accumulated = 0
lastY = y
@@ -78,6 +97,7 @@ onMounted(() => {
lastY = Math.max(0, window.scrollY)
window.addEventListener('scroll', onScrollRaf, { passive: true })
document.addEventListener('click', onDocumentClick)
refreshUnreadDisplay()
})
onUnmounted(() => {
@@ -152,10 +172,12 @@ function handleToggleLayout() {
class="app-nav__filter"
:value="feedFilter ?? ''"
aria-label="Filter by feed"
@change="setFeedFilter($event.target.value || null)"
@focus="onFilterFocus"
@change="onFilterChange"
@blur="filterOpen = false"
>
<option value="">All feeds</option>
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}</option>
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}<template v-if="filterOpen"> ({{ displayedFeedUnreadCounts[title] ?? 0 }})</template></option>
</select>
<button
class="app-nav__hamburger"
+63 -13
View File
@@ -34,6 +34,45 @@ function scrollToNextArticle() {
}
}
// Swipe-to-navigate in article view. Transient per-gesture state, not shared
// app state, so it's kept as plain component-local variables rather than in
// useFeeds() — nothing outside this component needs it.
let touchStartX = null
let touchStartY = null
const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe
const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe
function onArticleTouchStart(event) {
// Ignore multi-touch (pinch-zoom) and swipes starting inside a
// horizontally-scrollable code block (.article-feature__content :deep(pre))
// — those should scroll the block, not page the article.
if (event.touches.length !== 1 || event.target.closest('pre')) {
touchStartX = null
touchStartY = null
return
}
touchStartX = event.touches[0].clientX
touchStartY = event.touches[0].clientY
}
function onArticleTouchEnd(event) {
if (touchStartX === null) return
const touch = event.changedTouches[0]
const deltaX = touch.clientX - touchStartX
const deltaY = touch.clientY - touchStartY
touchStartX = null
touchStartY = null
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
if (deltaX < 0) {
nextArticle()
} else {
prevArticle()
}
}
// Small images (icons, logos, ...) look bad stretched to the full-bleed
// width used for readable article images — leave them at their natural size
// instead. Intrinsic size is only known once the image has loaded, so check
@@ -126,7 +165,7 @@ onMounted(async () => {
>&darr;</button>
</div>
<div v-else class="article-single">
<div v-else class="article-single" @touchstart.passive="onArticleTouchStart" @touchend.passive="onArticleTouchEnd">
<div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
@@ -134,18 +173,22 @@ onMounted(async () => {
</svg>
<p class="empty-state__label">All caught up</p>
</div>
<template v-else>
<article class="article-feature">
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
<p class="feed-original-link">
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
</p>
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
</article>
</template>
<!-- Deliberately no enter/leave transition here: Vue's <Transition>
component forces a synchronous whole-document reflow
(document.body.offsetHeight, in @vue/runtime-dom's forceReflow) on
every enter/leave, regardless of the app's own CSS — confirmed (via
a DevTools performance trace) to be what was corrupting the header's
native <select>/<button> rendering during article paging. -->
<article v-else class="article-feature" :key="currentIndex">
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
<p class="feed-original-link">
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
</p>
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
</article>
<div class="article-nav">
<button
@@ -310,6 +353,13 @@ onMounted(async () => {
align-items: center;
padding-top: 1em;
padding-bottom: 5rem;
/* Fill the viewport below the fixed header even when the article content
is short, so swipe-to-navigate works anywhere on screen instead of only
over the (possibly small) content area. dvh is the accurate unit on
mobile, where this matters most; vh is the fallback for browsers
without dvh support. */
min-height: calc(100vh - var(--app-nav-height, 4.5rem));
min-height: calc(100dvh - var(--app-nav-height, 4.5rem));
}
.article-feature {
+59 -8
View File
@@ -239,10 +239,34 @@ describe('AppNav', () => {
const wrapper = mountNav()
await flushPromises()
const select = wrapper.find('.app-nav__filter')
const options = wrapper.find('.app-nav__filter').findAll('option').map(o => o.text())
// "All feeds" first, then distinct titles sorted alphabetically.
expect(options).toEqual(['All feeds', 'Feed A', 'Feed B'])
// 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
// its unread count.
expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)'])
})
it('hides the per-option unread count once the feed filter is closed', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
const wrapper = mountNav()
await flushPromises()
const select = wrapper.find('.app-nav__filter')
await select.trigger('focus')
await select.setValue('Feed A') // fires change, which closes the popup but keeps focus
await flushPromises()
const feedAOption = select.findAll('option').find(o => o.element.value === 'Feed A')
expect(feedAOption.text()).toBe('Feed A')
})
it('does not render the feed filter when there are no feeds', async () => {
@@ -305,9 +329,17 @@ describe('AppNav', () => {
// Reset scroll position so each mount's lastY baseline starts at 0.
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
// offsetHeight is 0 in jsdom; give the header a real height so the
// "near the top" guard (scrollY <= headerH) has something to compare to.
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(50)
// getBoundingClientRect() is all-zero in jsdom; give the header a real
// height so onMounted()'s one-time measurement (used for the
// --app-nav-height CSS var — see AppNav.vue) has a realistic value.
// This is a prototype-wide spy (scoped to this describe block only, restored in
// afterEach), so it returns a full DOMRect-shaped object rather than a
// partial one — no test here happens to call getBoundingClientRect() on
// anything but the header, but a bare `{ height: 50 }` would silently
// hand back `undefined` for top/left/width/etc. to any code that did.
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
height: 50, width: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {},
})
})
afterEach(() => {
@@ -340,19 +372,38 @@ describe('AppNav', () => {
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
})
it('always shows the header near the top of the page', async () => {
it('reveals the header on a large upward jump that lands near the top', async () => {
const wrapper = mountNav()
scrollTo(400)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// Back within the header's own height of the top → always revealed.
// A jump this large (e.g. article paging's scrollTo(0, 0)) crosses
// REVEAL_THRESHOLD on its own — no top-of-page special case needed.
scrollTo(10)
await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
})
it('does not reveal the header merely for landing near the top', async () => {
const wrapper = mountNav()
// Hide it with a jump just past the header's own height (50px, mocked
// above) plus the reveal threshold.
scrollTo(62)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// Move back to right at the header's own height — within the "near the
// top" zone the old code always revealed for — but the upward delta
// (12px) doesn't exceed REVEAL_THRESHOLD, so a hidden header stays
// hidden: proximity to the top is no longer sufficient on its own.
scrollTo(50)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
})
it('does not let a programmatic upward jump reveal the header mid-read', async () => {
const { markProgrammaticScroll } = useFeeds()
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000)
@@ -15,6 +15,20 @@ class FakeIntersectionObserver {
}
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
// Shared by the swipe-navigation tests below, which all start from the same
// two-article article view.
function twoArticleFeed() {
return [
{
title: 'My Feed',
items: [
{ id: 1, title: 'Article one', content: '<p>one</p>', 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' },
],
},
]
}
describe('RssFeeds', () => {
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
@@ -345,4 +359,179 @@ describe('RssFeeds', () => {
const titles = wrapper.findAll('.feed-title').map(el => el.text())
expect(titles).toEqual(['Article three'])
})
it('swipes left to navigate to the next article', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
wrapper.unmount()
})
it('swipes right to navigate to the previous article', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 130, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 200, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a swipe shorter than the horizontal threshold', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 180, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a mostly-vertical drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 250 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a diagonal drag that exceeds the vertical restraint, even with a qualifying horizontal delta', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 200 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('ignores a multi-touch gesture (pinch-zoom)', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', {
touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }],
})
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate when a swipe starts inside a code block', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{ id: 1, title: 'Article one', content: '<pre>some code</pre>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
],
},
],
},
})
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
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()
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.text()).toContain('All caught up')
wrapper.unmount()
})
})
+72 -1
View File
@@ -13,7 +13,7 @@ class FakeIntersectionObserver {
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('useFeeds', () => {
const { feeds, allItems, feedFilter, feedTitles, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead } = useFeeds()
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds()
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
@@ -119,6 +119,65 @@ describe('useFeeds', () => {
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 () => {
feeds.value = [
{ id: 201, title: 'First' },
{ id: 202, title: 'Second' },
]
setInitialLoad(true)
axios.put.mockResolvedValue({ status: 200 })
// A .observe node must exist for setupIntersectionObserver to arm the skip.
const node = document.createElement('div')
node.className = 'observe'
document.body.appendChild(node)
setupIntersectionObserver()
// First callback after connect is the initial snapshot — must be dropped
// even though both entries look "scrolled past" (above the topbar).
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
{ isIntersecting: false, boundingClientRect: { y: -5 }, target: { id: '1' } },
])
expect(axios.put).not.toHaveBeenCalled()
expect(feeds.value).toHaveLength(2)
// A genuine later scroll-past still marks read.
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
])
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/201', null, expect.anything())
document.body.removeChild(node)
setInitialLoad(false)
})
describe('feed filter', () => {
const twoFeedsResponse = {
data: {
@@ -219,6 +278,18 @@ describe('useFeeds', () => {
await setFeedFilter(null)
expect(unreadCount.value).toBe(3)
})
it('exposes per-feed unread counts, excluding items marked read in place', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
await fetchData()
expect(feedUnreadCounts.value).toEqual({ 'Feed A': 2, 'Feed B': 1 })
// Article view marks items read in place (feed.read = true) without
// removing them from the master — the counts must not include those.
allItems.value.find(i => i.id === 3).read = true
expect(feedUnreadCounts.value).toEqual({ 'Feed A': 1, 'Feed B': 1 })
})
})
it('strips leftover embedded-video placeholder headings', async () => {
@@ -0,0 +1,72 @@
// Reproduces the "reload the tab while in article view" boot sequence:
// module-scope init must pick up the persisted viewMode, and RssFeeds'
// mount flow (fetchData + sync) must not reset it back to 'list'.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
vi.mock('axios')
class FakeIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('viewMode boot persistence', () => {
beforeEach(() => {
vi.resetModules()
localStorage.clear()
localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7')
})
afterEach(() => {
localStorage.clear()
})
it('boots into article view when localStorage says so, surviving the mount fetch/sync', async () => {
localStorage.setItem('viewMode', 'article')
const axios = (await import('axios')).default
axios.get.mockResolvedValue({
data: {
feeds: [
{
title: 'My Feed',
items: [
{ id: 1, title: 'Article one', content: '<p>hi</p>', url: 'https://example.test/1', timestamp: '2026-01-01 10:00:00' },
],
},
],
},
})
axios.post.mockResolvedValue({ status: 200 })
axios.put.mockResolvedValue({ status: 200 })
// Import AFTER setting storage — module-scope refs initialize at import.
const { useFeeds } = await import('../useFeeds')
const { viewMode } = useFeeds()
expect(viewMode.value).toBe('article')
const RssFeeds = (await import('../../components/RssFeeds.vue')).default
const wrapper = mount(RssFeeds)
await flushPromises()
expect(viewMode.value).toBe('article')
expect(wrapper.find('.article-single').exists()).toBe(true)
expect(wrapper.find('#article').exists()).toBe(false)
wrapper.unmount()
})
it('boots into list view when the key is absent', async () => {
const axios = (await import('axios')).default
axios.get.mockResolvedValue({ data: { feeds: [] } })
axios.post.mockResolvedValue({ status: 200 })
const { useFeeds } = await import('../useFeeds')
const { viewMode } = useFeeds()
expect(viewMode.value).toBe('list')
})
})
+62 -6
View File
@@ -22,6 +22,17 @@ const feedTitles = computed(() => {
if (feedFilter.value) titles.add(feedFilter.value)
return [...titles].sort((a, b) => a.localeCompare(b))
})
// Unread count per feed title, shown next to each option in the filter
// <select>. Derived from the master (allItems) so article view's
// read-but-still-shown items are excluded. A title kept in feedTitles with no
// unread items left has no key here — render it as 0.
const feedUnreadCounts = computed(() => {
const counts = {}
for (const item of allItems.value) {
if (!item.read) counts[item.feedTitle] = (counts[item.feedTitle] ?? 0) + 1
}
return counts
})
// The header badge count: unread items in the selected feed when a filter is
// active, otherwise the global unread total. Derived from the master (allItems)
// rather than the displayed `feeds` so article view's read-but-still-shown
@@ -32,14 +43,38 @@ const unreadCount = computed(() => {
: allItems.value
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 showModal = ref(false)
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
const currentIndex = ref(0)
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
let observer; // Declare observer outside the setup function
let initialLoad = false
// An IntersectionObserver always delivers an initial snapshot for every element
// it starts observing. If the observer (re)connects while the page is scrolled
// down — e.g. leaving a scrolled article view, an HMR module reload, or the
// post-splice re-setup in handleIntersection — that snapshot reports every
// article above the viewport as "not intersecting / scrolled past" and would
// mark them all read at once. So each setupIntersectionObserver() sets this and
// handleIntersection() drops exactly the first callback after a connect; only
// genuine scroll-driven exits mark read after that.
let skipNextObservation = false
// Timestamp (performance.now()) of the most recent programmatic scroll / list
// mutation that moves the page without user intent — currently the list-view
@@ -222,6 +257,7 @@ async function setFeedFilter(title) {
feedFilter.value = title // null for "All feeds"
currentIndex.value = 0
applyFilter()
refreshUnreadDisplay()
await nextTick()
setupIntersectionObserver()
}
@@ -239,6 +275,7 @@ const fetchData = async () => {
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
allItems.value = items;
applyFilter();
refreshUnreadDisplay();
await nextTick();
setupIntersectionObserver();
} catch (error) {
@@ -282,14 +319,25 @@ function setupIntersectionObserver() {
});
const observedDivs = document.querySelectorAll(".observe");
if (observedDivs.length > 0) {
observedDivs.forEach(observedDiv => {
observer.observe(observedDiv);
})
}
// Arm the skip only when we actually observe nodes: an observe() batch fires
// an initial snapshot to drop (a connect while scrolled down would otherwise
// mass-mark everything above the viewport), but with nothing observed there's
// no snapshot — and assigning unconditionally clears any stale flag from a
// prior no-op setup (e.g. one run in article view, which has no .observe nodes).
skipNextObservation = observedDivs.length > 0
observedDivs.forEach(observedDiv => {
observer.observe(observedDiv);
})
}
function handleIntersection(entries, topbarHeight = 0) {
// Drop the initial snapshot fired on (re)connect — see skipNextObservation.
// The old observer is disconnected before this new one observes, so only the
// latest observer's initial callback reaches here; one skip is enough.
if (skipNextObservation) {
skipNextObservation = false
return
}
// Resolve all affected feeds before touching feeds.value — the target.id
// indices are render-time positions that shift once we splice the array.
const readFeeds = entries
@@ -315,6 +363,7 @@ function handleIntersection(entries, topbarHeight = 0) {
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
// Mirror into the master so cleared filters don't resurrect read articles.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
refreshUnreadDisplay()
for (const feed of readFeeds) {
markRead(feed.id)
@@ -361,6 +410,7 @@ async function markAllRead() {
// is active) — drop exactly those from the master too.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
currentIndex.value = 0
refreshUnreadDisplay()
// markRead swallows its own errors, so Promise.all can't reject here.
await Promise.all(ids.map(id => markRead(id)))
showMessageForXSeconds('All articles marked as read.', 5)
@@ -386,6 +436,8 @@ async function leaveArticleView() {
allItems.value = allItems.value.filter(feed => !feed.read)
currentIndex.value = 0
viewMode.value = 'list'
localStorage.setItem('viewMode', viewMode.value)
refreshUnreadDisplay()
// The v-if on the list container tears down and recreates all .observe DOM
// nodes when switching views, so the intersection observer must be
// re-pointed at the new elements after Vue has finished rendering.
@@ -405,6 +457,7 @@ function toggleViewMode() {
observer = null
}
viewMode.value = 'article'
localStorage.setItem('viewMode', viewMode.value)
currentIndex.value = 0
markCurrentArticleRead()
}
@@ -444,7 +497,10 @@ export function useFeeds() {
allItems,
feedFilter,
feedTitles,
feedUnreadCounts,
unreadCount,
displayedFeedUnreadCounts,
refreshUnreadDisplay,
setFeedFilter,
showMessage,
message,