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
13 changed files with 360 additions and 362 deletions
Generated
+73 -243
View File
@@ -251,14 +251,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.2"
version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
dependencies = [
"cssparser 0.35.0",
"html5ever 0.35.0",
"cssparser",
"html5ever",
"maplit",
"tendril 0.4.3",
"url",
]
@@ -323,15 +322,15 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "atom_syndication"
version = "0.12.8"
version = "0.12.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39d8b8ef99e33deb2a51504888222225797f48bace2f0cab01975746a39c05bb"
checksum = "d48d93a31c932d58b9fd68664a904ae5b74bea37f06c5f68d5bec92f0cdd106d"
dependencies = [
"chrono",
"derive_builder",
@@ -386,12 +385,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bcrypt"
version = "0.19.1"
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bcrypt"
version = "0.19.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"blowfish",
"getrandom 0.4.2",
"subtle",
@@ -506,9 +511,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -668,42 +673,19 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
dependencies = [
"cssparser-macros 0.6.1",
"dtoa-short",
"itoa",
"phf 0.11.3",
"smallvec",
]
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"cssparser-macros 0.7.0",
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "cssparser-macros"
version = "0.7.0"
@@ -963,10 +945,10 @@ dependencies = [
]
[[package]]
name = "dotenv"
version = "0.15.0"
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
@@ -1124,16 +1106,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.32"
@@ -1281,11 +1253,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasip2",
"wasip3",
"wasm-bindgen",
]
[[package]]
@@ -1332,9 +1306,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.14"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -1396,17 +1370,6 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "html5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"markup5ever 0.35.0",
"match_token",
]
[[package]]
name = "html5ever"
version = "0.39.0"
@@ -1414,7 +1377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever 0.39.0",
"markup5ever",
]
[[package]]
@@ -1492,7 +1455,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2 0.4.14",
"h2 0.4.19",
"http 1.4.1",
"http-body",
"httparse",
@@ -1899,29 +1862,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [
"log",
"tendril 0.4.3",
"web_atoms 0.1.3",
]
[[package]]
name = "markup5ever"
version = "0.39.0"
@@ -1929,19 +1875,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril 0.5.0",
"web_atoms 0.2.4",
]
[[package]]
name = "match_token"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn",
"tendril",
"web_atoms",
]
[[package]]
@@ -2073,55 +2008,25 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"phf_macros",
"phf_shared",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.6",
"phf_generator",
"phf_shared",
]
[[package]]
@@ -2131,20 +2036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
"phf_shared",
]
[[package]]
@@ -2153,22 +2045,13 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -2282,9 +2165,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.39.4"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"encoding_rs",
"memchr",
@@ -2312,15 +2195,16 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"getrandom 0.4.2",
"lru-slab",
"rand 0.9.4",
"rand 0.10.1",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
@@ -2367,15 +2251,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
@@ -2407,12 +2282,6 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
@@ -2428,6 +2297,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
@@ -2493,7 +2371,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2 0.4.14",
"h2 0.4.19",
"http 1.4.1",
"http-body",
"http-body-util",
@@ -2539,9 +2417,9 @@ dependencies = [
[[package]]
name = "rss"
version = "2.0.13"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e38781082c53bdde56e6081698c06831f69a27eac2593ed6b6cb2511424ad5"
checksum = "dc13570823abc675c60d7837f1fe7daaf18dd181a3cacb3c6ba7a062eef581c0"
dependencies = [
"atom_syndication",
"derive_builder",
@@ -2564,7 +2442,7 @@ dependencies = [
"dateparser",
"diesel",
"diesel_migrations",
"dotenv",
"dotenvy",
"env_logger",
"futures",
"hmac",
@@ -2713,13 +2591,13 @@ version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
dependencies = [
"cssparser 0.37.0",
"cssparser",
"ego-tree",
"getopts",
"html5ever 0.39.0",
"html5ever",
"precomputed-hash",
"selectors",
"tendril 0.5.0",
"tendril",
]
[[package]]
@@ -2752,12 +2630,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [
"bitflags",
"cssparser 0.37.0",
"cssparser",
"derive_more",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen 0.13.1",
"phf",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
@@ -2956,19 +2834,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -2977,30 +2842,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"phf_shared",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
@@ -3069,17 +2922,6 @@ dependencies = [
"libc",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]]
name = "tendril"
version = "0.5.0"
@@ -3573,28 +3415,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf 0.11.3",
"phf_codegen 0.11.3",
"string_cache 0.8.9",
"string_cache_codegen 0.5.4",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
"string_cache 0.9.0",
"string_cache_codegen 0.6.1",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
+1 -1
View File
@@ -18,7 +18,7 @@ serde_derive = "1.0.228"
actix-service = "2.0.3"
diesel = { version = "2.3", features = ["postgres", "chrono"] }
diesel_migrations = "2.3"
dotenv = "0.15.0"
dotenvy = "0.15"
bcrypt = "0.19"
uuid = {version = "1.23", features=["serde", "v4"]}
jwt = "0.16.0"
@@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
ALTER TABLE feed_item
DROP CONSTRAINT feed_item_feed_id_url_unique;
@@ -0,0 +1,36 @@
-- Your SQL goes here
-- Concurrent syncs for the same feed could previously race past the
-- application-level "does this article already exist" check and both
-- insert, producing duplicate feed items. Before enforcing uniqueness,
-- collapse any duplicates that already exist, keyed on the article's link
-- (rather than its title) since that's the stable identity RSS items are
-- built around — some feeds edit a headline after publishing while keeping
-- the same link, which title-based matching would treat as a new article.
-- If any duplicate in a group was already read, propagate that onto the
-- surviving (lowest-id) row first, so this one-time cleanup can't make an
-- already-read article look unread again.
UPDATE feed_item AS keep
SET read = true
FROM feed_item AS dupe
WHERE keep.feed_id = dupe.feed_id
AND keep.url = dupe.url
AND keep.id <> dupe.id
AND keep.id = (
SELECT MIN(candidate.id)
FROM feed_item AS candidate
WHERE candidate.feed_id = dupe.feed_id
AND candidate.url = dupe.url
)
AND dupe.read = true;
-- Keep the oldest (lowest id) row of each duplicate set.
DELETE FROM feed_item a
USING feed_item b
WHERE a.feed_id = b.feed_id
AND a.url = b.url
AND a.id > b.id;
ALTER TABLE feed_item
ADD CONSTRAINT feed_item_feed_id_url_unique UNIQUE (feed_id, url);
+1 -1
View File
@@ -6,7 +6,7 @@ use std::env;
use actix_web::HttpRequest;
use chrono::{Duration, Utc};
use dotenv::dotenv;
use dotenvy::dotenv;
use hmac::{Hmac, Mac};
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
use serde::{Deserialize, Serialize};
+1 -1
View File
@@ -1,7 +1,7 @@
use diesel::pg::PgConnection;
use diesel::prelude::*;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use dotenv::dotenv;
use dotenvy::dotenv;
use std::env;
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
+1 -2
View File
@@ -1,10 +1,9 @@
extern crate diesel;
extern crate dotenv;
use actix_cors::Cors;
use actix_service::Service;
use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
use dotenv::dotenv;
use dotenvy::dotenv;
use futures::future::{ok, Either};
use std::env;
mod auth;
+70 -13
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,12 +142,6 @@ 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)?;
if existing_item.is_empty() {
let new_feed_item = NewFeedItem::new(
feed.id,
content.clone(),
@@ -156,11 +149,22 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
item.link.expect("checked above"),
Some(time),
);
let insert_result = diesel::insert_into(feed_item::table)
.values(&new_feed_item)
.execute(connection);
log::info!("Insert Result: {:?}", insert_result);
// `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)?;
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();
+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, feedUnreadCounts, 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 }} ({{ 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>
<button
class="app-nav__hamburger"
+19 -66
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds';
const {
@@ -43,22 +43,6 @@ 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
// 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) {
// Ignore multi-touch (pinch-zoom) and swipes starting inside a
// horizontally-scrollable code block (.article-feature__content :deep(pre))
@@ -83,9 +67,9 @@ function onArticleTouchEnd(event) {
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
if (deltaX < 0) {
navigateToNextArticle()
nextArticle()
} else {
navigateToPrevArticle()
prevArticle()
}
}
@@ -189,9 +173,13 @@ onMounted(async () => {
</svg>
<p class="empty-state__label">All caught up</p>
</div>
<template v-else>
<Transition :name="`slide-${slideDirection}`">
<article class="article-feature" :key="currentIndex">
<!-- 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>
@@ -201,8 +189,6 @@ onMounted(async () => {
</p>
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
</article>
</Transition>
</template>
<div class="article-nav">
<button
@@ -210,14 +196,14 @@ onMounted(async () => {
class="article-nav__btn"
:disabled="currentIndex === 0"
aria-label="Previous article"
@click="navigateToPrevArticle"
@click="prevArticle"
>&uarr;</button>
<button
type="button"
class="article-nav__btn"
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
aria-label="Next article"
@click="navigateToNextArticle"
@click="nextArticle"
>&darr;</button>
</div>
</div>
@@ -367,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 {
@@ -374,46 +367,6 @@ onMounted(async () => {
max-width: 720px;
}
/* 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 {
margin: 0 0 0.5em;
padding: 0 1rem;
+56 -6
View File
@@ -239,13 +239,36 @@ 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())
// 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 () => {
const wrapper = mountNav()
await flushPromises()
@@ -306,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(() => {
@@ -341,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)
+27 -1
View File
@@ -13,7 +13,7 @@ class FakeIntersectionObserver {
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
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, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds()
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
@@ -119,6 +119,32 @@ 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' },
+22
View File
@@ -43,6 +43,21 @@ 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(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
@@ -242,6 +257,7 @@ async function setFeedFilter(title) {
feedFilter.value = title // null for "All feeds"
currentIndex.value = 0
applyFilter()
refreshUnreadDisplay()
await nextTick()
setupIntersectionObserver()
}
@@ -259,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) {
@@ -346,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)
@@ -392,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)
@@ -418,6 +437,7 @@ async function leaveArticleView() {
currentIndex.value = 0
viewMode.value = 'list'
localStorage.setItem('viewMode', viewMode.value)
refreshUnreadDisplay()
// The v-if on the list container tears down and recreates all .observe DOM
// nodes when switching views, so the intersection observer must be
// re-pointed at the new elements after Vue has finished rendering.
@@ -479,6 +499,8 @@ export function useFeeds() {
feedTitles,
feedUnreadCounts,
unreadCount,
displayedFeedUnreadCounts,
refreshUnreadDisplay,
setFeedFilter,
showMessage,
message,