Author SHA1 Message Date
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
11 changed files with 417 additions and 685 deletions
Generated
+73 -243
View File
@@ -251,14 +251,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "ammonia" name = "ammonia"
version = "4.1.2" version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
dependencies = [ dependencies = [
"cssparser 0.35.0", "cssparser",
"html5ever 0.35.0", "html5ever",
"maplit", "maplit",
"tendril 0.4.3",
"url", "url",
] ]
@@ -323,15 +322,15 @@ dependencies = [
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]] [[package]]
name = "atom_syndication" name = "atom_syndication"
version = "0.12.8" version = "0.12.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39d8b8ef99e33deb2a51504888222225797f48bace2f0cab01975746a39c05bb" checksum = "d48d93a31c932d58b9fd68664a904ae5b74bea37f06c5f68d5bec92f0cdd106d"
dependencies = [ dependencies = [
"chrono", "chrono",
"derive_builder", "derive_builder",
@@ -386,12 +385,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bcrypt" name = "base64"
version = "0.19.1" version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bcrypt"
version = "0.19.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.23.1",
"blowfish", "blowfish",
"getrandom 0.4.2", "getrandom 0.4.2",
"subtle", "subtle",
@@ -506,9 +511,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.10.0" version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.3.0", "cpufeatures 0.3.0",
@@ -668,42 +673,19 @@ dependencies = [
"hybrid-array", "hybrid-array",
] ]
[[package]]
name = "cssparser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
dependencies = [
"cssparser-macros 0.6.1",
"dtoa-short",
"itoa",
"phf 0.11.3",
"smallvec",
]
[[package]] [[package]]
name = "cssparser" name = "cssparser"
version = "0.37.0" version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [ dependencies = [
"cssparser-macros 0.7.0", "cssparser-macros",
"dtoa-short", "dtoa-short",
"itoa", "itoa",
"phf 0.13.1", "phf",
"smallvec", "smallvec",
] ]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn",
]
[[package]] [[package]]
name = "cssparser-macros" name = "cssparser-macros"
version = "0.7.0" version = "0.7.0"
@@ -963,10 +945,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "dotenv" name = "dotenvy"
version = "0.15.0" version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]] [[package]]
name = "downcast-rs" name = "downcast-rs"
@@ -1124,16 +1106,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@@ -1281,11 +1253,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1", "rand_core 0.10.1",
"wasip2", "wasip2",
"wasip3", "wasip3",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1332,9 +1306,9 @@ dependencies = [
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.14" version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -1396,17 +1370,6 @@ dependencies = [
"digest 0.10.7", "digest 0.10.7",
] ]
[[package]]
name = "html5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"markup5ever 0.35.0",
"match_token",
]
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.39.0" version = "0.39.0"
@@ -1414,7 +1377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [ dependencies = [
"log", "log",
"markup5ever 0.39.0", "markup5ever",
] ]
[[package]] [[package]]
@@ -1492,7 +1455,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2 0.4.14", "h2 0.4.19",
"http 1.4.1", "http 1.4.1",
"http-body", "http-body",
"httparse", "httparse",
@@ -1899,29 +1862,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "maplit" name = "maplit"
version = "1.0.2" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [
"log",
"tendril 0.4.3",
"web_atoms 0.1.3",
]
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.39.0" version = "0.39.0"
@@ -1929,19 +1875,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [ dependencies = [
"log", "log",
"tendril 0.5.0", "tendril",
"web_atoms 0.2.4", "web_atoms",
]
[[package]]
name = "match_token"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn",
] ]
[[package]] [[package]]
@@ -2073,55 +2008,25 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]] [[package]]
name = "phf" name = "phf"
version = "0.13.1" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [ dependencies = [
"phf_macros 0.13.1", "phf_macros",
"phf_shared 0.13.1", "phf_shared",
"serde", "serde",
] ]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
]
[[package]] [[package]]
name = "phf_codegen" name = "phf_codegen"
version = "0.13.1" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [ dependencies = [
"phf_generator 0.13.1", "phf_generator",
"phf_shared 0.13.1", "phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.6",
] ]
[[package]] [[package]]
@@ -2131,20 +2036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"phf_shared 0.13.1", "phf_shared",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
] ]
[[package]] [[package]]
@@ -2153,22 +2045,13 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [ dependencies = [
"phf_generator 0.13.1", "phf_generator",
"phf_shared 0.13.1", "phf_shared",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn",
] ]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "phf_shared" name = "phf_shared"
version = "0.13.1" version = "0.13.1"
@@ -2282,9 +2165,9 @@ dependencies = [
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.39.4" version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [ dependencies = [
"encoding_rs", "encoding_rs",
"memchr", "memchr",
@@ -2312,15 +2195,16 @@ dependencies = [
[[package]] [[package]]
name = "quinn-proto" name = "quinn-proto"
version = "0.11.14" version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.4.2",
"lru-slab", "lru-slab",
"rand 0.9.4", "rand 0.10.1",
"rand_pcg",
"ring", "ring",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
@@ -2367,15 +2251,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.4" version = "0.9.4"
@@ -2407,12 +2282,6 @@ dependencies = [
"rand_core 0.9.5", "rand_core 0.9.5",
] ]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.9.5"
@@ -2428,6 +2297,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "raw-cpuid" name = "raw-cpuid"
version = "11.6.0" version = "11.6.0"
@@ -2493,7 +2371,7 @@ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2 0.4.14", "h2 0.4.19",
"http 1.4.1", "http 1.4.1",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -2539,9 +2417,9 @@ dependencies = [
[[package]] [[package]]
name = "rss" name = "rss"
version = "2.0.13" version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e38781082c53bdde56e6081698c06831f69a27eac2593ed6b6cb2511424ad5" checksum = "dc13570823abc675c60d7837f1fe7daaf18dd181a3cacb3c6ba7a062eef581c0"
dependencies = [ dependencies = [
"atom_syndication", "atom_syndication",
"derive_builder", "derive_builder",
@@ -2564,7 +2442,7 @@ dependencies = [
"dateparser", "dateparser",
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
"dotenv", "dotenvy",
"env_logger", "env_logger",
"futures", "futures",
"hmac", "hmac",
@@ -2713,13 +2591,13 @@ version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
dependencies = [ dependencies = [
"cssparser 0.37.0", "cssparser",
"ego-tree", "ego-tree",
"getopts", "getopts",
"html5ever 0.39.0", "html5ever",
"precomputed-hash", "precomputed-hash",
"selectors", "selectors",
"tendril 0.5.0", "tendril",
] ]
[[package]] [[package]]
@@ -2752,12 +2630,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"cssparser 0.37.0", "cssparser",
"derive_more", "derive_more",
"log", "log",
"new_debug_unreachable", "new_debug_unreachable",
"phf 0.13.1", "phf",
"phf_codegen 0.13.1", "phf_codegen",
"precomputed-hash", "precomputed-hash",
"rustc-hash", "rustc-hash",
"servo_arc", "servo_arc",
@@ -2956,19 +2834,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
]
[[package]] [[package]]
name = "string_cache" name = "string_cache"
version = "0.9.0" version = "0.9.0"
@@ -2977,30 +2842,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [ dependencies = [
"new_debug_unreachable", "new_debug_unreachable",
"parking_lot", "parking_lot",
"phf_shared 0.13.1", "phf_shared",
"precomputed-hash", "precomputed-hash",
] ]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
]
[[package]] [[package]]
name = "string_cache_codegen" name = "string_cache_codegen"
version = "0.6.1" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [ dependencies = [
"phf_generator 0.13.1", "phf_generator",
"phf_shared 0.13.1", "phf_shared",
"proc-macro2", "proc-macro2",
"quote", "quote",
] ]
@@ -3069,17 +2922,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]] [[package]]
name = "tendril" name = "tendril"
version = "0.5.0" version = "0.5.0"
@@ -3573,28 +3415,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf 0.11.3",
"phf_codegen 0.11.3",
"string_cache 0.8.9",
"string_cache_codegen 0.5.4",
]
[[package]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.4" version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [ dependencies = [
"phf 0.13.1", "phf",
"phf_codegen 0.13.1", "phf_codegen",
"string_cache 0.9.0", "string_cache",
"string_cache_codegen 0.6.1", "string_cache_codegen",
] ]
[[package]] [[package]]
+1 -1
View File
@@ -18,7 +18,7 @@ serde_derive = "1.0.228"
actix-service = "2.0.3" actix-service = "2.0.3"
diesel = { version = "2.3", features = ["postgres", "chrono"] } diesel = { version = "2.3", features = ["postgres", "chrono"] }
diesel_migrations = "2.3" diesel_migrations = "2.3"
dotenv = "0.15.0" dotenvy = "0.15"
bcrypt = "0.19" bcrypt = "0.19"
uuid = {version = "1.23", features=["serde", "v4"]} uuid = {version = "1.23", features=["serde", "v4"]}
jwt = "0.16.0" jwt = "0.16.0"
+1 -1
View File
@@ -6,7 +6,7 @@ use std::env;
use actix_web::HttpRequest; use actix_web::HttpRequest;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use dotenv::dotenv; use dotenvy::dotenv;
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use jwt::{Header, SignWithKey, Token, VerifyWithKey}; use jwt::{Header, SignWithKey, Token, VerifyWithKey};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
+1 -1
View File
@@ -1,7 +1,7 @@
use diesel::pg::PgConnection; use diesel::pg::PgConnection;
use diesel::prelude::*; use diesel::prelude::*;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use dotenv::dotenv; use dotenvy::dotenv;
use std::env; use std::env;
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
+1 -2
View File
@@ -1,10 +1,9 @@
extern crate diesel; extern crate diesel;
extern crate dotenv;
use actix_cors::Cors; use actix_cors::Cors;
use actix_service::Service; use actix_service::Service;
use actix_web::{App, HttpMessage, HttpResponse, HttpServer}; use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
use dotenv::dotenv; use dotenvy::dotenv;
use futures::future::{ok, Either}; use futures::future::{ok, Either};
use std::env; use std::env;
mod auth; mod auth;
+35 -13
View File
@@ -6,16 +6,37 @@ import Modal from './modal/AddUrl.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, feedUnreadCounts, setFeedFilter, unreadCount, lastProgrammaticScroll } = useFeeds() const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds()
const headerRef = ref(null) const headerRef = ref(null)
// Native <select>/<option> has no "is open" state — the closed box just shows
// the selected option's text verbatim, so per-option unread counts can only be
// toggled based on focus, since the popup is only ever shown while focused.
// @focus ~ opening (shows counts); @change (picking an option closes the
// popup but keeps focus, so no blur fires) and @blur (focus left without
// picking) ~ closed (counts hidden, so the closed box just shows the name).
const filterOpen = ref(false)
function onFilterFocus() {
filterOpen.value = true
refreshUnreadDisplay()
}
function onFilterChange(event) {
setFeedFilter(event.target.value || null)
filterOpen.value = false
}
// Scroll-driven show/hide: the header slides out of view on scroll-down and // Scroll-driven show/hide: the header slides out of view on scroll-down and
// back in on scroll-up. This is show/hide via `transform` (not the old // back in only on scroll-up — never merely by proximity to the top. This is
// resize behaviour) — the header is position:fixed, so translating it never // show/hide via `transform` (not the old resize behaviour) — the header is
// reflows content, and the app's programmatic scrolls resolve to sensible // position:fixed, so translating it never reflows content. The app's
// states: scrollTo(0, 0) lands near the top → shown; the list-view // programmatic scrolls resolve to sensible states without any top-of-page
// read-correction scrollBy moves only a few px → stays under the threshold. // special case: scrollTo(0, 0) (article paging, layout toggle) is itself a
// large upward jump, so it crosses REVEAL_THRESHOLD like a real scroll-up and
// reveals the header that way; the list-view read-correction scrollBy moves
// only a few px → stays under the threshold and leaves a hidden header hidden.
const hidden = ref(false) const hidden = ref(false)
const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
// When the feed list mutates itself (read-correction scrollBy + array-splice // When the feed list mutates itself (read-correction scrollBy + array-splice
@@ -30,12 +51,10 @@ let accumulated = 0
function onScroll() { function onScroll() {
const y = Math.max(0, window.scrollY) const y = Math.max(0, window.scrollY)
const headerH = headerRef.value?.offsetHeight ?? 0
// Always reveal near the very top, and keep it visible while the menu is // Keep the header visible while the menu is open (the dropdown is anchored
// open (the dropdown is anchored to the header, so hiding it would slide the // to the header, so hiding it would slide the open menu off-screen).
// open menu off-screen). if (menuOpen.value) {
if (y <= headerH || menuOpen.value) {
hidden.value = false hidden.value = false
accumulated = 0 accumulated = 0
lastY = y lastY = y
@@ -78,6 +97,7 @@ onMounted(() => {
lastY = Math.max(0, window.scrollY) lastY = Math.max(0, window.scrollY)
window.addEventListener('scroll', onScrollRaf, { passive: true }) window.addEventListener('scroll', onScrollRaf, { passive: true })
document.addEventListener('click', onDocumentClick) document.addEventListener('click', onDocumentClick)
refreshUnreadDisplay()
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -152,10 +172,12 @@ function handleToggleLayout() {
class="app-nav__filter" class="app-nav__filter"
:value="feedFilter ?? ''" :value="feedFilter ?? ''"
aria-label="Filter by feed" aria-label="Filter by feed"
@change="setFeedFilter($event.target.value || null)" @focus="onFilterFocus"
@change="onFilterChange"
@blur="filterOpen = false"
> >
<option value="">All feeds</option> <option value="">All feeds</option>
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }} ({{ feedUnreadCounts[title] ?? 0 }})</option> <option v-for="title in feedTitles" :key="title" :value="title">{{ title }}<template v-if="filterOpen"> ({{ displayedFeedUnreadCounts[title] ?? 0 }})</template></option>
</select> </select>
<button <button
class="app-nav__hamburger" class="app-nav__hamburger"
+41 -175
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue'; import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds'; import { useFeeds } from '@/composables/useFeeds';
const { const {
@@ -34,43 +34,14 @@ function scrollToNextArticle() {
} }
} }
// Swipe-to-navigate in article view. The article visually tracks the finger // Swipe-to-navigate in article view. Transient per-gesture state, not shared
// while dragging (dragOffsetX/isDragging below), then either completes the // app state, so it's kept as plain component-local variables rather than in
// page-turn or springs back to center on release. This is transient // useFeeds() — nothing outside this component needs it.
// per-gesture state, not shared app state, so it's kept local to this
// component rather than in useFeeds() — nothing outside this component needs
// it.
const dragOffsetX = ref(0) // live translateX (px) applied to the current article while dragging or springing back
const isDragging = ref(false) // true only while actively tracking touchmove — disables the CSS transition so the article follows the finger with zero lag; the spring-back and the eventual page-turn both happen after this flips back to false, so they use the (slower, already-tuned) CSS transitions instead
let touchStartX = null let touchStartX = null
let touchStartY = null let touchStartY = null
let touchStartTime = null
let gestureAxis = null // 'horizontal' | 'vertical' | null (undecided) — locked in after DIRECTION_LOCK_THRESHOLD of movement, so a single gesture can't both page the article and scroll the page
let articleWidth = 0 // captured on touchstart; the live drag and the commit-distance check below are relative to it
let dragRafPending = false
let pendingDragOffset = 0
const DIRECTION_LOCK_THRESHOLD = 10 // px of movement before an ambiguous drag commits to horizontal (swipe) or vertical (scroll) const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe
const COMMIT_DISTANCE_RATIO = 0.35 // fraction of the article's width dragged that counts as "far enough" to complete the page-turn const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe
const COMMIT_VELOCITY = 0.5 // px/ms — a flick at least this fast completes the page-turn even short of the distance ratio
const BOUNDARY_DAMPING = 0.35 // drag multiplier applied when dragging past the first/last article, so it still moves a little (feedback) but resists
// Drives which slide-in/slide-out transition ('slide-next' / 'slide-prev') the
// article view uses — set right before the index actually changes, by both
// the nav buttons and the swipe handler below, so the two triggers animate
// identically.
const slideDirection = ref('next')
function navigateToNextArticle() {
slideDirection.value = 'next'
nextArticle()
}
function navigateToPrevArticle() {
slideDirection.value = 'prev'
prevArticle()
}
function onArticleTouchStart(event) { function onArticleTouchStart(event) {
// Ignore multi-touch (pinch-zoom) and swipes starting inside a // Ignore multi-touch (pinch-zoom) and swipes starting inside a
@@ -78,79 +49,27 @@ function onArticleTouchStart(event) {
// — those should scroll the block, not page the article. // — those should scroll the block, not page the article.
if (event.touches.length !== 1 || event.target.closest('pre')) { if (event.touches.length !== 1 || event.target.closest('pre')) {
touchStartX = null touchStartX = null
touchStartY = null
return return
} }
const touch = event.touches[0] touchStartX = event.touches[0].clientX
touchStartX = touch.clientX touchStartY = event.touches[0].clientY
touchStartY = touch.clientY
touchStartTime = Date.now()
gestureAxis = null
const article = event.currentTarget.querySelector('.article-feature')
articleWidth = article ? article.offsetWidth : window.innerWidth
}
function onArticleTouchMove(event) {
if (touchStartX === null || event.touches.length !== 1) return
const touch = event.touches[0]
const deltaX = touch.clientX - touchStartX
const deltaY = touch.clientY - touchStartY
if (gestureAxis === null) {
if (Math.abs(deltaX) < DIRECTION_LOCK_THRESHOLD && Math.abs(deltaY) < DIRECTION_LOCK_THRESHOLD) return
gestureAxis = Math.abs(deltaX) > Math.abs(deltaY) ? 'horizontal' : 'vertical'
if (gestureAxis === 'horizontal') isDragging.value = true
}
if (gestureAxis !== 'horizontal') return // let the page scroll normally instead
// Stop the page from also scrolling vertically (and the browser's own
// horizontal-overscroll gesture) while we're driving the drag ourselves.
// Requires this listener to not be passive.
event.preventDefault()
const atBoundary = (deltaX < 0 && currentIndex.value === feeds.value.length - 1) ||
(deltaX > 0 && currentIndex.value === 0)
pendingDragOffset = atBoundary ? deltaX * BOUNDARY_DAMPING : deltaX
// rAF-throttled: touchmove can fire faster than the display refreshes. If a
// frame is already scheduled, later events in that window just update
// pendingDragOffset above — the callback always reads the latest value
// once it actually runs, rather than whatever triggered the first one.
if (dragRafPending) return
dragRafPending = true
requestAnimationFrame(() => {
dragOffsetX.value = pendingDragOffset
dragRafPending = false
})
} }
function onArticleTouchEnd(event) { function onArticleTouchEnd(event) {
if (touchStartX === null) return if (touchStartX === null) return
const wasDraggingHorizontally = gestureAxis === 'horizontal' const touch = event.changedTouches[0]
// Computed fresh from the release position rather than read back from const deltaX = touch.clientX - touchStartX
// dragOffsetX, which is rAF-throttled and so can be a frame stale. const deltaY = touch.clientY - touchStartY
const deltaX = event.changedTouches[0].clientX - touchStartX
const elapsed = Date.now() - touchStartTime
touchStartX = null touchStartX = null
touchStartY = null touchStartY = null
gestureAxis = null
isDragging.value = false
if (!wasDraggingHorizontally) return if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
// Date.now() only has ~1ms resolution, so a genuinely fast flick often
// measures as elapsed === 0 — treat that as "as fast as it gets" (clears
// the flick check) rather than dividing by zero into "no velocity at all".
const isFastFlick = elapsed > 0 ? Math.abs(deltaX / elapsed) > COMMIT_VELOCITY : deltaX !== 0
const shouldComplete = Math.abs(deltaX) > articleWidth * COMMIT_DISTANCE_RATIO || isFastFlick
dragOffsetX.value = 0 // springs back to center — or, if completing, the (now off-screen-bound) article being replaced
if (!shouldComplete) return
if (deltaX < 0) { if (deltaX < 0) {
navigateToNextArticle() nextArticle()
} else { } else {
navigateToPrevArticle() prevArticle()
} }
} }
@@ -246,13 +165,7 @@ onMounted(async () => {
>&darr;</button> >&darr;</button>
</div> </div>
<div <div v-else class="article-single" @touchstart.passive="onArticleTouchStart" @touchend.passive="onArticleTouchEnd">
v-else
class="article-single"
@touchstart.passive="onArticleTouchStart"
@touchmove="onArticleTouchMove"
@touchend.passive="onArticleTouchEnd"
>
<div v-if="feeds.length == 0" class="empty-state"> <div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
@@ -260,25 +173,22 @@ onMounted(async () => {
</svg> </svg>
<p class="empty-state__label">All caught up</p> <p class="empty-state__label">All caught up</p>
</div> </div>
<template v-else> <!-- Deliberately no enter/leave transition here: Vue's <Transition>
<Transition :name="`slide-${slideDirection}`"> component forces a synchronous whole-document reflow
<article (document.body.offsetHeight, in @vue/runtime-dom's forceReflow) on
class="article-feature" every enter/leave, regardless of the app's own CSS — confirmed (via
:class="{ 'article-feature--dragging': isDragging }" a DevTools performance trace) to be what was corrupting the header's
:style="dragOffsetX ? { transform: `translateX(${dragOffsetX}px)` } : null" native <select>/<button> rendering during article paging. -->
:key="currentIndex" <article v-else class="article-feature" :key="currentIndex">
> <p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p> <h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2> <h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3> <p class="feed-original-link">
<p class="feed-original-link"> <a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a> <button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button> </p>
</p> <p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p> </article>
</article>
</Transition>
</template>
<div class="article-nav"> <div class="article-nav">
<button <button
@@ -286,14 +196,14 @@ onMounted(async () => {
class="article-nav__btn" class="article-nav__btn"
:disabled="currentIndex === 0" :disabled="currentIndex === 0"
aria-label="Previous article" aria-label="Previous article"
@click="navigateToPrevArticle" @click="prevArticle"
>&uarr;</button> >&uarr;</button>
<button <button
type="button" type="button"
class="article-nav__btn" class="article-nav__btn"
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1" :disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
aria-label="Next article" aria-label="Next article"
@click="navigateToNextArticle" @click="nextArticle"
>&darr;</button> >&darr;</button>
</div> </div>
</div> </div>
@@ -443,62 +353,18 @@ onMounted(async () => {
align-items: center; align-items: center;
padding-top: 1em; padding-top: 1em;
padding-bottom: 5rem; padding-bottom: 5rem;
/* Fill the viewport below the fixed header even when the article content
is short, so swipe-to-navigate works anywhere on screen instead of only
over the (possibly small) content area. dvh is the accurate unit on
mobile, where this matters most; vh is the fallback for browsers
without dvh support. */
min-height: calc(100vh - var(--app-nav-height, 4.5rem));
min-height: calc(100dvh - var(--app-nav-height, 4.5rem));
} }
.article-feature { .article-feature {
width: 100%; width: 100%;
max-width: 720px; max-width: 720px;
/* Governs the drag-release spring-back (dragOffsetX snapping to 0 after a
cancelled swipe). Overridden by .article-feature--dragging (below, so it
wins the tie) while actively tracking a touchmove, and by the
.slide-next/.slide-prev *-active rules (further below, so they win too)
during an actual page-turn — both need their own transform transition
timing instead. */
transition: transform 0.2s ease;
}
.article-feature--dragging {
transition: none;
}
/* Slide-in/out when paging between articles (swipe or nav buttons). The
outgoing article is pulled out of flex flow during its leave transition so
the incoming one can take its place immediately, instead of the two
stacking and doubling the page height for the transition's duration. */
.slide-next-enter-active,
.slide-next-leave-active,
.slide-prev-enter-active,
.slide-prev-leave-active {
transition: transform 0.28s ease, opacity 0.28s ease;
}
.slide-next-leave-active,
.slide-prev-leave-active {
position: absolute;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
}
.slide-next-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-next-leave-to {
transform: translateX(-100%);
opacity: 0;
}
.slide-prev-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.slide-prev-leave-to {
transform: translateX(100%);
opacity: 0;
} }
.article-feature__source { .article-feature__source {
+56 -6
View File
@@ -239,13 +239,36 @@ describe('AppNav', () => {
const wrapper = mountNav() const wrapper = mountNav()
await flushPromises() await flushPromises()
const select = wrapper.find('.app-nav__filter')
const options = wrapper.find('.app-nav__filter').findAll('option').map(o => o.text()) // Counts only show while the dropdown is open (focused) — see AppNav.vue.
await select.trigger('focus')
const options = select.findAll('option').map(o => o.text())
// "All feeds" first, then distinct titles sorted alphabetically, each with // "All feeds" first, then distinct titles sorted alphabetically, each with
// its unread count. // its unread count.
expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)']) expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)'])
}) })
it('hides the per-option unread count once the feed filter is closed', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
const wrapper = mountNav()
await flushPromises()
const select = wrapper.find('.app-nav__filter')
await select.trigger('focus')
await select.setValue('Feed A') // fires change, which closes the popup but keeps focus
await flushPromises()
const feedAOption = select.findAll('option').find(o => o.element.value === 'Feed A')
expect(feedAOption.text()).toBe('Feed A')
})
it('does not render the feed filter when there are no feeds', async () => { it('does not render the feed filter when there are no feeds', async () => {
const wrapper = mountNav() const wrapper = mountNav()
await flushPromises() await flushPromises()
@@ -306,9 +329,17 @@ describe('AppNav', () => {
// Reset scroll position so each mount's lastY baseline starts at 0. // Reset scroll position so each mount's lastY baseline starts at 0.
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true }) Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 }) vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
// offsetHeight is 0 in jsdom; give the header a real height so the // getBoundingClientRect() is all-zero in jsdom; give the header a real
// "near the top" guard (scrollY <= headerH) has something to compare to. // height so onMounted()'s one-time measurement (used for the
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(50) // --app-nav-height CSS var — see AppNav.vue) has a realistic value.
// This is a prototype-wide spy (scoped to this describe block only, restored in
// afterEach), so it returns a full DOMRect-shaped object rather than a
// partial one — no test here happens to call getBoundingClientRect() on
// anything but the header, but a bare `{ height: 50 }` would silently
// hand back `undefined` for top/left/width/etc. to any code that did.
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
height: 50, width: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {},
})
}) })
afterEach(() => { afterEach(() => {
@@ -341,19 +372,38 @@ describe('AppNav', () => {
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden') expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
}) })
it('always shows the header near the top of the page', async () => { it('reveals the header on a large upward jump that lands near the top', async () => {
const wrapper = mountNav() const wrapper = mountNav()
scrollTo(400) scrollTo(400)
await nextTick() await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden') expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// Back within the header's own height of the top → always revealed. // A jump this large (e.g. article paging's scrollTo(0, 0)) crosses
// REVEAL_THRESHOLD on its own — no top-of-page special case needed.
scrollTo(10) scrollTo(10)
await nextTick() await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden') expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
}) })
it('does not reveal the header merely for landing near the top', async () => {
const wrapper = mountNav()
// Hide it with a jump just past the header's own height (50px, mocked
// above) plus the reveal threshold.
scrollTo(62)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// Move back to right at the header's own height — within the "near the
// top" zone the old code always revealed for — but the upward delta
// (12px) doesn't exceed REVEAL_THRESHOLD, so a hidden header stays
// hidden: proximity to the top is no longer sufficient on its own.
scrollTo(50)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
})
it('does not let a programmatic upward jump reveal the header mid-read', async () => { it('does not let a programmatic upward jump reveal the header mid-read', async () => {
const { markProgrammaticScroll } = useFeeds() const { markProgrammaticScroll } = useFeeds()
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000) const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000)
+159 -242
View File
@@ -360,261 +360,178 @@ describe('RssFeeds', () => {
expect(titles).toEqual(['Article three']) expect(titles).toEqual(['Article three'])
}) })
describe('swipe navigation in article view', () => { it('swipes left to navigate to the next article', async () => {
// The drag-follow handler is rAF-throttled (dragOffsetX only updates once axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
// per frame) and reads the article's real offsetWidth for the axios.put.mockResolvedValue({ status: 200 })
// commit-distance check — stub both so gestures are deterministic. Per
// the CLAUDE.md Vitest gotcha, avoid bare fake timers here since they'd const wrapper = mount(RssFeeds)
// also fake rAF and clobber this manual stub. (Faking Date, even scoped, await flushPromises()
// was tried for the release-velocity check below and broke test-utils'
// event dispatch outright — real small setTimeout delays instead.) useFeeds().toggleViewMode()
beforeEach(() => { await flushPromises()
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(400) const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
wrapper.unmount()
})
it('swipes right to navigate to the previous article', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 130, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 200, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a swipe shorter than the horizontal threshold', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 180, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a mostly-vertical drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 250 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a diagonal drag that exceeds the vertical restraint, even with a qualifying horizontal delta', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 200 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('ignores a multi-touch gesture (pinch-zoom)', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', {
touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }],
}) })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
afterEach(() => { expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
// Not vi.unstubAllGlobals(): it would also remove the file-level
// IntersectionObserver stub (stubGlobal isn't describe-scoped), and wrapper.unmount()
// every test in here re-stubs requestAnimationFrame in beforeEach })
// anyway, so there's nothing to reset it back to for.
vi.restoreAllMocks() it('does not navigate when a swipe starts inside a code block', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{ id: 1, title: 'Article one', content: '<pre>some code</pre>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
],
},
],
},
}) })
axios.put.mockResolvedValue({ status: 200 })
// Drives one full touchstart -> touchmove -> touchend gesture. delayMs is const wrapper = mount(RssFeeds)
// a genuine wall-clock wait before releasing — it's how tests control the await flushPromises()
// release velocity (deltaX / elapsed) the component computes, alongside
// the distance dragged.
async function swipe(wrapper, { startX, startY = 100, endX, endY = 100, delayMs = 0 }) {
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: startX, clientY: startY }] })
await article.trigger('touchmove', { touches: [{ clientX: endX, clientY: endY }] })
if (delayMs > 0) {
await new Promise(resolve => setTimeout(resolve, delayMs))
}
await article.trigger('touchend', { changedTouches: [{ clientX: endX, clientY: endY }] })
await flushPromises()
}
it('drags the article to follow the finger while swiping', async () => { useFeeds().toggleViewMode()
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) await flushPromises()
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds) await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await flushPromises() await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
useFeeds().toggleViewMode() await flushPromises()
await flushPromises()
const article = wrapper.find('.article-single') expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchmove', { touches: [{ clientX: 150, clientY: 100 }] })
const dragged = wrapper.find('.article-feature') wrapper.unmount()
expect(dragged.classes()).toContain('article-feature--dragging') })
expect(dragged.attributes('style')).toContain('translateX(-50px)')
wrapper.unmount() it('does not throw when swiping with an empty feed list', async () => {
}) axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
it('completes the page-turn on a swipe past the distance threshold', async () => { const wrapper = mount(RssFeeds)
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) await flushPromises()
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds) useFeeds().toggleViewMode()
await flushPromises() await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
// 400px-wide article (mocked above), 35% commit ratio -> needs >140px. const article = wrapper.find('.article-single')
await swipe(wrapper, { startX: 300, endX: 80 }) await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two') expect(wrapper.text()).toContain('All caught up')
wrapper.unmount() wrapper.unmount()
})
it('completes the page-turn backwards on a swipe past the distance threshold', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
await swipe(wrapper, { startX: 80, endX: 300 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('springs back without navigating on a short, slow drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
// Only 20px of a 400px-wide article (well under the 140px distance
// threshold), released slowly enough that velocity doesn't qualify
// either (well under the 0.5px/ms flick threshold).
await swipe(wrapper, { startX: 200, endX: 180, delayMs: 150 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
expect(wrapper.find('.article-feature').classes()).not.toContain('article-feature--dragging')
wrapper.unmount()
})
it('completes the page-turn on a fast flick even under the distance threshold', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
// Only 60px (well under the 140px distance threshold), released with
// no extra delay. Date.now() only has ~1ms resolution, so a gesture
// this fast typically measures as 0ms elapsed — the component treats
// that as clearing the flick check outright (see the comment by
// isFastFlick in onArticleTouchEnd) rather than as "no velocity".
await swipe(wrapper, { startX: 200, endX: 140 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
wrapper.unmount()
})
it('does not drag or navigate on a mostly-vertical drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await swipe(wrapper, { startX: 200, startY: 100, endX: 205, endY: 250 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate when the vertical component dominates a diagonal drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
// More horizontal travel (70px) than in the previous test, but the
// vertical component (100px) is still larger — locks as a vertical
// scroll, not a swipe, regardless of the sizeable deltaX.
await swipe(wrapper, { startX: 200, startY: 100, endX: 130, endY: 200 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('applies resistance when dragging past the last article', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.findAll('.article-nav__btn')[1].trigger('click') // now on the last article
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 300, clientY: 100 }] })
await article.trigger('touchmove', { touches: [{ clientX: 200, clientY: 100 }] }) // still swiping towards "next"
// Damped to 35% of the raw -100px drag, not the full amount.
expect(wrapper.find('.article-feature').attributes('style')).toContain('translateX(-35px)')
wrapper.unmount()
})
it('ignores a multi-touch gesture (pinch-zoom)', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', {
touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }],
})
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not drag or navigate when a swipe starts inside a code block', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{ id: 1, title: 'Article one', content: '<pre>some code</pre>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
],
},
],
},
})
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await wrapper.find('.article-single').trigger('touchmove', { touches: [{ clientX: 130, clientY: 100 }] })
await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
expect(wrapper.find('.article-feature').classes()).not.toContain('article-feature--dragging')
wrapper.unmount()
})
it('does not throw when swiping with an empty feed list', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await swipe(wrapper, { startX: 200, endX: 80 })
expect(wrapper.text()).toContain('All caught up')
wrapper.unmount()
})
}) })
}) })
+27 -1
View File
@@ -13,7 +13,7 @@ class FakeIntersectionObserver {
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('useFeeds', () => { describe('useFeeds', () => {
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds() const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds()
beforeEach(() => { beforeEach(() => {
localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-token', 'test-token')
@@ -119,6 +119,32 @@ describe('useFeeds', () => {
setInitialLoad(false) setInitialLoad(false)
}) })
it('refreshes the filter dropdown\'s displayed per-feed counts when list-view scrolling marks an article read', async () => {
// displayedFeedUnreadCounts is a frozen snapshot of feedUnreadCounts (see
// useFeeds.js) — AppNav's filter <select> renders it instead of the live
// computed so paging through article view doesn't force it to repaint.
// List-view scrolling is a different code path (handleIntersection) and
// should keep it live regardless.
allItems.value = [
{ id: 301, feedTitle: 'Feed A', read: false },
{ id: 302, feedTitle: 'Feed A', read: false },
]
feeds.value = [...allItems.value]
refreshUnreadDisplay()
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 2 })
setInitialLoad(true)
axios.put.mockResolvedValue({ status: 200 })
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
])
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 1 })
setInitialLoad(false)
})
it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => { it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => {
feeds.value = [ feeds.value = [
{ id: 201, title: 'First' }, { id: 201, title: 'First' },
+22
View File
@@ -43,6 +43,21 @@ const unreadCount = computed(() => {
: allItems.value : allItems.value
return items.filter(i => !i.read).length return items.filter(i => !i.read).length
}) })
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below)
// for the filter <select>'s per-feed option counts, instead of binding
// feedUnreadCounts directly — by design, this stays frozen while paging
// through article view (markCurrentArticleRead marks one article read per
// page turn, which would otherwise recompute it live on every single swipe).
// The header's own unread badge stays bound to the live unreadCount (it
// should update in real time regardless of view). Everywhere else that marks
// articles read — list-view scrolling (handleIntersection), opening/changing
// the filter, syncing, mark-all-read, leaving article view — refreshes this
// snapshot immediately, so it only ever looks stale during article-view
// paging itself.
const displayedFeedUnreadCounts = ref({})
function refreshUnreadDisplay() {
displayedFeedUnreadCounts.value = feedUnreadCounts.value
}
const message = ref('') const message = ref('')
const showModal = ref(false) const showModal = ref(false)
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
@@ -242,6 +257,7 @@ async function setFeedFilter(title) {
feedFilter.value = title // null for "All feeds" feedFilter.value = title // null for "All feeds"
currentIndex.value = 0 currentIndex.value = 0
applyFilter() applyFilter()
refreshUnreadDisplay()
await nextTick() await nextTick()
setupIntersectionObserver() setupIntersectionObserver()
} }
@@ -259,6 +275,7 @@ const fetchData = async () => {
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
allItems.value = items; allItems.value = items;
applyFilter(); applyFilter();
refreshUnreadDisplay();
await nextTick(); await nextTick();
setupIntersectionObserver(); setupIntersectionObserver();
} catch (error) { } catch (error) {
@@ -346,6 +363,7 @@ function handleIntersection(entries, topbarHeight = 0) {
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id)) feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
// Mirror into the master so cleared filters don't resurrect read articles. // Mirror into the master so cleared filters don't resurrect read articles.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id)) allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
refreshUnreadDisplay()
for (const feed of readFeeds) { for (const feed of readFeeds) {
markRead(feed.id) markRead(feed.id)
@@ -392,6 +410,7 @@ async function markAllRead() {
// is active) — drop exactly those from the master too. // is active) — drop exactly those from the master too.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id)) allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
currentIndex.value = 0 currentIndex.value = 0
refreshUnreadDisplay()
// markRead swallows its own errors, so Promise.all can't reject here. // markRead swallows its own errors, so Promise.all can't reject here.
await Promise.all(ids.map(id => markRead(id))) await Promise.all(ids.map(id => markRead(id)))
showMessageForXSeconds('All articles marked as read.', 5) showMessageForXSeconds('All articles marked as read.', 5)
@@ -418,6 +437,7 @@ async function leaveArticleView() {
currentIndex.value = 0 currentIndex.value = 0
viewMode.value = 'list' viewMode.value = 'list'
localStorage.setItem('viewMode', viewMode.value) localStorage.setItem('viewMode', viewMode.value)
refreshUnreadDisplay()
// The v-if on the list container tears down and recreates all .observe DOM // The v-if on the list container tears down and recreates all .observe DOM
// nodes when switching views, so the intersection observer must be // nodes when switching views, so the intersection observer must be
// re-pointed at the new elements after Vue has finished rendering. // re-pointed at the new elements after Vue has finished rendering.
@@ -479,6 +499,8 @@ export function useFeeds() {
feedTitles, feedTitles,
feedUnreadCounts, feedUnreadCounts,
unreadCount, unreadCount,
displayedFeedUnreadCounts,
refreshUnreadDisplay,
setFeedFilter, setFeedFilter,
showMessage, showMessage,
message, message,