Merge pull request #86 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-03-31 23:21:27 +08:00 committed by GitHub
commit e29f14bbcf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 3136 additions and 1878 deletions

View file

@ -105,6 +105,8 @@ And here I am. Building a Mastodon web client.
- [Mastodeck](https://mastodeck.com/)
- [Trunks (alpha)](https://alpha.trunks.social/)
- [Tooty](https://github.com/n1k0/tooty)
- [Litterbox](https://litterbox.koyu.space/)
- [Statuzer](https://statuzer.com/)
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
## License

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Compose / Phanpy</title>
<title>Compose / %VITE_CLIENT_NAME%</title>
<meta name="color-scheme" content="dark light" />
<meta name="google" content="notranslate" />
</head>

View file

@ -6,7 +6,7 @@
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title><{ VITE_CLIENT_NAME }></title>
<title>%VITE_CLIENT_NAME%</title>
<meta
name="description"
content="Minimalistic opinionated Mastodon web client"
@ -14,10 +14,10 @@
<meta name="color-scheme" content="dark light" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="<{ VITE_CLIENT_NAME }>" />
<meta name="apple-mobile-web-app-title" content="%VITE_CLIENT_NAME%" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="canonical" href="<{ VITE_WEBSITE }>" />
<link rel="canonical" href="%VITE_WEBSITE%" />
<meta
name="theme-color"
content="#fff"
@ -33,13 +33,13 @@
<!-- Metacrap https://broken-links.com/2015/12/01/little-less-metacrap/ -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="og:url" content="<{ VITE_WEBSITE }>" />
<meta property="og:title" content="<{ VITE_CLIENT_NAME }>" />
<meta property="og:url" content="%VITE_WEBSITE%" />
<meta property="og:title" content="%VITE_CLIENT_NAME%" />
<meta
property="og:description"
content="Minimalistic opinionated Mastodon web client"
/>
<meta property="og:image" content="<{ VITE_WEBSITE }>/og-image.png" />
<meta property="og:image" content="%VITE_WEBSITE%/og-image.png" />
</head>
<body>
<div id="app"></div>

471
package-lock.json generated
View file

@ -11,6 +11,7 @@
"@formatjs/intl-localematcher": "~0.2.32",
"@github/text-expander-element": "~2.3.0",
"@iconify-icons/mingcute": "~1.2.4",
"@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~3.5.2",
"dayjs": "~1.11.7",
"dayjs-twitter": "~0.5.0",
@ -23,8 +24,9 @@
"p-retry": "~5.1.2",
"p-throttle": "~5.0.0",
"preact": "~10.13.1",
"react-hotkeys-hook": "~4.3.7",
"react-hotkeys-hook": "~4.3.8",
"react-intersection-observer": "~9.4.3",
"react-quick-pinch-zoom": "~4.6.0",
"react-router-dom": "6.6.2",
"string-length": "~5.0.1",
"swiped-events": "~1.1.7",
@ -42,10 +44,9 @@
"postcss-dark-theme-class": "~0.7.3",
"postcss-preset-env": "~8.0.1",
"twitter-text": "~3.1.0",
"vite": "~4.1.4",
"vite": "~4.2.1",
"vite-plugin-generate-file": "~0.0.4",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-html-env": "~1.2.7",
"vite-plugin-pwa": "~0.14.4",
"vite-plugin-remove-console": "~2.1.0",
"workbox-cacheable-response": "~6.5.4",
@ -2159,9 +2160,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.16.17.tgz",
"integrity": "sha512-N9x1CMXVhtWEAMS7pNNONyA14f71VPQN9Cnavj1XQh6T7bskqiLLrSca4O0Vr8Wdcga943eThxnVp3JLnBMYtw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.12.tgz",
"integrity": "sha512-E/sgkvwoIfj4aMAPL2e35VnUJspzVYl7+M1B2cqeubdBhADV4uPon0KCc8p2G+LqSJ6i8ocYPCqY3A4GGq0zkQ==",
"cpu": [
"arm"
],
@ -2175,9 +2176,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.16.17.tgz",
"integrity": "sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.12.tgz",
"integrity": "sha512-WQ9p5oiXXYJ33F2EkE3r0FRDFVpEdcDiwNX3u7Xaibxfx6vQE0Sb8ytrfQsA5WO6kDn6mDfKLh6KrPBjvkk7xA==",
"cpu": [
"arm64"
],
@ -2191,9 +2192,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.16.17.tgz",
"integrity": "sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.12.tgz",
"integrity": "sha512-m4OsaCr5gT+se25rFPHKQXARMyAehHTQAz4XX1Vk3d27VtqiX0ALMBPoXZsGaB6JYryCLfgGwUslMqTfqeLU0w==",
"cpu": [
"x64"
],
@ -2207,9 +2208,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.16.17.tgz",
"integrity": "sha512-/2agbUEfmxWHi9ARTX6OQ/KgXnOWfsNlTeLcoV7HSuSTv63E4DqtAc+2XqGw1KHxKMHGZgbVCZge7HXWX9Vn+w==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.12.tgz",
"integrity": "sha512-O3GCZghRIx+RAN0NDPhyyhRgwa19MoKlzGonIb5hgTj78krqp9XZbYCvFr9N1eUxg0ZQEpiiZ4QvsOQwBpP+lg==",
"cpu": [
"arm64"
],
@ -2223,9 +2224,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.16.17.tgz",
"integrity": "sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.12.tgz",
"integrity": "sha512-5D48jM3tW27h1qjaD9UNRuN+4v0zvksqZSPZqeSWggfMlsVdAhH3pwSfQIFJwcs9QJ9BRibPS4ViZgs3d2wsCA==",
"cpu": [
"x64"
],
@ -2239,9 +2240,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.16.17.tgz",
"integrity": "sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.12.tgz",
"integrity": "sha512-OWvHzmLNTdF1erSvrfoEBGlN94IE6vCEaGEkEH29uo/VoONqPnoDFfShi41Ew+yKimx4vrmmAJEGNoyyP+OgOQ==",
"cpu": [
"arm64"
],
@ -2255,9 +2256,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.16.17.tgz",
"integrity": "sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.12.tgz",
"integrity": "sha512-A0Xg5CZv8MU9xh4a+7NUpi5VHBKh1RaGJKqjxe4KG87X+mTjDE6ZvlJqpWoeJxgfXHT7IMP9tDFu7IZ03OtJAw==",
"cpu": [
"x64"
],
@ -2271,9 +2272,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.16.17.tgz",
"integrity": "sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.12.tgz",
"integrity": "sha512-WsHyJ7b7vzHdJ1fv67Yf++2dz3D726oO3QCu8iNYik4fb5YuuReOI9OtA+n7Mk0xyQivNTPbl181s+5oZ38gyA==",
"cpu": [
"arm"
],
@ -2287,9 +2288,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.16.17.tgz",
"integrity": "sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.12.tgz",
"integrity": "sha512-cK3AjkEc+8v8YG02hYLQIQlOznW+v9N+OI9BAFuyqkfQFR+DnDLhEM5N8QRxAUz99cJTo1rLNXqRrvY15gbQUg==",
"cpu": [
"arm64"
],
@ -2303,9 +2304,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.16.17.tgz",
"integrity": "sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.12.tgz",
"integrity": "sha512-jdOBXJqcgHlah/nYHnj3Hrnl9l63RjtQ4vn9+bohjQPI2QafASB5MtHAoEv0JQHVb/xYQTFOeuHnNYE1zF7tYw==",
"cpu": [
"ia32"
],
@ -2319,9 +2320,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.16.17.tgz",
"integrity": "sha512-dTzNnQwembNDhd654cA4QhbS9uDdXC3TKqMJjgOWsC0yNCbpzfWoXdZvp0mY7HU6nzk5E0zpRGGx3qoQg8T2DQ==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.12.tgz",
"integrity": "sha512-GTOEtj8h9qPKXCyiBBnHconSCV9LwFyx/gv3Phw0pa25qPYjVuuGZ4Dk14bGCfGX3qKF0+ceeQvwmtI+aYBbVA==",
"cpu": [
"loong64"
],
@ -2335,9 +2336,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.16.17.tgz",
"integrity": "sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.12.tgz",
"integrity": "sha512-o8CIhfBwKcxmEENOH9RwmUejs5jFiNoDw7YgS0EJTF6kgPgcqLFjgoc5kDey5cMHRVCIWc6kK2ShUePOcc7RbA==",
"cpu": [
"mips64el"
],
@ -2351,9 +2352,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.16.17.tgz",
"integrity": "sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.12.tgz",
"integrity": "sha512-biMLH6NR/GR4z+ap0oJYb877LdBpGac8KfZoEnDiBKd7MD/xt8eaw1SFfYRUeMVx519kVkAOL2GExdFmYnZx3A==",
"cpu": [
"ppc64"
],
@ -2367,9 +2368,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.16.17.tgz",
"integrity": "sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.12.tgz",
"integrity": "sha512-jkphYUiO38wZGeWlfIBMB72auOllNA2sLfiZPGDtOBb1ELN8lmqBrlMiucgL8awBw1zBXN69PmZM6g4yTX84TA==",
"cpu": [
"riscv64"
],
@ -2383,9 +2384,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.16.17.tgz",
"integrity": "sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.12.tgz",
"integrity": "sha512-j3ucLdeY9HBcvODhCY4b+Ds3hWGO8t+SAidtmWu/ukfLLG/oYDMaA+dnugTVAg5fnUOGNbIYL9TOjhWgQB8W5g==",
"cpu": [
"s390x"
],
@ -2399,9 +2400,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.16.17.tgz",
"integrity": "sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.12.tgz",
"integrity": "sha512-uo5JL3cgaEGotaqSaJdRfFNSCUJOIliKLnDGWaVCgIKkHxwhYMm95pfMbWZ9l7GeW9kDg0tSxcy9NYdEtjwwmA==",
"cpu": [
"x64"
],
@ -2415,9 +2416,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.16.17.tgz",
"integrity": "sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.12.tgz",
"integrity": "sha512-DNdoRg8JX+gGsbqt2gPgkgb00mqOgOO27KnrWZtdABl6yWTST30aibGJ6geBq3WM2TIeW6COs5AScnC7GwtGPg==",
"cpu": [
"x64"
],
@ -2431,9 +2432,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.16.17.tgz",
"integrity": "sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.12.tgz",
"integrity": "sha512-aVsENlr7B64w8I1lhHShND5o8cW6sB9n9MUtLumFlPhG3elhNWtE7M1TFpj3m7lT3sKQUMkGFjTQBrvDDO1YWA==",
"cpu": [
"x64"
],
@ -2447,9 +2448,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.16.17.tgz",
"integrity": "sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.12.tgz",
"integrity": "sha512-qbHGVQdKSwi0JQJuZznS4SyY27tYXYF0mrgthbxXrZI3AHKuRvU+Eqbg/F0rmLDpW/jkIZBlCO1XfHUBMNJ1pg==",
"cpu": [
"x64"
],
@ -2463,9 +2464,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.16.17.tgz",
"integrity": "sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.12.tgz",
"integrity": "sha512-zsCp8Ql+96xXTVTmm6ffvoTSZSV2B/LzzkUXAY33F/76EajNw1m+jZ9zPfNJlJ3Rh4EzOszNDHsmG/fZOhtqDg==",
"cpu": [
"arm64"
],
@ -2479,9 +2480,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.16.17.tgz",
"integrity": "sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.12.tgz",
"integrity": "sha512-FfrFjR4id7wcFYOdqbDfDET3tjxCozUgbqdkOABsSFzoZGFC92UK7mg4JKRc/B3NNEf1s2WHxJ7VfTdVDPN3ng==",
"cpu": [
"ia32"
],
@ -2495,9 +2496,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.16.17.tgz",
"integrity": "sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.12.tgz",
"integrity": "sha512-JOOxw49BVZx2/5tW3FqkdjSD/5gXYeVGPDcB0lvap0gLQshkh1Nyel1QazC+wNxus3xPlsYAgqU1BUmrmCvWtw==",
"cpu": [
"x64"
],
@ -2620,6 +2621,11 @@
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@justinribeiro/lite-youtube": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@justinribeiro/lite-youtube/-/lite-youtube-1.5.0.tgz",
"integrity": "sha512-TU92RKtz9BI9PRYrVwDIUsnFadLZtqRKWl1ZOdbxb7roJDb8Dd/xURllAsLEmCg6oJNyhXlVa5RsnUc0EKd8Cw=="
},
"node_modules/@lukeed/csprng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.0.1.tgz",
@ -3705,9 +3711,9 @@
}
},
"node_modules/esbuild": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.16.17.tgz",
"integrity": "sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.12.tgz",
"integrity": "sha512-bX/zHl7Gn2CpQwcMtRogTTBf9l1nl+H6R8nUbjk+RuKqAE3+8FDulLA+pHvX7aA7Xe07Iwa+CWvy9I8Y2qqPKQ==",
"dev": true,
"hasInstallScript": true,
"bin": {
@ -3717,28 +3723,28 @@
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/android-arm": "0.16.17",
"@esbuild/android-arm64": "0.16.17",
"@esbuild/android-x64": "0.16.17",
"@esbuild/darwin-arm64": "0.16.17",
"@esbuild/darwin-x64": "0.16.17",
"@esbuild/freebsd-arm64": "0.16.17",
"@esbuild/freebsd-x64": "0.16.17",
"@esbuild/linux-arm": "0.16.17",
"@esbuild/linux-arm64": "0.16.17",
"@esbuild/linux-ia32": "0.16.17",
"@esbuild/linux-loong64": "0.16.17",
"@esbuild/linux-mips64el": "0.16.17",
"@esbuild/linux-ppc64": "0.16.17",
"@esbuild/linux-riscv64": "0.16.17",
"@esbuild/linux-s390x": "0.16.17",
"@esbuild/linux-x64": "0.16.17",
"@esbuild/netbsd-x64": "0.16.17",
"@esbuild/openbsd-x64": "0.16.17",
"@esbuild/sunos-x64": "0.16.17",
"@esbuild/win32-arm64": "0.16.17",
"@esbuild/win32-ia32": "0.16.17",
"@esbuild/win32-x64": "0.16.17"
"@esbuild/android-arm": "0.17.12",
"@esbuild/android-arm64": "0.17.12",
"@esbuild/android-x64": "0.17.12",
"@esbuild/darwin-arm64": "0.17.12",
"@esbuild/darwin-x64": "0.17.12",
"@esbuild/freebsd-arm64": "0.17.12",
"@esbuild/freebsd-x64": "0.17.12",
"@esbuild/linux-arm": "0.17.12",
"@esbuild/linux-arm64": "0.17.12",
"@esbuild/linux-ia32": "0.17.12",
"@esbuild/linux-loong64": "0.17.12",
"@esbuild/linux-mips64el": "0.17.12",
"@esbuild/linux-ppc64": "0.17.12",
"@esbuild/linux-riscv64": "0.17.12",
"@esbuild/linux-s390x": "0.17.12",
"@esbuild/linux-x64": "0.17.12",
"@esbuild/netbsd-x64": "0.17.12",
"@esbuild/openbsd-x64": "0.17.12",
"@esbuild/sunos-x64": "0.17.12",
"@esbuild/win32-arm64": "0.17.12",
"@esbuild/win32-ia32": "0.17.12",
"@esbuild/win32-x64": "0.17.12"
}
},
"node_modules/escalade": {
@ -5788,9 +5794,9 @@
}
},
"node_modules/react-hotkeys-hook": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.3.7.tgz",
"integrity": "sha512-qUcA5vl/liGWr9wLYI5/8oppHLa6nExFqOAMC6CyZhpj7C56PIzYZ76xAtJ+5lgxObgl4A4pQz8upy+nq7orSQ==",
"version": "4.3.8",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.3.8.tgz",
"integrity": "sha512-RmrIQ3M259c84MnYVEAQsmHkD6s7XUgLG0rW6S7qjt1Lh7q+SPIz5b6obVU8OJw1Utsj1mUCj6twtBPaK/ytww==",
"peerDependencies": {
"react": ">=16.8.1",
"react-dom": ">=16.8.1"
@ -5809,6 +5815,27 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-quick-pinch-zoom": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/react-quick-pinch-zoom/-/react-quick-pinch-zoom-4.6.0.tgz",
"integrity": "sha512-M3woYVzWt8Kh6FCAytBtJJ4KC/5noG98GpI8ZTxTIE2sjR1XRPjV0NpFRhFgxPQpDvD+lkMp63sxP130uhafaw==",
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0",
"tslib": ">=2.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"tslib": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.6.2.tgz",
@ -5987,9 +6014,9 @@
}
},
"node_modules/rollup": {
"version": "3.12.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.12.1.tgz",
"integrity": "sha512-t9elERrz2i4UU9z7AwISj3CQcXP39cWxgRWLdf4Tm6aKm1eYrqHIgjzXBgb67GNY1sZckTFFi0oMozh3/S++Ig==",
"version": "3.19.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.19.1.tgz",
"integrity": "sha512-lAbrdN7neYCg/8WaoWn/ckzCtz+jr70GFfYdlf50OF7387HTg+wiuiqJRFYawwSPpqfqDNYqK7smY/ks2iAudg==",
"dev": true,
"bin": {
"rollup": "dist/bin/rollup"
@ -6624,15 +6651,15 @@
}
},
"node_modules/vite": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.1.4.tgz",
"integrity": "sha512-3knk/HsbSTKEin43zHu7jTwYWv81f8kgAL99G5NWBcA1LKvtvcVAC4JjBH1arBunO9kQka+1oGbrMKOjk4ZrBg==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.2.1.tgz",
"integrity": "sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg==",
"dev": true,
"dependencies": {
"esbuild": "^0.16.14",
"esbuild": "^0.17.5",
"postcss": "^8.4.21",
"resolve": "^1.22.1",
"rollup": "^3.10.0"
"rollup": "^3.18.0"
},
"bin": {
"vite": "bin/vite.js"
@ -6766,18 +6793,6 @@
"vite": ">=2.0.0"
}
},
"node_modules/vite-plugin-html-env": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/vite-plugin-html-env/-/vite-plugin-html-env-1.2.7.tgz",
"integrity": "sha512-vdTnKtuBeB8Zp93DCbN0Qjf4odW2msVRq45r7lGKA6nwQGJFj6YemU54u3xPPkvDeZhG8DEEU64xbLwzVEBilQ==",
"dev": true,
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"vite": "*"
}
},
"node_modules/vite-plugin-pwa": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.14.4.tgz",
@ -8563,156 +8578,156 @@
"requires": {}
},
"@esbuild/android-arm": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.16.17.tgz",
"integrity": "sha512-N9x1CMXVhtWEAMS7pNNONyA14f71VPQN9Cnavj1XQh6T7bskqiLLrSca4O0Vr8Wdcga943eThxnVp3JLnBMYtw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.12.tgz",
"integrity": "sha512-E/sgkvwoIfj4aMAPL2e35VnUJspzVYl7+M1B2cqeubdBhADV4uPon0KCc8p2G+LqSJ6i8ocYPCqY3A4GGq0zkQ==",
"dev": true,
"optional": true
},
"@esbuild/android-arm64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.16.17.tgz",
"integrity": "sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.12.tgz",
"integrity": "sha512-WQ9p5oiXXYJ33F2EkE3r0FRDFVpEdcDiwNX3u7Xaibxfx6vQE0Sb8ytrfQsA5WO6kDn6mDfKLh6KrPBjvkk7xA==",
"dev": true,
"optional": true
},
"@esbuild/android-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.16.17.tgz",
"integrity": "sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.12.tgz",
"integrity": "sha512-m4OsaCr5gT+se25rFPHKQXARMyAehHTQAz4XX1Vk3d27VtqiX0ALMBPoXZsGaB6JYryCLfgGwUslMqTfqeLU0w==",
"dev": true,
"optional": true
},
"@esbuild/darwin-arm64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.16.17.tgz",
"integrity": "sha512-/2agbUEfmxWHi9ARTX6OQ/KgXnOWfsNlTeLcoV7HSuSTv63E4DqtAc+2XqGw1KHxKMHGZgbVCZge7HXWX9Vn+w==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.12.tgz",
"integrity": "sha512-O3GCZghRIx+RAN0NDPhyyhRgwa19MoKlzGonIb5hgTj78krqp9XZbYCvFr9N1eUxg0ZQEpiiZ4QvsOQwBpP+lg==",
"dev": true,
"optional": true
},
"@esbuild/darwin-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.16.17.tgz",
"integrity": "sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.12.tgz",
"integrity": "sha512-5D48jM3tW27h1qjaD9UNRuN+4v0zvksqZSPZqeSWggfMlsVdAhH3pwSfQIFJwcs9QJ9BRibPS4ViZgs3d2wsCA==",
"dev": true,
"optional": true
},
"@esbuild/freebsd-arm64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.16.17.tgz",
"integrity": "sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.12.tgz",
"integrity": "sha512-OWvHzmLNTdF1erSvrfoEBGlN94IE6vCEaGEkEH29uo/VoONqPnoDFfShi41Ew+yKimx4vrmmAJEGNoyyP+OgOQ==",
"dev": true,
"optional": true
},
"@esbuild/freebsd-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.16.17.tgz",
"integrity": "sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.12.tgz",
"integrity": "sha512-A0Xg5CZv8MU9xh4a+7NUpi5VHBKh1RaGJKqjxe4KG87X+mTjDE6ZvlJqpWoeJxgfXHT7IMP9tDFu7IZ03OtJAw==",
"dev": true,
"optional": true
},
"@esbuild/linux-arm": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.16.17.tgz",
"integrity": "sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.12.tgz",
"integrity": "sha512-WsHyJ7b7vzHdJ1fv67Yf++2dz3D726oO3QCu8iNYik4fb5YuuReOI9OtA+n7Mk0xyQivNTPbl181s+5oZ38gyA==",
"dev": true,
"optional": true
},
"@esbuild/linux-arm64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.16.17.tgz",
"integrity": "sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.12.tgz",
"integrity": "sha512-cK3AjkEc+8v8YG02hYLQIQlOznW+v9N+OI9BAFuyqkfQFR+DnDLhEM5N8QRxAUz99cJTo1rLNXqRrvY15gbQUg==",
"dev": true,
"optional": true
},
"@esbuild/linux-ia32": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.16.17.tgz",
"integrity": "sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.12.tgz",
"integrity": "sha512-jdOBXJqcgHlah/nYHnj3Hrnl9l63RjtQ4vn9+bohjQPI2QafASB5MtHAoEv0JQHVb/xYQTFOeuHnNYE1zF7tYw==",
"dev": true,
"optional": true
},
"@esbuild/linux-loong64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.16.17.tgz",
"integrity": "sha512-dTzNnQwembNDhd654cA4QhbS9uDdXC3TKqMJjgOWsC0yNCbpzfWoXdZvp0mY7HU6nzk5E0zpRGGx3qoQg8T2DQ==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.12.tgz",
"integrity": "sha512-GTOEtj8h9qPKXCyiBBnHconSCV9LwFyx/gv3Phw0pa25qPYjVuuGZ4Dk14bGCfGX3qKF0+ceeQvwmtI+aYBbVA==",
"dev": true,
"optional": true
},
"@esbuild/linux-mips64el": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.16.17.tgz",
"integrity": "sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.12.tgz",
"integrity": "sha512-o8CIhfBwKcxmEENOH9RwmUejs5jFiNoDw7YgS0EJTF6kgPgcqLFjgoc5kDey5cMHRVCIWc6kK2ShUePOcc7RbA==",
"dev": true,
"optional": true
},
"@esbuild/linux-ppc64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.16.17.tgz",
"integrity": "sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.12.tgz",
"integrity": "sha512-biMLH6NR/GR4z+ap0oJYb877LdBpGac8KfZoEnDiBKd7MD/xt8eaw1SFfYRUeMVx519kVkAOL2GExdFmYnZx3A==",
"dev": true,
"optional": true
},
"@esbuild/linux-riscv64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.16.17.tgz",
"integrity": "sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.12.tgz",
"integrity": "sha512-jkphYUiO38wZGeWlfIBMB72auOllNA2sLfiZPGDtOBb1ELN8lmqBrlMiucgL8awBw1zBXN69PmZM6g4yTX84TA==",
"dev": true,
"optional": true
},
"@esbuild/linux-s390x": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.16.17.tgz",
"integrity": "sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.12.tgz",
"integrity": "sha512-j3ucLdeY9HBcvODhCY4b+Ds3hWGO8t+SAidtmWu/ukfLLG/oYDMaA+dnugTVAg5fnUOGNbIYL9TOjhWgQB8W5g==",
"dev": true,
"optional": true
},
"@esbuild/linux-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.16.17.tgz",
"integrity": "sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.12.tgz",
"integrity": "sha512-uo5JL3cgaEGotaqSaJdRfFNSCUJOIliKLnDGWaVCgIKkHxwhYMm95pfMbWZ9l7GeW9kDg0tSxcy9NYdEtjwwmA==",
"dev": true,
"optional": true
},
"@esbuild/netbsd-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.16.17.tgz",
"integrity": "sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.12.tgz",
"integrity": "sha512-DNdoRg8JX+gGsbqt2gPgkgb00mqOgOO27KnrWZtdABl6yWTST30aibGJ6geBq3WM2TIeW6COs5AScnC7GwtGPg==",
"dev": true,
"optional": true
},
"@esbuild/openbsd-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.16.17.tgz",
"integrity": "sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.12.tgz",
"integrity": "sha512-aVsENlr7B64w8I1lhHShND5o8cW6sB9n9MUtLumFlPhG3elhNWtE7M1TFpj3m7lT3sKQUMkGFjTQBrvDDO1YWA==",
"dev": true,
"optional": true
},
"@esbuild/sunos-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.16.17.tgz",
"integrity": "sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.12.tgz",
"integrity": "sha512-qbHGVQdKSwi0JQJuZznS4SyY27tYXYF0mrgthbxXrZI3AHKuRvU+Eqbg/F0rmLDpW/jkIZBlCO1XfHUBMNJ1pg==",
"dev": true,
"optional": true
},
"@esbuild/win32-arm64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.16.17.tgz",
"integrity": "sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.12.tgz",
"integrity": "sha512-zsCp8Ql+96xXTVTmm6ffvoTSZSV2B/LzzkUXAY33F/76EajNw1m+jZ9zPfNJlJ3Rh4EzOszNDHsmG/fZOhtqDg==",
"dev": true,
"optional": true
},
"@esbuild/win32-ia32": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.16.17.tgz",
"integrity": "sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.12.tgz",
"integrity": "sha512-FfrFjR4id7wcFYOdqbDfDET3tjxCozUgbqdkOABsSFzoZGFC92UK7mg4JKRc/B3NNEf1s2WHxJ7VfTdVDPN3ng==",
"dev": true,
"optional": true
},
"@esbuild/win32-x64": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.16.17.tgz",
"integrity": "sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.12.tgz",
"integrity": "sha512-JOOxw49BVZx2/5tW3FqkdjSD/5gXYeVGPDcB0lvap0gLQshkh1Nyel1QazC+wNxus3xPlsYAgqU1BUmrmCvWtw==",
"dev": true,
"optional": true
},
@ -8816,6 +8831,11 @@
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"@justinribeiro/lite-youtube": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@justinribeiro/lite-youtube/-/lite-youtube-1.5.0.tgz",
"integrity": "sha512-TU92RKtz9BI9PRYrVwDIUsnFadLZtqRKWl1ZOdbxb7roJDb8Dd/xURllAsLEmCg6oJNyhXlVa5RsnUc0EKd8Cw=="
},
"@lukeed/csprng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.0.1.tgz",
@ -9635,33 +9655,33 @@
}
},
"esbuild": {
"version": "0.16.17",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.16.17.tgz",
"integrity": "sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg==",
"version": "0.17.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.12.tgz",
"integrity": "sha512-bX/zHl7Gn2CpQwcMtRogTTBf9l1nl+H6R8nUbjk+RuKqAE3+8FDulLA+pHvX7aA7Xe07Iwa+CWvy9I8Y2qqPKQ==",
"dev": true,
"requires": {
"@esbuild/android-arm": "0.16.17",
"@esbuild/android-arm64": "0.16.17",
"@esbuild/android-x64": "0.16.17",
"@esbuild/darwin-arm64": "0.16.17",
"@esbuild/darwin-x64": "0.16.17",
"@esbuild/freebsd-arm64": "0.16.17",
"@esbuild/freebsd-x64": "0.16.17",
"@esbuild/linux-arm": "0.16.17",
"@esbuild/linux-arm64": "0.16.17",
"@esbuild/linux-ia32": "0.16.17",
"@esbuild/linux-loong64": "0.16.17",
"@esbuild/linux-mips64el": "0.16.17",
"@esbuild/linux-ppc64": "0.16.17",
"@esbuild/linux-riscv64": "0.16.17",
"@esbuild/linux-s390x": "0.16.17",
"@esbuild/linux-x64": "0.16.17",
"@esbuild/netbsd-x64": "0.16.17",
"@esbuild/openbsd-x64": "0.16.17",
"@esbuild/sunos-x64": "0.16.17",
"@esbuild/win32-arm64": "0.16.17",
"@esbuild/win32-ia32": "0.16.17",
"@esbuild/win32-x64": "0.16.17"
"@esbuild/android-arm": "0.17.12",
"@esbuild/android-arm64": "0.17.12",
"@esbuild/android-x64": "0.17.12",
"@esbuild/darwin-arm64": "0.17.12",
"@esbuild/darwin-x64": "0.17.12",
"@esbuild/freebsd-arm64": "0.17.12",
"@esbuild/freebsd-x64": "0.17.12",
"@esbuild/linux-arm": "0.17.12",
"@esbuild/linux-arm64": "0.17.12",
"@esbuild/linux-ia32": "0.17.12",
"@esbuild/linux-loong64": "0.17.12",
"@esbuild/linux-mips64el": "0.17.12",
"@esbuild/linux-ppc64": "0.17.12",
"@esbuild/linux-riscv64": "0.17.12",
"@esbuild/linux-s390x": "0.17.12",
"@esbuild/linux-x64": "0.17.12",
"@esbuild/netbsd-x64": "0.17.12",
"@esbuild/openbsd-x64": "0.17.12",
"@esbuild/sunos-x64": "0.17.12",
"@esbuild/win32-arm64": "0.17.12",
"@esbuild/win32-ia32": "0.17.12",
"@esbuild/win32-x64": "0.17.12"
}
},
"escalade": {
@ -11058,9 +11078,9 @@
}
},
"react-hotkeys-hook": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.3.7.tgz",
"integrity": "sha512-qUcA5vl/liGWr9wLYI5/8oppHLa6nExFqOAMC6CyZhpj7C56PIzYZ76xAtJ+5lgxObgl4A4pQz8upy+nq7orSQ==",
"version": "4.3.8",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.3.8.tgz",
"integrity": "sha512-RmrIQ3M259c84MnYVEAQsmHkD6s7XUgLG0rW6S7qjt1Lh7q+SPIz5b6obVU8OJw1Utsj1mUCj6twtBPaK/ytww==",
"requires": {}
},
"react-intersection-observer": {
@ -11074,6 +11094,12 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"react-quick-pinch-zoom": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/react-quick-pinch-zoom/-/react-quick-pinch-zoom-4.6.0.tgz",
"integrity": "sha512-M3woYVzWt8Kh6FCAytBtJJ4KC/5noG98GpI8ZTxTIE2sjR1XRPjV0NpFRhFgxPQpDvD+lkMp63sxP130uhafaw==",
"requires": {}
},
"react-router": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.6.2.tgz",
@ -11204,9 +11230,9 @@
"dev": true
},
"rollup": {
"version": "3.12.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.12.1.tgz",
"integrity": "sha512-t9elERrz2i4UU9z7AwISj3CQcXP39cWxgRWLdf4Tm6aKm1eYrqHIgjzXBgb67GNY1sZckTFFi0oMozh3/S++Ig==",
"version": "3.19.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.19.1.tgz",
"integrity": "sha512-lAbrdN7neYCg/8WaoWn/ckzCtz+jr70GFfYdlf50OF7387HTg+wiuiqJRFYawwSPpqfqDNYqK7smY/ks2iAudg==",
"dev": true,
"requires": {
"fsevents": "~2.3.2"
@ -11658,16 +11684,16 @@
}
},
"vite": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.1.4.tgz",
"integrity": "sha512-3knk/HsbSTKEin43zHu7jTwYWv81f8kgAL99G5NWBcA1LKvtvcVAC4JjBH1arBunO9kQka+1oGbrMKOjk4ZrBg==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.2.1.tgz",
"integrity": "sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg==",
"dev": true,
"requires": {
"esbuild": "^0.16.14",
"esbuild": "^0.17.5",
"fsevents": "~2.3.2",
"postcss": "^8.4.21",
"resolve": "^1.22.1",
"rollup": "^3.10.0"
"rollup": "^3.18.0"
}
},
"vite-plugin-generate-file": {
@ -11740,13 +11766,6 @@
"dev": true,
"requires": {}
},
"vite-plugin-html-env": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/vite-plugin-html-env/-/vite-plugin-html-env-1.2.7.tgz",
"integrity": "sha512-vdTnKtuBeB8Zp93DCbN0Qjf4odW2msVRq45r7lGKA6nwQGJFj6YemU54u3xPPkvDeZhG8DEEU64xbLwzVEBilQ==",
"dev": true,
"requires": {}
},
"vite-plugin-pwa": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.14.4.tgz",

View file

@ -13,6 +13,7 @@
"@formatjs/intl-localematcher": "~0.2.32",
"@github/text-expander-element": "~2.3.0",
"@iconify-icons/mingcute": "~1.2.4",
"@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~3.5.2",
"dayjs": "~1.11.7",
"dayjs-twitter": "~0.5.0",
@ -25,8 +26,9 @@
"p-retry": "~5.1.2",
"p-throttle": "~5.0.0",
"preact": "~10.13.1",
"react-hotkeys-hook": "~4.3.7",
"react-hotkeys-hook": "~4.3.8",
"react-intersection-observer": "~9.4.3",
"react-quick-pinch-zoom": "~4.6.0",
"react-router-dom": "6.6.2",
"string-length": "~5.0.1",
"swiped-events": "~1.1.7",
@ -44,10 +46,9 @@
"postcss-dark-theme-class": "~0.7.3",
"postcss-preset-env": "~8.0.1",
"twitter-text": "~3.1.0",
"vite": "~4.1.4",
"vite": "~4.2.1",
"vite-plugin-generate-file": "~0.0.4",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-html-env": "~1.2.7",
"vite-plugin-pwa": "~0.14.4",
"vite-plugin-remove-console": "~2.1.0",
"workbox-cacheable-response": "~6.5.4",

View file

@ -204,6 +204,9 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.timeline.contextual > li:last-child {
background-size: 100% 20px;
}
.timeline.contextual > li:only-child {
background-image: none;
}
.timeline.contextual > li.descendant {
position: relative;
}
@ -213,21 +216,40 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.timeline.contextual > li.descendant:not(.thread) > .status-link {
padding-left: 40px;
}
.timeline.contextual .replies[data-comments-level='4'] {
overflow: auto;
}
.timeline.contextual .replies[data-comments-level='4']:has(.replies) {
overflow: auto;
mask-image: linear-gradient(to left, transparent, black 32px);
}
.timeline.contextual
> li.descendant.thread
> .status-link
+ .replies
> summary {
margin-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
);
.replies[data-comments-level='4']:has(.replies)
> .replies-summary {
border-top: 2px dashed var(--divider-color);
}
.timeline.contextual
.replies[data-comments-level='4']
.replies[data-comments-level-overflow='true']
.status {
min-width: min(15em, 75vw);
}
.timeline.contextual
> li.descendant.thread
> .status-link
+ .replies
.replies-summary {
margin-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
(var(--line-margin-end) * (var(--comments-level) - 1))
);
}
/* .timeline.contextual
> li.descendant.thread
> .status-link
+ .replies
.replies
> summary {
> .replies-summary {
margin-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
var(--line-margin-end)
@ -239,22 +261,23 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
+ .replies
.replies
.replies
> summary {
> .replies-summary {
margin-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
(var(--line-margin-end) * 2)
);
}
} */
.timeline.contextual
> li.descendant.thread
> .status-link
+ .replies
.status-link {
padding-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
(var(--line-margin-end) * (var(--comments-level) - 1))
);
}
.timeline.contextual
/* .timeline.contextual
> li.descendant.thread
> .status-link
+ .replies
@ -276,20 +299,22 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
(var(--line-margin-end) * 2)
);
}
} */
.timeline.contextual
> li.descendant:not(.thread)
> .status-link
+ .replies
> summary {
margin-left: calc(var(--thread-start) + var(--line-margin-end));
.replies-summary {
margin-left: calc(
var(--thread-start) + var(--line-margin-end) * var(--comments-level)
);
}
.timeline.contextual
/* .timeline.contextual
> li.descendant:not(.thread)
> .status-link
+ .replies
.replies
> summary {
> .replies-summary {
margin-left: calc(
var(--thread-start) + var(--line-margin-end) + var(--line-margin-end)
);
@ -300,19 +325,21 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
+ .replies
.replies
.replies
> summary {
> .replies-summary {
margin-left: calc(
var(--thread-start) + var(--line-margin-end) + (var(--line-margin-end) * 2)
);
}
} */
.timeline.contextual
> li.descendant:not(.thread)
> .status-link
+ .replies
.status-link {
padding-left: calc(var(--thread-start) + var(--line-margin-end));
padding-left: calc(
var(--thread-start) + var(--line-margin-end) * var(--comments-level)
);
}
.timeline.contextual
/* .timeline.contextual
> li.descendant:not(.thread)
> .status-link
+ .replies
@ -328,7 +355,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.replies
.status-link {
padding-left: calc(var(--thread-start) + (var(--line-margin-end) * 3));
}
} */
.timeline.contextual > li.descendant:not(.thread):before {
content: '';
position: absolute;
@ -365,7 +392,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
padding: 0;
list-style: none;
}
.timeline.contextual > li .replies > summary {
.timeline.contextual > li .replies > .replies-summary {
padding: 8px;
background-color: var(--bg-faded-color);
display: inline-block;
@ -381,17 +408,17 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
list-style: none;
white-space: nowrap;
}
.timeline.contextual > li .replies > summary::-webkit-details-marker {
.timeline.contextual > li .replies > .replies-summary::-webkit-details-marker {
display: none;
}
.timeline.contextual > li .replies > summary > * {
.timeline.contextual > li .replies > .replies-summary > * {
vertical-align: middle;
}
.timeline.contextual > li .replies > summary .avatars {
.timeline.contextual > li .replies > .replies-summary .avatars {
margin-right: 8px;
}
.timeline.contextual > li .replies > summary:active,
.timeline.contextual > li .replies[open] > summary {
.timeline.contextual > li .replies > .replies-summary:active,
.timeline.contextual > li .replies[open] > .replies-summary {
color: var(--text-color);
background-color: var(--comment-line-color);
background-image: linear-gradient(
@ -400,17 +427,19 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
var(--bg-faded-color)
);
}
.timeline.contextual > li .replies[open] > summary {
.timeline.contextual > li .replies[open] > .replies-summary {
border-bottom-left-radius: 0;
}
.timeline.contextual > li .replies summary[hidden] {
.timeline.contextual > li .replies .replies-summary[hidden] {
display: none;
}
.timeline.contextual > li .replies li {
position: relative;
}
.timeline.contextual > li .replies li {
--line-start: calc(var(--thread-start) + var(--line-margin-end));
--line-start: calc(
var(--thread-start) + var(--line-margin-end) * var(--comments-level)
);
--line-end: calc(var(--line-start) + var(--line-width));
background-image: linear-gradient(
to right,
@ -423,18 +452,19 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
);
background-repeat: no-repeat;
}
.timeline.contextual > li .replies .replies li {
/* .timeline.contextual > li .replies .replies li {
--line-start: calc(var(--thread-start) + (var(--line-margin-end) * 2));
}
.timeline.contextual > li .replies .replies .replies li {
--line-start: calc(var(--thread-start) + (var(--line-margin-end) * 3));
}
} */
.timeline.contextual > li.thread .replies li {
--line-start: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
(var(--line-margin-end) * (var(--comments-level) - 1))
);
}
.timeline.contextual > li.thread .replies .replies li {
/* .timeline.contextual > li.thread .replies .replies li {
--line-start: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
var(--line-margin-end)
@ -445,7 +475,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
(var(--line-margin-end) * 2)
);
}
} */
.timeline.contextual > li .replies li:last-child {
background-size: 100% 20px;
}
@ -462,7 +492,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
border-color: transparent transparent var(--comment-line-color) transparent;
transform: rotate(45deg);
}
.timeline.contextual > li .replies .replies li:before {
/* .timeline.contextual > li .replies .replies li:before {
--line-start: calc(
var(--thread-start) + var(--line-margin-end) + var(--line-margin-end)
);
@ -471,13 +501,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
--line-start: calc(
var(--thread-start) + var(--line-margin-end) + (var(--line-margin-end) * 2)
);
}
} */
.timeline.contextual > li.thread .replies li:before {
--line-start: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
(var(--line-margin-end) * (var(--comments-level) - 1))
);
}
.timeline.contextual > li.thread .replies .replies li:before {
/* .timeline.contextual > li.thread .replies .replies li:before {
--line-start: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
var(--line-margin-end)
@ -488,7 +519,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
(var(--line-margin-end) * 2)
);
}
} */
.timeline.contextual.loading > li:not(.hero) {
/* opacity: 0.5; */
pointer-events: none;
@ -518,6 +549,48 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
margin-bottom: 3em;
}
.timeline:not(.flat) > li.timeline-item-container {
--line-start: 40px;
--line-width: 3px;
--line-end: calc(var(--line-start) + var(--line-width));
background-image: linear-gradient(
to right,
transparent,
transparent var(--line-start),
var(--comment-line-color) var(--line-start),
var(--comment-line-color) var(--line-end),
transparent var(--line-end),
transparent
);
background-repeat: no-repeat;
}
.timeline:not(.flat) > li.timeline-item-container-start {
margin-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom: 0;
background-position: 0 16px;
}
.timeline:not(.flat) > li.timeline-item-container-middle {
margin-top: 0;
margin-bottom: 0;
border-radius: 0;
border-bottom: 0;
border-top: 0;
}
.timeline:not(.flat) > li.timeline-item-container-end {
margin-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
background-size: 100% 20px;
}
.timeline:not(.flat)
> li:is(.timeline-item-container-middle, .timeline-item-container-end)
.status-reply-to:not(.visibility-direct) {
background-image: none;
}
.status-loading {
text-align: center;
color: var(--text-insignificant-color);
@ -615,6 +688,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
counter-increment: index;
position: relative;
}
@media (hover: hover) or (pointer: fine) {
.status-carousel ul {
scroll-snap-type: none;
}
}
.status-carousel .content-container .content:only-child {
font-size: calc(100% + 25% * max(2 - var(--content-text-weight), 0));
}
@ -784,7 +862,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.carousel::-webkit-scrollbar {
display: none;
}
.carousel > * {
.carousel .carousel-item {
scroll-snap-align: center;
scroll-snap-stop: always;
flex-shrink: 0;
@ -802,7 +880,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
transparent 100%
);
}
.carousel > * :is(img, video) {
.carousel .carousel-item :is(img, video) {
width: auto;
max-width: 100%;
height: auto;
@ -810,10 +888,24 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
max-height: 100dvh;
vertical-align: middle;
}
.carousel > * video {
.carousel .carousel-item video {
min-height: 80px;
max-height: 80vh; /* prevent other UI elements from obscuring video */
}
.carousel .carousel-item .media {
background-size: contain;
background-repeat: no-repeat;
background-position: center;
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0)
env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0);
background-origin: content-box;
}
.carousel-top-controls {
top: 0;
@ -841,6 +933,9 @@ button.carousel-dot {
pointer-events: auto;
font-weight: bold;
}
.carousel-top-controls .szh-menu-container {
pointer-events: auto;
}
:is(.button, button).carousel-button[hidden] {
display: inline-block;
opacity: 0;
@ -1074,8 +1169,10 @@ body:has(.status-deck) .media-post-link {
max-width: 90vw;
overflow: hidden;
}
.szh-menu__item--focusable {
background-color: transparent;
.szh-menu[aria-label='Submenu'] {
background-color: var(--bg-blur-color);
backdrop-filter: blur(4px);
box-shadow: 0 3px 24px -3px var(--drop-shadow-color);
}
.szh-menu__header {
margin: -8px 0 8px;
@ -1088,6 +1185,10 @@ body:has(.status-deck) .media-post-link {
line-height: 1.2;
/* border-bottom: 1px solid var(--outline-color); */
}
.szh-menu__header.plain {
margin-bottom: 0;
background-color: transparent;
}
.szh-menu__header * {
vertical-align: middle;
}
@ -1095,7 +1196,7 @@ body:has(.status-deck) .media-post-link {
display: flex;
gap: 8px;
align-items: center;
line-height: 1;
line-height: 1.1;
padding: 8px 16px !important;
transition: all 0.1s ease-in-out;
text-decoration: none;
@ -1103,6 +1204,9 @@ body:has(.status-deck) .media-post-link {
overflow: hidden;
text-overflow: ellipsis;
}
.szh-menu .szh-menu__item--focusable {
background-color: transparent;
}
.szh-menu .szh-menu__item span {
white-space: nowrap;
overflow: hidden;
@ -1147,11 +1251,11 @@ body:has(.status-deck) .media-post-link {
overflow: hidden;
}
.szh-menu .menu-double-lines {
white-space: normal;
white-space: normal !important;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
overflow: hidden !important;
}
.szh-menu .menu-double-lines span {
white-space: normal;
@ -1183,6 +1287,15 @@ body:has(.status-deck) .media-post-link {
opacity: 1;
}
.szh-menu .menu-wrap {
display: flex;
flex-wrap: wrap;
}
.szh-menu .menu-wrap > * {
flex-grow: 1;
flex-basis: 50%;
}
/* GLASS MENU */
.glass-menu {
@ -1251,6 +1364,10 @@ meter.donut:is(.warning, .danger, .explode):after {
meter.donut:is(.danger, .explode):after {
color: var(--red-color);
}
meter.donut[hidden] {
display: inline-block;
visibility: hidden;
}
/* SHINY PILL */
@ -1416,6 +1533,31 @@ ul.link-list li a .icon {
}
}
/* NAV MENU BUTTON */
.nav-menu-button.with-avatar {
position: relative;
}
.nav-menu-button:is(:hover, :focus):not(:active) {
filter: none !important;
}
.nav-menu-button .avatar {
transition: box-shadow 0.3s ease-out;
}
.nav-menu-button:is(:hover, :focus, .active) .avatar {
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-light-color);
}
.nav-menu-button.with-avatar .icon {
position: absolute;
bottom: 4px;
right: 8px;
background-color: var(--bg-color);
border-radius: 2px;
}
.nav-menu-button.with-avatar:hover:not(:active, .active) .icon {
transform: translateY(-1px);
}
/* COLUMNS */
#columns {
@ -1562,17 +1704,21 @@ ul.link-list li a .icon {
--back-transition: transform 0.4s ease-out;
}
.timeline:not(.flat) > li > a {
border-radius: var(--item-radius);
border-radius: inherit;
}
.timeline:not(.flat) > li:not(:has(.status-carousel)) {
transform: translate3d(0, 0, 0);
}
.timeline:not(.flat) > li:has(.status-badge) {
border-top-right-radius: 8px;
}
.timeline:not(.flat) > li:has(.status-link.is-active) {
transition: var(--back-transition);
transform: translate3d(-2.5vw, 0, 0);
}
.timeline:not(.flat)
> li:not(:has(.status-carousel)):has(+ li .status-link.is-active),
.timeline:not(.flat) > li.timeline-item-container:has(.status-link.is-active),
.timeline:not(.flat)
> li:not(:has(.status-carousel)):has(.status-link.is-active)
+ li {

View file

@ -33,7 +33,6 @@ import FollowedHashtags from './pages/followed-hashtags';
import Following from './pages/following';
import Hashtag from './pages/hashtag';
import Home from './pages/home';
import HomeV1 from './pages/home-v1';
import List from './pages/list';
import Lists from './pages/lists';
import Login from './pages/login';
@ -140,10 +139,6 @@ function App() {
let location = useLocation();
states.currentLocation = location.pathname;
const locationDeckMap = {
'/': 'home-page',
'/notifications': 'notifications-page',
};
const focusDeck = () => {
let timer = setTimeout(() => {
const columns = document.getElementById('columns');
@ -161,11 +156,6 @@ function App() {
page.focus();
}
}
// const page = document.getElementById(locationDeckMap[location.pathname]);
// console.debug('FOCUS', location.pathname, page);
// if (page) {
// page.focus();
// }
}, 100);
return () => clearTimeout(timer);
};
@ -182,59 +172,6 @@ function App() {
if (!showModal) focusDeck();
}, [showModal]);
// Notifications service
// - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true);
usePageVisibility(setVisible);
const notificationStream = useRef();
useEffect(() => {
if (isLoggedIn && visible) {
const { masto, instance } = api();
(async () => {
// 1. Get the latest notification
if (states.notificationsLast) {
const notificationsIterator = masto.v1.notifications.list({
limit: 1,
since_id: states.notificationsLast.id,
});
const { value: notifications } = await notificationsIterator.next();
if (notifications?.length) {
states.notificationsShowNew = true;
}
}
// 2. Start streaming
notificationStream.current = await masto.ws.stream(
'/api/v1/streaming',
{
stream: 'user:notification',
},
);
console.log('🎏 Streaming notification', notificationStream.current);
notificationStream.current.on('notification', (notification) => {
console.log('🔔🔔 Notification', notification);
if (notification.status) {
saveStatus(notification.status, instance, {
skipThreading: true,
});
}
states.notificationsShowNew = true;
});
notificationStream.current.ws.onclose = () => {
console.log('🔔🔔 Notification stream closed');
};
})();
}
return () => {
if (notificationStream.current) {
notificationStream.current.ws.close();
notificationStream.current = null;
}
};
}, [visible, isLoggedIn]);
const { prevLocation } = snapStates;
const backgroundLocation = useRef(prevLocation || null);
const isModalPage =
@ -255,34 +192,6 @@ function App() {
return !/^\/(login|welcome)/.test(pathname);
}, [location]);
const lastCheckDate = useRef();
const checkForUpdates = () => {
lastCheckDate.current = Date.now();
console.log('✨ Check app update');
fetch('./version.json')
.then((r) => r.json())
.then((info) => {
if (info) states.appVersion = info;
})
.catch((e) => {
console.error(e);
});
};
useInterval(() => checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes
usePageVisibility((visible) => {
if (visible) {
if (!lastCheckDate.current) {
checkForUpdates();
} else {
const diff = Date.now() - lastCheckDate.current;
if (diff > 1000 * 60 * 60) {
// 1 hour
checkForUpdates();
}
}
}
});
return (
<>
<Routes location={nonRootLocation || location}>
@ -306,7 +215,6 @@ function App() {
<Route path="/notifications" element={<Notifications />} />
)}
{isLoggedIn && <Route path="/following" element={<Following />} />}
{isLoggedIn && <Route path="/homev1" element={<HomeV1 />} />}
{isLoggedIn && <Route path="/b" element={<Bookmarks />} />}
{isLoggedIn && <Route path="/f" element={<Favourites />} />}
{isLoggedIn && (
@ -472,8 +380,95 @@ function App() {
<ShortcutsSettings />
</Modal>
)}
<BackgroundService isLoggedIn={isLoggedIn} />
</>
);
}
function BackgroundService({ isLoggedIn }) {
// Notifications service
// - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true);
usePageVisibility(setVisible);
const notificationStream = useRef();
useEffect(() => {
if (isLoggedIn && visible) {
const { masto, instance } = api();
(async () => {
// 1. Get the latest notification
if (states.notificationsLast) {
const notificationsIterator = masto.v1.notifications.list({
limit: 1,
since_id: states.notificationsLast.id,
});
const { value: notifications } = await notificationsIterator.next();
if (notifications?.length) {
states.notificationsShowNew = true;
}
}
// 2. Start streaming
notificationStream.current = await masto.ws.stream(
'/api/v1/streaming',
{
stream: 'user:notification',
},
);
console.log('🎏 Streaming notification', notificationStream.current);
notificationStream.current.on('notification', (notification) => {
console.log('🔔🔔 Notification', notification);
if (notification.status) {
saveStatus(notification.status, instance, {
skipThreading: true,
});
}
states.notificationsShowNew = true;
});
notificationStream.current.ws.onclose = () => {
console.log('🔔🔔 Notification stream closed');
};
})();
}
return () => {
if (notificationStream.current) {
notificationStream.current.ws.close();
notificationStream.current = null;
}
};
}, [visible, isLoggedIn]);
// Check for updates service
const lastCheckDate = useRef();
const checkForUpdates = () => {
lastCheckDate.current = Date.now();
console.log('✨ Check app update');
fetch('./version.json')
.then((r) => r.json())
.then((info) => {
if (info) states.appVersion = info;
})
.catch((e) => {
console.error(e);
});
};
useInterval(() => checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes
usePageVisibility((visible) => {
if (visible) {
if (!lastCheckDate.current) {
checkForUpdates();
} else {
const diff = Date.now() - lastCheckDate.current;
if (diff > 1000 * 60 * 60) {
// 1 hour
checkForUpdates();
}
}
}
});
return null;
}
export { App };

View file

@ -153,10 +153,14 @@
gap: 8px;
justify-content: space-between;
min-height: 2.5em;
align-items: center;
}
.account-container .actions button {
align-self: flex-end;
}
.account-container .actions .buttons {
display: flex;
}
.account-container .profile-metadata {
display: flex;

View file

@ -1,13 +1,22 @@
import './account-info.css';
import {
Menu,
MenuDivider,
MenuHeader,
MenuItem,
SubMenu,
} from '@szhsin/react-menu';
import { useEffect, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
import enhanceContent from '../utils/enhance-content';
import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links';
import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { hideAllModals } from '../utils/states';
import store from '../utils/store';
@ -15,6 +24,29 @@ import AccountBlock from './account-block';
import Avatar from './avatar';
import Icon from './icon';
import Link from './link';
import Modal from './modal';
import TranslationBlock from './translation-block';
const MUTE_DURATIONS = [
1000 * 60 * 5, // 5 minutes
1000 * 60 * 30, // 30 minutes
1000 * 60 * 60, // 1 hour
1000 * 60 * 60 * 6, // 6 hours
1000 * 60 * 60 * 24, // 1 day
1000 * 60 * 60 * 24 * 3, // 3 days
1000 * 60 * 60 * 24 * 7, // 1 week
0, // forever
];
const MUTE_DURATIONS_LABELS = {
0: 'Forever',
300_000: '5 minutes',
1_800_000: '30 minutes',
3_600_000: '1 hour',
21_600_000: '6 hours',
86_400_000: '1 day',
259_200_000: '3 days',
604_800_000: '1 week',
};
function AccountInfo({
account,
@ -359,7 +391,7 @@ function RelatedActions({ info, instance, authenticated }) {
const [relationship, setRelationship] = useState(null);
const [familiarFollowers, setFamiliarFollowers] = useState([]);
const { id, locked } = info;
const { id, acct, url, username, locked, lastStatusAt, note, fields } = info;
const accountID = useRef(id);
const {
@ -376,6 +408,9 @@ function RelatedActions({ info, instance, authenticated }) {
endorsed,
} = relationship || {};
const [currentInfo, setCurrentInfo] = useState(null);
const [isSelf, setIsSelf] = useState(false);
useEffect(() => {
if (info) {
const currentAccount = store.session.get('currentAccount');
@ -394,7 +429,10 @@ function RelatedActions({ info, instance, authenticated }) {
resolve: true,
});
console.log('🥏 Fetched account from logged-in instance', results);
currentID = results.accounts[0].id;
if (results.accounts.length) {
currentID = results.accounts[0].id;
setCurrentInfo(results.accounts[0]);
}
} catch (e) {
console.error(e);
}
@ -404,6 +442,7 @@ function RelatedActions({ info, instance, authenticated }) {
if (currentAccount === currentID) {
// It's myself!
setIsSelf(true);
return;
}
@ -444,6 +483,11 @@ function RelatedActions({ info, instance, authenticated }) {
}
}, [info, authenticated]);
const loading = relationshipUIState === 'loading';
const menuInstanceRef = useRef(null);
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
return (
<>
{familiarFollowers?.length > 0 && (
@ -473,67 +517,329 @@ function RelatedActions({ info, instance, authenticated }) {
</p>
)}
<p class="actions">
{followedBy ? <span class="tag">Following you</span> : <span />}{' '}
{relationshipUIState !== 'loading' && relationship && (
<button
type="button"
class={`${following || requested ? 'light swap' : ''}`}
data-swap-state={following || requested ? 'danger' : ''}
disabled={relationshipUIState === 'loading'}
onClick={() => {
setRelationshipUIState('loading');
{followedBy ? (
<span class="tag">Following you</span>
) : !!lastStatusAt ? (
<span class="insignificant">
Last status:{' '}
{niceDateTime(lastStatusAt, {
hideTime: true,
})}
</span>
) : (
<span />
)}{' '}
<span class="buttons">
<Menu
instanceRef={menuInstanceRef}
portal={{
target: document.body,
}}
containerProps={{
style: {
// Higher than the backdrop
zIndex: 1001,
},
onClick: (e) => {
if (e.target === e.currentTarget) {
menuInstanceRef.current?.closeMenu?.();
}
},
}}
align="center"
position="anchor"
overflow="auto"
boundingBoxPadding="8 8 8 8"
menuButton={
<button
type="button"
title="More"
class="plain"
disabled={loading}
>
<Icon icon="more" size="l" alt="More" />
</button>
}
>
{currentAuthenticated && !isSelf && (
<>
<MenuItem
onClick={() => {
states.showCompose = {
draftStatus: {
status: `@${currentInfo?.acct || acct} `,
},
};
}}
>
<Icon icon="at" />
<span>Mention @{username}</span>
</MenuItem>
<MenuItem
onClick={() => {
setShowTranslatedBio(true);
}}
>
<Icon icon="translate" />
<span>Translate bio</span>
</MenuItem>
<MenuDivider />
</>
)}
<MenuItem href={url} target="_blank">
<Icon icon="external" />
<small class="menu-double-lines">{niceAccountURL(url)}</small>
</MenuItem>
<div class="menu-horizontal">
<MenuItem
onClick={() => {
// Copy url to clipboard
try {
navigator.clipboard.writeText(url);
showToast('Link copied');
} catch (e) {
console.error(e);
showToast('Unable to copy link');
}
}}
>
<Icon icon="link" />
<span>Copy</span>
</MenuItem>
{navigator?.share &&
navigator?.canShare?.({
url,
}) && (
<MenuItem
onClick={() => {
try {
navigator.share({
url,
});
} catch (e) {
console.error(e);
alert("Sharing doesn't seem to work.");
}
}}
>
<Icon icon="share" />
<span>Share</span>
</MenuItem>
)}
</div>
{!!relationship && (
<>
<MenuDivider />
{muting ? (
<MenuItem
onClick={() => {
setRelationshipUIState('loading');
(async () => {
try {
const newRelationship =
await currentMasto.v1.accounts.unmute(
currentInfo?.id || id,
);
console.log('unmuting', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`Unmuted @${username}`);
} catch (e) {
console.error(e);
setRelationshipUIState('error');
}
})();
}}
>
<Icon icon="unmute" />
<span>Unmute @{username}</span>
</MenuItem>
) : (
<SubMenu
openTrigger="clickOnly"
direction="bottom"
overflow="auto"
offsetX={-16}
label={
<>
<Icon icon="mute" />
<span class="menu-grow">Mute @{username}</span>
<span
style={{
textOverflow: 'clip',
}}
>
<Icon icon="time" />
<Icon icon="chevron-right" />
</span>
</>
}
>
<div class="menu-wrap">
{MUTE_DURATIONS.map((duration) => (
<MenuItem
onClick={() => {
setRelationshipUIState('loading');
(async () => {
try {
const newRelationship =
await currentMasto.v1.accounts.mute(
currentInfo?.id || id,
{
duration,
},
);
console.log('muting', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(
`Muted @${username} for ${MUTE_DURATIONS_LABELS[duration]}`,
);
} catch (e) {
console.error(e);
setRelationshipUIState('error');
showToast(`Unable to mute @${username}`);
}
})();
}}
>
{MUTE_DURATIONS_LABELS[duration]}
</MenuItem>
))}
</div>
</SubMenu>
)}
<MenuItem
onClick={() => {
if (!blocking && !confirm(`Block @${username}?`)) {
return;
}
setRelationshipUIState('loading');
(async () => {
try {
if (blocking) {
const newRelationship =
await currentMasto.v1.accounts.unblock(
currentInfo?.id || id,
);
console.log('unblocking', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`Unblocked @${username}`);
} else {
const newRelationship =
await currentMasto.v1.accounts.block(
currentInfo?.id || id,
);
console.log('blocking', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`Blocked @${username}`);
}
} catch (e) {
console.error(e);
setRelationshipUIState('error');
if (blocking) {
showToast(`Unable to unblock @${username}`);
} else {
showToast(`Unable to block @${username}`);
}
}
})();
}}
>
{blocking ? (
<>
<Icon icon="unblock" />
<span>Unblock @{username}</span>
</>
) : (
<>
<Icon icon="block" />
<span>Block @{username}</span>
</>
)}
</MenuItem>
{/* <MenuItem>
<Icon icon="flag" />
<span>Report @{username}</span>
</MenuItem> */}
</>
)}
</Menu>
{!!relationship && (
<button
type="button"
class={`${following || requested ? 'light swap' : ''}`}
data-swap-state={following || requested ? 'danger' : ''}
disabled={loading}
onClick={() => {
setRelationshipUIState('loading');
(async () => {
try {
let newRelationship;
(async () => {
try {
let newRelationship;
if (following || requested) {
const yes = confirm(
requested
? 'Withdraw follow request?'
: `Unfollow @${info.acct || info.username}?`,
);
if (following || requested) {
const yes = confirm(
requested
? 'Withdraw follow request?'
: `Unfollow @${info.acct || info.username}?`,
);
if (yes) {
newRelationship = await currentMasto.v1.accounts.unfollow(
if (yes) {
newRelationship =
await currentMasto.v1.accounts.unfollow(
accountID.current,
);
}
} else {
newRelationship = await currentMasto.v1.accounts.follow(
accountID.current,
);
}
} else {
newRelationship = await currentMasto.v1.accounts.follow(
accountID.current,
);
}
if (newRelationship) setRelationship(newRelationship);
setRelationshipUIState('default');
} catch (e) {
alert(e);
setRelationshipUIState('error');
}
})();
}}
>
{following ? (
<>
<span>Following</span>
<span>Unfollow</span>
</>
) : requested ? (
<>
<span>Requested</span>
<span>Withdraw</span>
</>
) : locked ? (
<>
<Icon icon="lock" /> <span>Follow</span>
</>
) : (
'Follow'
)}
</button>
)}
if (newRelationship) setRelationship(newRelationship);
setRelationshipUIState('default');
} catch (e) {
alert(e);
setRelationshipUIState('error');
}
})();
}}
>
{following ? (
<>
<span>Following</span>
<span>Unfollow</span>
</>
) : requested ? (
<>
<span>Requested</span>
<span>Withdraw</span>
</>
) : locked ? (
<>
<Icon icon="lock" /> <span>Follow</span>
</>
) : (
'Follow'
)}
</button>
)}
</span>
</p>
{!!showTranslatedBio && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowTranslatedBio(false);
}
}}
>
<TranslatedBioSheet note={note} fields={fields} />
</Modal>
)}
</>
);
}
@ -554,4 +860,44 @@ function lightenRGB([r, g, b]) {
return [r, g, b, alpha];
}
function niceAccountURL(url) {
if (!url) return;
const urlObj = new URL(url);
const { host, pathname } = urlObj;
const path = pathname.replace(/\/$/, '').replace(/^\//, '');
return (
<>
<span class="more-insignificant">{host}/</span>
<wbr />
<span>{path}</span>
</>
);
}
function TranslatedBioSheet({ note, fields }) {
const fieldsText =
fields
?.map(({ name, value }) => `${name}\n${getHTMLText(value)}`)
.join('\n\n') || '';
const text = getHTMLText(note) + (fieldsText ? `\n\n${fieldsText}` : '');
return (
<div class="sheet">
<header>
<h2>Translated Bio</h2>
</header>
<main>
<p
style={{
whiteSpace: 'pre-wrap',
}}
>
{text}
</p>
<TranslationBlock forceTranslate text={text} />
</main>
</div>
);
}
export default AccountInfo;

View file

@ -16,6 +16,7 @@ const alphaCache = {};
function Avatar({ url, size, alt = '', ...props }) {
size = SIZES[size] || size || SIZES.m;
const avatarRef = useRef();
const isMissing = /missing\.png$/.test(url);
return (
<span
ref={avatarRef}
@ -34,7 +35,11 @@ function Avatar({ url, size, alt = '', ...props }) {
height={size}
alt={alt}
loading="lazy"
crossOrigin={alphaCache[url] === undefined ? 'anonymous' : undefined}
crossOrigin={
alphaCache[url] === undefined && !isMissing
? 'anonymous'
: undefined
}
onError={(e) => {
if (e.target.crossOrigin) {
e.target.crossOrigin = null;
@ -43,6 +48,8 @@ function Avatar({ url, size, alt = '', ...props }) {
}}
onLoad={(e) => {
if (avatarRef.current) avatarRef.current.dataset.loaded = true;
if (alphaCache[url] !== undefined) return;
if (isMissing) return;
try {
// Check if image has alpha channel
const canvas = document.createElement('canvas');
@ -65,10 +72,11 @@ function Avatar({ url, size, alt = '', ...props }) {
if (hasAlpha) {
// console.log('hasAlpha', hasAlpha, allPixels.data);
avatarRef.current.classList.add('has-alpha');
alphaCache[url] = true;
}
alphaCache[url] = hasAlpha;
} catch (e) {
// Ignore
// Silent fail
alphaCache[url] = false;
}
}}
/>

View file

@ -1,14 +1,18 @@
#compose-container-outer {
width: 100%;
height: 100vh;
height: 100dvh;
overflow: auto;
align-self: flex-start;
padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left);
}
#compose-container {
margin: auto;
width: var(--main-width);
max-width: 100vw;
align-self: stretch;
animation: fade-in 0.2s ease-out;
max-height: 100vh;
overflow: auto;
}
#compose-container.standalone {
max-height: none;
margin: auto;
}
#compose-container .compose-top {
@ -26,8 +30,8 @@
#compose-container textarea {
width: 100%;
max-width: 100%;
height: 4em;
min-height: 4em;
height: 5em;
min-height: 5em;
max-height: 50vh;
resize: vertical;
line-height: 1.4;
@ -41,6 +45,7 @@
#compose-container textarea {
font-size: 150%;
font-size: calc(100% + 50% / var(--text-weight));
max-height: 65vh;
}
}
@ -65,6 +70,9 @@
overflow: auto;
box-shadow: 0 -3px 12px -3px var(--drop-shadow-color);
}
#compose-container .status-preview:has(.status-badge) {
border-top-right-radius: 8px;
}
#compose-container .status-preview :is(.hashtag, .time) {
/* Prevent hashtags from being clickable */
/* TODO: maybe use a different solution? */
@ -130,6 +138,9 @@
padding: 8px 0;
gap: 8px;
}
#compose-container .toolbar.wrap {
flex-wrap: wrap;
}
#compose-container .toolbar.stretch {
justify-content: stretch;
}
@ -140,7 +151,7 @@
#compose-container .toolbar-button {
display: inline-block;
color: var(--link-color);
background-color: var(--bg-blur-color);
background-color: transparent;
padding: 0 8px;
border-radius: 8px;
min-height: 2.4em;
@ -266,6 +277,19 @@
background-color: var(--bg-color);
}
#compose-container .form-visibility-direct {
--yellow-stripes: repeating-linear-gradient(
-45deg,
var(--reply-to-faded-color),
var(--reply-to-faded-color) 10px,
var(--reply-to-faded-color) 10px,
transparent 10px,
transparent 20px
);
/* diagonal stripes of yellow */
background-image: var(--yellow-stripes);
}
#compose-container .media-attachments {
background-color: var(--bg-faded-color);
padding: 8px;
@ -502,3 +526,43 @@
height: auto;
}
}
#custom-emojis-sheet {
max-height: 50vh;
max-height: 50dvh;
}
#custom-emojis-sheet main {
mask-image: none;
}
#custom-emojis-sheet .custom-emojis-list .section-header {
font-size: 80%;
text-transform: uppercase;
color: var(--text-insignificant-color);
padding: 8px 0 4px;
position: sticky;
top: 0;
background-color: var(--bg-blur-color);
backdrop-filter: blur(1px);
}
#custom-emojis-sheet .custom-emojis-list section {
display: flex;
flex-wrap: wrap;
}
#custom-emojis-sheet .custom-emojis-list button {
border-radius: 8px;
background-image: radial-gradient(
closest-side,
var(--img-bg-color),
transparent
);
}
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) {
filter: none;
background-color: var(--bg-faded-color);
}
#custom-emojis-sheet .custom-emojis-list button img {
transition: transform 0.1s ease-out;
}
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) img {
transform: scale(1.5);
}

File diff suppressed because it is too large Load diff

View file

@ -65,6 +65,15 @@ const ICONS = {
exit: 'mingcute:exit-line',
translate: 'mingcute:translate-line',
play: 'mingcute:play-fill',
trash: 'mingcute:delete-2-line',
mute: 'mingcute:volume-mute-line',
unmute: 'mingcute:volume-line',
block: 'mingcute:forbid-circle-line',
unblock: ['mingcute:forbid-circle-line', '180deg'],
flag: 'mingcute:flag-4-line',
time: 'mingcute:time-line',
refresh: 'mingcute:refresh-2-line',
emoji2: 'mingcute:emoji-2-line',
};
const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');

View file

@ -6,6 +6,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import Icon from './icon';
import Link from './link';
import Media from './media';
import MenuLink from './menu-link';
import Modal from './modal';
import TranslationBlock from './translation-block';
@ -22,6 +23,17 @@ function MediaModal({
const carouselFocusItem = useRef(null);
useLayoutEffect(() => {
carouselFocusItem.current?.scrollIntoView();
history.pushState({ mediaModal: true }, '');
const handlePopState = (e) => {
if (e.state?.mediaModal) {
onClose();
}
};
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, []);
const prevStatusID = useRef(statusID);
useEffect(() => {
@ -82,7 +94,8 @@ function MediaModal({
onClick={(e) => {
if (
e.target.classList.contains('carousel-item') ||
e.target.classList.contains('media')
e.target.classList.contains('media') ||
e.target.classList.contains('media-zoom')
) {
onClose();
}
@ -166,6 +179,32 @@ function MediaModal({
<span />
)}
<span>
<Menu
overflow="auto"
align="end"
position="anchor"
boundingBoxPadding="8 8 8 8"
offsetY={4}
menuClassName="glass-menu"
menuButton={
<button type="button" class="carousel-button plain3">
<Icon icon="more" alt="More" />
</button>
}
>
<MenuLink
href={
mediaAttachments[currentIndex]?.remoteUrl ||
mediaAttachments[currentIndex]?.url
}
class="carousel-button plain3"
target="_blank"
title="Open original media in new window"
>
<Icon icon="popout" />
<span>Open original media</span>
</MenuLink>
</Menu>{' '}
<Link
to={instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`}
class="button carousel-button media-post-link plain3"
@ -179,18 +218,7 @@ function MediaModal({
}}
>
<span class="button-label">See post </span>&raquo;
</Link>{' '}
<a
href={
mediaAttachments[currentIndex]?.remoteUrl ||
mediaAttachments[currentIndex]?.url
}
target="_blank"
class="button carousel-button plain3"
title="Open original media in new window"
>
<Icon icon="popout" alt="Open original media in new window" />
</a>{' '}
</Link>
</span>
</div>
{mediaAttachments?.length > 1 && (

View file

@ -1,5 +1,6 @@
import { getBlurHashAverageColor } from 'fast-blurhash';
import { useRef } from 'preact/hooks';
import { useCallback, useRef } from 'preact/hooks';
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
import Icon from './icon';
import { formatDuration } from './status';
@ -39,8 +40,40 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`;
}
const mediaRef = useRef();
const onUpdate = useCallback(({ x, y, scale }) => {
const { current: media } = mediaRef;
if (media) {
const value = make3dTransformValue({ x, y, scale });
media.style.setProperty('transform', value);
media.closest('.media-zoom').style.touchAction =
scale <= 1 ? 'pan-x' : '';
}
}, []);
const quickPinchZoomProps = {
draggableUnZoomed: false,
inertiaFriction: 0.9,
containerProps: {
className: 'media-zoom',
style: {
overflow: 'visible',
// width: 'inherit',
// height: 'inherit',
// justifyContent: 'inherit',
// alignItems: 'inherit',
// display: 'inherit',
},
},
onUpdate,
};
if (type === 'image' || (type === 'unknown' && previewUrl && url)) {
// Note: type: unknown might not have width/height
quickPinchZoomProps.containerProps.style.display = 'inherit';
return (
<div
class={`media media-image`}
@ -48,42 +81,42 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
style={
showOriginal && {
backgroundImage: `url(${previewUrl})`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
aspectRatio: `${width}/${height}`,
width,
height,
maxWidth: '100%',
maxHeight: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}
}
>
<img
src={mediaURL}
alt={description}
width={width}
height={height}
loading={showOriginal ? 'eager' : 'lazy'}
style={
!showOriginal && {
{showOriginal ? (
<QuickPinchZoom {...quickPinchZoomProps}>
<img
ref={mediaRef}
src={mediaURL}
alt={description}
width={width}
height={height}
loading="eager"
decoding="async"
onLoad={(e) => {
e.target.closest('.media-image').style.backgroundImage = '';
e.target.closest('.media-zoom').style.display = '';
}}
/>
</QuickPinchZoom>
) : (
<img
src={mediaURL}
alt={description}
width={width}
height={height}
loading="lazy"
style={{
backgroundColor:
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
backgroundPosition: focalBackgroundPosition || 'center',
}
}
onDblClick={() => {
// Open original image in new tab
window.open(url, '_blank');
}}
onLoad={(e) => {
// Hide background image after image loads
e.target.parentElement.style.backgroundImage = 'none';
}}
/>
}}
onLoad={(e) => {
e.target.closest('.media-image').style.backgroundImage = '';
}}
/>
)}
</div>
);
} else if (type === 'gifv' || type === 'video') {
@ -94,6 +127,23 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
const formattedDuration = formatDuration(original.duration);
const hoverAnimate = !showOriginal && !autoAnimate && isGIF;
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
const videoHTML = `
<video
src="${url}"
poster="${previewUrl}"
width="${width}"
height="${height}"
preload="auto"
autoplay
muted="${isGIF}"
${isGIF ? '' : 'controls'}
playsinline
loop="${loopable}"
${isGIF ? 'ondblclick="this.paused ? this.play() : this.pause()"' : ''}
></video>
`;
return (
<div
class={`media media-${isGIF ? 'gif' : 'video'} ${
@ -129,33 +179,22 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
}}
>
{showOriginal || autoGIFAnimate ? (
<div
style={{
width: '100%',
height: '100%',
}}
dangerouslySetInnerHTML={{
__html: `
<video
src="${url}"
poster="${previewUrl}"
width="${width}"
height="${height}"
preload="auto"
autoplay
muted="${isGIF}"
${isGIF ? '' : 'controls'}
playsinline
loop="${loopable}"
${
isGIF
? 'ondblclick="this.paused ? this.play() : this.pause()"'
: ''
}
></video>
`,
}}
/>
isGIF ? (
<QuickPinchZoom {...quickPinchZoomProps}>
<div
ref={mediaRef}
dangerouslySetInnerHTML={{
__html: videoHTML,
}}
/>
</QuickPinchZoom>
) : (
<div
dangerouslySetInnerHTML={{
__html: videoHTML,
}}
/>
)
) : isGIF ? (
<video
ref={videoRef}
@ -204,6 +243,11 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
loading="lazy"
/>
) : null}
{!showOriginal && (
<div class="media-play">
<Icon icon="play" size="xxl" />
</div>
)}
</div>
);
}

View file

@ -1,17 +1,23 @@
import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { useLongPress } from 'use-long-press';
import { useSnapshot } from 'valtio';
import { api } from '../utils/api';
import states from '../utils/states';
import { getCurrentAccount } from '../utils/store-utils';
import store from '../utils/store';
import Avatar from './avatar';
import Icon from './icon';
import MenuLink from './MenuLink';
import MenuLink from './menu-link';
function NavMenu(props) {
const snapStates = useSnapshot(states);
const { instance, authenticated } = api();
const currentAccount = getCurrentAccount();
const accounts = store.local.getJSON('accounts') || [];
const currentAccount = accounts.find(
(account) => account.info.id === store.session.get('currentAccount'),
);
const moreThanOneAccount = accounts.length > 1;
// Home = Following
// But when in multi-column mode, Home becomes columns of anything
@ -21,6 +27,16 @@ function NavMenu(props) {
snapStates.settings.shortcutsColumnsMode &&
!snapStates.shortcuts.find((pin) => pin.type === 'following');
const bindLongPress = useLongPress(
() => {
states.showAccounts = true;
},
{
detect: 'touch',
cancelOnMovement: true,
},
);
return (
<Menu
portal={{
@ -30,11 +46,31 @@ function NavMenu(props) {
overflow="auto"
viewScroll="close"
boundingBoxPadding="8 8 8 8"
menuButton={
<button type="button" class="button plain">
<Icon icon="menu" size="l" />
menuButton={({ open }) => (
<button
type="button"
class={`button plain nav-menu-button ${
moreThanOneAccount ? 'with-avatar' : ''
} ${open ? 'active' : ''}`}
style={{ position: 'relative' }}
onContextMenu={(e) => {
e.preventDefault();
states.showAccounts = true;
}}
{...bindLongPress()}
>
{moreThanOneAccount && (
<Avatar
url={
currentAccount?.info?.avatar ||
currentAccount?.info?.avatarStatic
}
size="l"
/>
)}
<Icon icon="menu" size={moreThanOneAccount ? 's' : 'l'} />
</button>
}
)}
>
{!!snapStates.appVersion?.commitHash &&
__COMMIT_HASH__ !== snapStates.appVersion.commitHash && (

View file

@ -60,6 +60,11 @@
#shortcuts-settings-container .shortcuts-view-mode label img {
max-height: 64px;
}
@media (prefers-color-scheme: dark) {
#shortcuts-settings-container .shortcuts-view-mode label img {
filter: invert(0.9) hue-rotate(180deg);
}
}
#shortcuts-settings-container .shortcuts-view-mode label span {
text-align: center;
font-size: 80%;
@ -68,10 +73,13 @@
position: absolute;
opacity: 0;
pointer-events: none;
perspective: 500px;
}
#shortcuts-settings-container .shortcuts-view-mode label input ~ * {
opacity: 0.5;
transition: opacity 0.2s;
transform-origin: bottom;
transform: scale(0.975);
transition: all 0.2s ease-out;
}
#shortcuts-settings-container .shortcuts-view-mode label:has(input:checked) {
box-shadow: inset 0 0 0 3px var(--link-color);
@ -81,6 +89,7 @@
label
input:is(:hover, :active, :checked)
~ * {
transform: scale(1);
opacity: 1;
}

View file

@ -244,7 +244,8 @@ function ShortcutsSettings() {
states.settings.shortcutsViewMode = e.target.value;
}}
/>{' '}
<img src={imgURL} alt="" /> <span>{label}</span>
<img src={imgURL} alt="" width="80" height="58" />{' '}
<span>{label}</span>
</label>
))}
</div>

View file

@ -12,7 +12,7 @@ import states from '../utils/states';
import AsyncText from './AsyncText';
import Icon from './icon';
import Link from './link';
import MenuLink from './MenuLink';
import MenuLink from './menu-link';
function Shortcuts() {
const snapStates = useSnapshot(states);

View file

@ -50,9 +50,11 @@
.status-pre-meta .name-text {
display: inline;
}
.status-pre-meta .icon {
color: var(--reblog-color);
.status-pre-meta > * {
vertical-align: middle;
}
.status-reblog .status-pre-meta .icon {
color: var(--reblog-color);
margin-right: 4px;
}
@ -65,6 +67,11 @@
align-items: flex-start;
position: relative;
}
@media (min-width: 40em) {
.status {
padding-bottom: 16px;
}
}
.status.large {
--fade-in-out-bg: linear-gradient(
to bottom,
@ -103,6 +110,72 @@
background-color: var(--outline-color);
}
.status.filtered {
padding-block: 12px;
display: flex;
gap: 8px;
align-items: center;
}
.status.filtered .status-filtered-info {
pointer-events: none;
flex-grow: 1;
font-size: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
mask-image: linear-gradient(to right, black 90%, transparent);
position: relative;
}
.status.filtered .avatar {
opacity: 0.5;
transition: opacity 0.7s ease-in;
}
.status.filtered:is(:hover, :focus, :active) .avatar {
opacity: 1;
}
.status.filtered :is(.status-filtered-info-1, .status-filtered-info-2) {
transition: all 0.2s ease-out;
}
.status.filtered:hover :is(.status-filtered-info-1, .status-filtered-info-2) {
transition-delay: 0.5s;
}
.status.filtered .status-filtered-info-1 {
opacity: 0.5;
}
.status.filtered:is(:hover, :focus, :active) .status-filtered-info-1 {
opacity: 0;
}
.status.filtered .status-filtered-info-2 {
opacity: 0;
transform: translateX(8px);
position: absolute;
left: 0;
}
.status.filtered:is(:hover, :focus, :active) .status-filtered-info-2 {
opacity: 0.75;
transform: translateX(0);
}
.status.compact-thread {
display: flex;
gap: 8px;
}
.status.compact-thread .status-thread-badge {
flex-shrink: 0;
min-width: 50px;
justify-content: center;
}
.status.compact-thread .content-compact {
overflow: hidden;
display: -webkit-box;
display: box;
-webkit-box-orient: vertical;
box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
font-size: 90%;
}
.status .container {
flex-grow: 1;
min-width: 0;
@ -195,6 +268,52 @@
);
font-weight: bold;
}
.status-filtered-badge {
flex-shrink: 0;
color: var(--text-insignificant-color);
/* background: var(--bg-faded-color); */
/* border: var(--hairline-width) solid var(--bg-color); */
border: var(--hairline-width) dashed var(--text-insignificant-color);
border-radius: 4px;
padding: 4px;
font-size: 10px;
line-height: 1;
text-transform: uppercase;
font-weight: bold;
vertical-align: middle;
display: inline-block;
}
.status-filtered-badge.badge-meta {
display: inline-flex;
flex-direction: column;
position: relative;
top: calc((9px + 2px) / 2 * -1);
min-width: 50px;
text-align: center;
}
.status-filtered-badge.clickable:hover {
cursor: pointer;
color: var(--text-color);
border-color: var(--text-color);
background: var(--bg-color);
}
.status-filtered-badge.badge-meta > span:first-child {
white-space: nowrap;
}
.status-filtered-badge.badge-meta > span + span {
display: block;
font-size: 9px;
font-weight: normal;
text-transform: none;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
position: absolute;
width: 100%;
top: calc(100% + 2px);
left: 0;
text-align: center;
}
.status.large .content-container {
margin-left: calc(-50px - 16px);
@ -440,6 +559,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: inherit;
}
.status :is(.media-video, .media-audio, .media-gif) {
position: relative;
@ -553,6 +673,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
align-items: center;
gap: 8px;
font-size: 90%;
z-index: 1;
}
.carousel-item button.media-alt .media-alt-desc {
overflow: hidden;
@ -713,6 +834,7 @@ a.card:is(:hover, :focus) {
.poll {
transition: opacity 0.2s ease-in-out;
margin-top: 8px;
}
.poll.loading {
opacity: 0.5;
@ -721,31 +843,62 @@ a.card:is(:hover, :focus) {
.poll.read-only {
pointer-events: none;
}
.poll-options {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px;
border-radius: 16px;
border: 1px solid var(--outline-color);
background-color: var(--bg-faded-color);
}
.poll-option {
margin-top: 8px;
padding: 8px;
padding: 4px 8px;
display: flex;
gap: 8px;
justify-content: space-between;
background-color: var(--bg-faded-color);
background-image: linear-gradient(
to right,
var(--link-faded-color),
var(--link-faded-color) var(--percentage),
transparent var(--percentage),
transparent
);
background-repeat: no-repeat;
border-radius: 8px;
border: 1px solid var(--outline-color);
align-items: center;
text-shadow: 0 1px var(--bg-blur-color);
position: relative;
}
.poll-option > * {
z-index: 1;
}
.poll-option:after {
content: '';
position: absolute;
inset: 0;
border-radius: 4px;
background-color: var(--link-faded-color);
opacity: 0;
pointer-events: none;
transition: all 0.2s ease-in-out;
z-index: 0;
}
.poll-option:first-child:after {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.poll-option:last-child:after {
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.poll-option:hover:after {
opacity: 1;
}
.poll-option.poll-result:after {
width: var(--percentage);
opacity: 1;
}
.poll-label {
width: 100%;
display: flex;
gap: 8px;
cursor: pointer;
z-index: 1;
}
.poll-label input:is([type='radio'], [type='checkbox']) {
flex-shrink: 0;
margin: 3px;
}
.poll-option-votes {
flex-shrink: 0;
@ -763,6 +916,9 @@ a.card:is(:hover, :focus) {
margin: 8px 0;
font-size: 90%;
}
.poll-option-title {
text-shadow: 0 1px var(--bg-color);
}
.poll-option-title .icon {
vertical-align: middle;
}
@ -780,6 +936,9 @@ a.card:is(:hover, :focus) {
.status .extra-meta a {
color: inherit;
text-decoration: none;
vertical-align: baseline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.status .extra-meta a:is(:hover, :focus) {
text-decoration: underline;
@ -807,6 +966,10 @@ a.card:is(:hover, :focus) {
border-top: var(--hairline-width) solid var(--outline-color);
margin-top: 8px;
}
.status.large .actions.disabled {
pointer-events: none;
opacity: 0.5;
}
.status .action.has-count {
flex: 1;
}
@ -981,3 +1144,54 @@ a.card:is(:hover, :focus) {
border: 1px solid var(--outline-color);
border-radius: 8px;
}
/* DELETED */
.status-deleted {
opacity: 0.75;
}
.status-deleted-tag {
color: var(--text-insignificant-color);
text-transform: uppercase;
font-size: 80%;
}
/* FILTERED */
#filtered-status-peek {
user-select: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
}
#filtered-status-peek main > p:first-child {
margin-top: 0;
}
#filtered-status-peek main .heading {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#filtered-status-peek .status-link {
border-radius: 16px;
border: var(--hairline-width) dashed var(--text-insignificant-color);
max-height: 33vh;
max-height: 33dvh;
overflow: hidden;
}
#filtered-status-peek .status-link .status {
pointer-events: none;
font-size: 90%;
max-height: 33vh;
max-height: 33dvh;
overflow: hidden;
mask-image: linear-gradient(black 80%, transparent 95%);
}
#filtered-status-peek .status-post-link {
float: right;
position: sticky;
bottom: 8px;
right: 8px;
}

View file

@ -1,5 +1,7 @@
import './status.css';
import { match } from '@formatjs/intl-localematcher';
import '@justinribeiro/lite-youtube';
import {
ControlledMenu,
Menu,
@ -7,6 +9,7 @@ import {
MenuHeader,
MenuItem,
} from '@szhsin/react-menu';
import { decodeBlurHash } from 'fast-blurhash';
import mem from 'mem';
import pThrottle from 'p-throttle';
import { memo } from 'preact/compat';
@ -22,12 +25,14 @@ import NameText from '../components/name-text';
import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links';
import htmlContentLength from '../utils/html-content-length';
import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { saveStatus, statusKey } from '../utils/states';
import states, { getStatus, saveStatus, statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import store from '../utils/store';
import visibilityIconsMap from '../utils/visibility-icons-map';
@ -35,7 +40,7 @@ import Avatar from './avatar';
import Icon from './icon';
import Link from './link';
import Media from './media';
import MenuLink from './MenuLink';
import MenuLink from './menu-link';
import RelativeTime from './relative-time';
import TranslationBlock from './translation-block';
@ -71,6 +76,7 @@ function Status({
contentTextWeight,
enableTranslate,
previewMode,
allowFilters,
}) {
if (skeleton) {
return (
@ -140,10 +146,30 @@ function Status({
// Non-API props
_deleted,
_pinned,
_filtered,
} = status;
console.debug('RENDER Status', id, status?.account.displayName);
const debugHover = (e) => {
if (e.shiftKey) {
console.log(status);
}
};
if (allowFilters && size !== 'l' && _filtered) {
return (
<FilteredStatus
status={status}
filterInfo={_filtered}
instance={instance}
containerProps={{
onMouseEnter: debugHover,
}}
/>
);
}
const createdAtDate = new Date(createdAt);
const editedAtDate = new Date(editedAt);
@ -175,13 +201,8 @@ function Status({
const showSpoiler = !!snapStates.spoilers[id] || false;
const debugHover = (e) => {
if (e.shiftKey) {
console.log(status);
}
};
if (reblog) {
// If has statusID, means useItemID (cached in states)
return (
<div class="status-reblog" onMouseEnter={debugHover}>
<div class="status-pre-meta">
@ -190,7 +211,8 @@ function Status({
boosted
</div>
<Status
status={reblog}
status={statusID ? null : reblog}
statusID={statusID ? reblog.id : null}
instance={instance}
size={size}
contentTextWeight={contentTextWeight}
@ -201,6 +223,8 @@ function Status({
const [forceTranslate, setForceTranslate] = useState(false);
const targetLanguage = getTranslateTargetLanguage(true);
const contentTranslationHideLanguages =
snapStates.settings.contentTranslationHideLanguages || [];
if (!snapStates.settings.contentTranslation) enableTranslate = false;
const [showEdited, setShowEdited] = useState(false);
@ -543,6 +567,29 @@ function Status({
<Icon icon="pencil" />
<span>Edit</span>
</MenuItem>
{isSizeLarge && (
<MenuItem
onClick={() => {
const yes = confirm('Delete this post?');
if (yes) {
(async () => {
try {
await masto.v1.statuses.remove(id);
const cachedStatus = getStatus(id, instance);
cachedStatus._deleted = true;
showToast('Deleted');
} catch (e) {
console.error(e);
showToast('Unable to delete');
}
})();
}
}}
>
<Icon icon="trash" />
<span>Delete</span>
</MenuItem>
)}
</>
)}
</>
@ -582,12 +629,13 @@ function Status({
m: 'medium',
l: 'large',
}[size]
}`}
} ${_deleted ? 'status-deleted' : ''}`}
onMouseEnter={debugHover}
onContextMenu={(e) => {
if (size === 'l') return;
if (e.metaKey) return;
if (previewMode) return;
if (_deleted) return;
// console.log('context menu', e);
const link = e.target.closest('a');
if (link && /^https?:\/\//.test(link.getAttribute('href'))) return;
@ -672,7 +720,9 @@ function Status({
)} */}
{/* </span> */}{' '}
{size !== 'l' &&
(url && !previewMode ? (
(_deleted ? (
<span class="status-deleted-tag">Deleted</span>
) : url && !previewMode ? (
<Menu
instanceRef={menuInstanceRef}
portal={{
@ -859,7 +909,11 @@ function Status({
{((enableTranslate &&
!!content.trim() &&
language &&
language !== targetLanguage) ||
language !== targetLanguage &&
!match([language], [targetLanguage]) &&
!contentTranslationHideLanguages.find(
(l) => language === l || match([language], [l]),
)) ||
forceTranslate) && (
<TranslationBlock
forceTranslate={forceTranslate}
@ -931,29 +985,41 @@ function Status({
{isSizeLarge && (
<>
<div class="extra-meta">
<Icon icon={visibilityIconsMap[visibility]} alt={visibility} />{' '}
<a href={url} target="_blank">
<time class="created" datetime={createdAtDate.toISOString()}>
{createdDateText}
</time>
</a>
{editedAt && (
{_deleted ? (
<span class="status-deleted-tag">Deleted</span>
) : (
<>
{' '}
&bull; <Icon icon="pencil" alt="Edited" />{' '}
<time
class="edited"
datetime={editedAtDate.toISOString()}
onClick={() => {
setShowEdited(id);
}}
>
{editedDateText}
</time>
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibility}
/>{' '}
<a href={url} target="_blank">
<time
class="created"
datetime={createdAtDate.toISOString()}
>
{createdDateText}
</time>
</a>
{editedAt && (
<>
{' '}
&bull; <Icon icon="pencil" alt="Edited" />{' '}
<time
class="edited"
datetime={editedAtDate.toISOString()}
onClick={() => {
setShowEdited(id);
}}
>
{editedDateText}
</time>
</>
)}
</>
)}
</div>
<div class="actions">
<div class={`actions ${_deleted ? 'disabled' : ''}`}>
<div class="action has-count">
<StatusButton
title="Reply"
@ -1105,18 +1171,32 @@ function Card({ card, instance }) {
// );
// }
if (hasText && image) {
if (hasText && (image || (!type !== 'photo' && blurhash))) {
const domain = new URL(url).hostname.replace(/^www\./, '');
let blurhashImage;
if (!image) {
const w = 44;
const h = 44;
const blurhashPixels = decodeBlurHash(blurhash, w, h);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(w, h);
imageData.data.set(blurhashPixels);
ctx.putImageData(imageData, 0, 0);
blurhashImage = canvas.toDataURL();
}
return (
<a
href={cardStatusURL || url}
target={cardStatusURL ? null : '_blank'}
rel="nofollow noopener noreferrer"
class={`card link ${size}`}
class={`card link ${blurhashImage ? '' : size}`}
>
<div class="card-image">
<img
src={image}
src={image || blurhashImage}
width={width}
height={height}
loading="lazy"
@ -1157,6 +1237,13 @@ function Card({ card, instance }) {
</a>
);
} else if (type === 'video') {
if (/youtube/i.test(providerName)) {
// Get ID from e.g. https://www.youtube.com/watch?v=[VIDEO_ID]
const videoID = url.match(/watch\?v=([^&]+)/)?.[1];
if (videoID) {
return <lite-youtube videoid={videoID} nocookie></lite-youtube>;
}
}
return (
<div
class="card video"
@ -1222,53 +1309,64 @@ function Poll({
roundPrecision = 2;
}
const [showResults, setShowResults] = useState(false);
const optionsHaveVoteCounts = options.every((o) => o.votesCount !== null);
return (
<div
lang={lang}
class={`poll ${readOnly ? 'read-only' : ''} ${
uiState === 'loading' ? 'loading' : ''
}`}
onDblClick={() => {
setShowResults(!showResults);
}}
>
{voted || expired ? (
options.map((option, i) => {
const { title, votesCount: optionVotesCount } = option;
const percentage = pollVotesCount
? ((optionVotesCount / pollVotesCount) * 100).toFixed(
roundPrecision,
)
: 0;
// check if current poll choice is the leading one
const isLeading =
optionVotesCount > 0 &&
optionVotesCount === Math.max(...options.map((o) => o.votesCount));
return (
<div
key={`${i}-${title}-${optionVotesCount}`}
class={`poll-option ${isLeading ? 'poll-option-leading' : ''}`}
style={{
'--percentage': `${percentage}%`,
}}
>
<div class="poll-option-title">
{title}
{voted && ownVotes.includes(i) && (
<>
{' '}
<Icon icon="check-circle" />
</>
)}
</div>
{(showResults && optionsHaveVoteCounts) || voted || expired ? (
<div class="poll-options">
{options.map((option, i) => {
const { title, votesCount: optionVotesCount } = option;
const percentage = pollVotesCount
? ((optionVotesCount / pollVotesCount) * 100).toFixed(
roundPrecision,
)
: 0;
// check if current poll choice is the leading one
const isLeading =
optionVotesCount > 0 &&
optionVotesCount ===
Math.max(...options.map((o) => o.votesCount));
return (
<div
class="poll-option-votes"
title={`${optionVotesCount} vote${
optionVotesCount === 1 ? '' : 's'
key={`${i}-${title}-${optionVotesCount}`}
class={`poll-option poll-result ${
isLeading ? 'poll-option-leading' : ''
}`}
style={{
'--percentage': `${percentage}%`,
}}
>
{percentage}%
<div class="poll-option-title">
{title}
{voted && ownVotes.includes(i) && (
<>
{' '}
<Icon icon="check-circle" />
</>
)}
</div>
<div
class="poll-option-votes"
title={`${optionVotesCount} vote${
optionVotesCount === 1 ? '' : 's'
}`}
>
{percentage}%
</div>
</div>
</div>
);
})
);
})}
</div>
) : (
<form
onSubmit={async (e) => {
@ -1287,23 +1385,25 @@ function Poll({
setUIState('default');
}}
>
{options.map((option, i) => {
const { title } = option;
return (
<div class="poll-option">
<label class="poll-label">
<input
type={multiple ? 'checkbox' : 'radio'}
name="poll"
value={i}
disabled={uiState === 'loading'}
readOnly={readOnly}
/>
<span class="poll-option-title">{title}</span>
</label>
</div>
);
})}
<div class="poll-options">
{options.map((option, i) => {
const { title } = option;
return (
<div class="poll-option">
<label class="poll-label">
<input
type={multiple ? 'checkbox' : 'radio'}
name="poll"
value={i}
disabled={uiState === 'loading'}
readOnly={readOnly}
/>
<span class="poll-option-title">{title}</span>
</label>
</div>
);
})}
</div>
{!readOnly && (
<button
class="poll-vote-button"
@ -1418,6 +1518,7 @@ function EditedAtModal({
size="s"
withinContext
readOnly
previewMode
/>
</li>
);
@ -1608,18 +1709,6 @@ function nicePostURL(url) {
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
const div = document.createElement('div');
function getHTMLText(html) {
if (!html) return 0;
div.innerHTML = html
.replace(/<\/p>/g, '</p>\n\n')
.replace(/<\/li>/g, '</li>\n');
div.querySelectorAll('br').forEach((br) => {
br.replaceWith('\n');
});
return div.innerText.replace(/[\r\n]{3,}/g, '\n\n').trim();
}
const root = document.documentElement;
const defaultBoundingBoxPadding = 8;
function safeBoundingBoxPadding() {
@ -1641,4 +1730,112 @@ function safeBoundingBoxPadding() {
return str;
}
function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
const {
account: { avatar, avatarStatic },
createdAt,
visibility,
reblog,
} = status;
const isReblog = !!reblog;
const filterTitleStr = filterInfo?.titlesStr || '';
const createdAtDate = new Date(createdAt);
const statusPeekText = statusPeek(status.reblog || status);
const [showPeek, setShowPeek] = useState(false);
const bindLongPress = useLongPress(
() => {
setShowPeek(true);
},
{
captureEvent: true,
detect: 'touch',
cancelOnMovement: true,
},
);
return (
<div
class={isReblog ? 'status-reblog' : ''}
{...containerProps}
title={statusPeekText}
onContextMenu={(e) => {
e.preventDefault();
setShowPeek(true);
}}
{...bindLongPress()}
>
<article class="status filtered" tabindex="-1">
<b
class="status-filtered-badge clickable badge-meta"
title={filterTitleStr}
onClick={(e) => {
e.preventDefault();
setShowPeek(true);
}}
>
<span>Filtered</span>
<span>{filterTitleStr}</span>
</b>{' '}
<Avatar url={avatarStatic || avatar} />
<span class="status-filtered-info">
<span class="status-filtered-info-1">
<NameText account={status.account} instance={instance} />{' '}
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>{' '}
{isReblog ? (
'boosted'
) : (
<RelativeTime datetime={createdAtDate} format="micro" />
)}
</span>
<span class="status-filtered-info-2">
{isReblog && (
<>
<Avatar
url={reblog.account.avatarStatic || reblog.account.avatar}
/>{' '}
</>
)}
{statusPeekText}
</span>
</span>
</article>
{!!showPeek && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowPeek(false);
}
}}
>
<div id="filtered-status-peek" class="sheet">
<main tabIndex="-1">
<p class="heading">
<b class="status-filtered-badge">Filtered</b> {filterTitleStr}
</p>
<Link
class="status-link"
to={`/${instance}/s/${status.id}`}
onClick={() => {
setShowPeek(false);
}}
>
<Status status={status} instance={instance} size="s" readOnly />
<button type="button" class="status-post-link plain3">
See post &raquo;
</button>
</Link>
</main>
</div>
</Modal>
)}
</div>
);
}
export default memo(Status);

View file

@ -2,7 +2,11 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio';
import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import { groupBoosts, groupContext } from '../utils/timeline-utils';
import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll';
@ -28,6 +32,7 @@ function Timeline({
headerStart,
headerEnd,
timelineStart,
allowFilters,
}) {
const [items, setItems] = useState([]);
const [uiState, setUIState] = useState('default');
@ -48,6 +53,7 @@ function Timeline({
if (boostsCarousel) {
value = groupBoosts(value);
}
value = groupContext(value);
console.log(value);
if (firstLoad) {
setItems(value);
@ -192,24 +198,27 @@ function Timeline({
}, [nearReachEnd, showMore]);
const lastHiddenTime = useRef();
usePageVisibility((visible) => {
if (visible) {
const timeDiff = Date.now() - lastHiddenTime.current;
if (!lastHiddenTime.current || timeDiff > 1000 * 60) {
(async () => {
console.log('✨ Check updates');
const hasUpdate = await checkForUpdates();
if (hasUpdate) {
console.log('✨ Has new updates');
setShowNew(true);
}
})();
usePageVisibility(
(visible) => {
if (visible) {
const timeDiff = Date.now() - lastHiddenTime.current;
if (!lastHiddenTime.current || timeDiff > 1000 * 60) {
(async () => {
console.log('✨ Check updates');
const hasUpdate = await checkForUpdates();
if (hasUpdate) {
console.log('✨ Has new updates');
setShowNew(true);
}
})();
}
} else {
lastHiddenTime.current = Date.now();
}
} else {
lastHiddenTime.current = Date.now();
}
setVisible(visible);
}, []);
setVisible(visible);
},
[checkForUpdates],
);
// checkForUpdates interval
useInterval(
@ -309,52 +318,117 @@ function Timeline({
} else if (type === 'pinned') {
title = 'Pinned posts';
}
const isCarousel = type === 'boosts' || type === 'pinned';
if (items) {
return (
<li key={`timeline-${statusID}`}>
<StatusCarousel title={title} class={`${type}-carousel`}>
{items.map((item) => {
const { id: statusID, reblog } = item;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
return (
<li key={statusID}>
<Link
class="status-carousel-link timeline-item-alt"
to={url}
>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
size="s"
contentTextWeight
/>
) : (
<Status
status={item}
instance={instance}
size="s"
contentTextWeight
/>
)}
</Link>
</li>
);
})}
</StatusCarousel>
</li>
);
if (isCarousel) {
// Here, we don't hide filtered posts, but we sort them last
items.sort((a, b) => {
if (a._filtered && !b._filtered) {
return 1;
}
if (!a._filtered && b._filtered) {
return -1;
}
return 0;
});
return (
<li key={`timeline-${statusID}`}>
<StatusCarousel
title={title}
class={`${type}-carousel`}
>
{items.map((item) => {
const { id: statusID, reblog } = item;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
return (
<li key={statusID}>
<Link
class="status-carousel-link timeline-item-alt"
to={url}
>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
size="s"
contentTextWeight
/>
) : (
<Status
status={item}
instance={instance}
size="s"
contentTextWeight
/>
)}
</Link>
</li>
);
})}
</StatusCarousel>
</li>
);
}
const manyItems = items.length > 3;
return items.map((item, i) => {
const { id: statusID } = item;
const url = instance
? `/${instance}/s/${statusID}`
: `/s/${statusID}`;
const isMiddle = i > 0 && i < items.length - 1;
return (
<li
key={`timeline-${statusID}`}
class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${
i === 0
? 'start'
: i === items.length - 1
? 'end'
: 'middle'
}`}
>
<Link class="status-link timeline-item" to={url}>
{manyItems && isMiddle && type === 'thread' ? (
<TimelineStatusCompact
status={item}
instance={instance}
/>
) : useItemID ? (
<Status
statusID={statusID}
instance={instance}
allowFilters={allowFilters}
/>
) : (
<Status
status={item}
instance={instance}
allowFilters={allowFilters}
/>
)}
</Link>
</li>
);
});
}
return (
<li key={`timeline-${statusID + _pinned}`}>
<Link class="status-link timeline-item" to={url}>
{useItemID ? (
<Status statusID={statusID} instance={instance} />
<Status
statusID={statusID}
instance={instance}
allowFilters={allowFilters}
/>
) : (
<Status status={status} instance={instance} />
<Status
status={status}
instance={instance}
allowFilters={allowFilters}
/>
)}
</Link>
</li>
@ -430,52 +504,6 @@ function Timeline({
);
}
function groupBoosts(values) {
let newValues = [];
let boostStash = [];
let serialBoosts = 0;
for (let i = 0; i < values.length; i++) {
const item = values[i];
if (item.reblog) {
boostStash.push(item);
serialBoosts++;
} else {
newValues.push(item);
if (serialBoosts < 3) {
serialBoosts = 0;
}
}
}
// if boostStash is more than quarter of values
// or if there are 3 or more boosts in a row
if (boostStash.length > values.length / 4 || serialBoosts >= 3) {
// if boostStash is more than 3 quarter of values
const boostStashID = boostStash.map((status) => status.id);
if (boostStash.length > (values.length * 3) / 4) {
// insert boost array at the end of specialHome list
newValues = [
...newValues,
{ id: boostStashID, items: boostStash, type: 'boosts' },
];
} else {
// insert boosts array in the middle of specialHome list
const half = Math.floor(newValues.length / 2);
newValues = [
...newValues.slice(0, half),
{
id: boostStashID,
items: boostStash,
type: 'boosts',
},
...newValues.slice(half),
];
}
return newValues;
} else {
return values;
}
}
function StatusCarousel({ title, class: className, children }) {
const carouselRef = useRef();
const { reachStart, reachEnd, init } = useScroll({
@ -524,4 +552,26 @@ function StatusCarousel({ title, class: className, children }) {
);
}
function TimelineStatusCompact({ status, instance }) {
const snapStates = useSnapshot(states);
const { id } = status;
const statusPeekText = statusPeek(status);
const sKey = statusKey(id, instance);
return (
<article class="status compact-thread" tabindex="-1">
{!!snapStates.statusThreadNumber[sKey] && (
<div class="status-thread-badge">
<Icon icon="thread" size="s" />
{snapStates.statusThreadNumber[sKey]
? ` ${snapStates.statusThreadNumber[sKey]}/X`
: ''}
</div>
)}
<div class="content-compact" title={statusPeekText}>
{statusPeekText}
</div>
</article>
);
}
export default Timeline;

View file

@ -58,8 +58,14 @@
0 1px 5px -2px var(--drop-shadow-color);
text-shadow: 0 1px var(--bg-color);
}
.status-translation-block .translated-block .translation-info {
display: flex;
align-items: center;
gap: 8px;
}
.status-translation-block .translated-block .translation-info * {
vertical-align: middle;
flex-shrink: 0;
}
.status-translation-block .translated-source-select {
appearance: none;
@ -71,16 +77,31 @@
background-color: var(--bg-faded-color);
color: inherit;
width: min-content;
min-width: 2em;
flex-shrink: 1 !important;
}
.status-translation-block .translated-block output {
display: block;
margin-top: 1em;
margin-top: 0.75em;
}
.status-translation-block
.translated-block
output.translated-pronunciation-content {
opacity: 0.75;
padding-bottom: 1em;
border-top: var(--hairline-width) solid var(--bg-color);
border-bottom: var(--hairline-width) solid var(--outline-color);
padding-top: 0.75em;
border-top: var(--hairline-width) solid var(--outline-color);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
mask-image: linear-gradient(to bottom, black 3em, transparent);
}
.status-translation-block
.translated-block
output.translated-pronunciation-content.expand {
display: block;
-webkit-line-clamp: unset;
-webkit-box-orient: unset;
overflow: visible;
mask-image: none;
}

View file

@ -87,7 +87,12 @@ function TranslationBlock({
}, [forceTranslate]);
return (
<div class="status-translation-block">
<div
class="status-translation-block"
onClick={(e) => {
e.preventDefault();
}}
>
<details ref={detailsRef}>
<summary>
<button
@ -134,14 +139,20 @@ function TranslationBlock({
) : (
!!translatedContent && (
<>
{!!pronunciationContent && (
<output class="translated-pronunciation-content">
{pronunciationContent}
</output>
)}
<output class="translated-content" lang={targetLang}>
{translatedContent}
</output>
{!!pronunciationContent && (
<output
class="translated-pronunciation-content"
tabIndex={-1}
onClick={(e) => {
e.target.classList.toggle('expand');
}}
>
{pronunciationContent}
</output>
)}
</>
)
)}

View file

@ -7,6 +7,7 @@ import Timeline from '../components/timeline';
import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
@ -27,6 +28,7 @@ function AccountStatuses() {
if (pinnedStatuses?.length) {
pinnedStatuses.forEach((status) => {
status._pinned = true;
saveStatus(status, instance);
});
if (pinnedStatuses.length >= 3) {
const pinnedStatusesIds = pinnedStatuses.map((status) => status.id);
@ -48,6 +50,10 @@ function AccountStatuses() {
const { value, done } = await accountStatusesIterator.current.next();
if (value?.length) {
results.push(...value);
value.forEach((item) => {
saveStatus(item, instance);
});
}
return {
value: results,
@ -118,6 +124,7 @@ function AccountStatuses() {
emptyText="Nothing to see here yet."
errorText="Unable to load statuses"
fetchItems={fetchAccountStatuses}
useItemID
boostsCarousel={snapStates.settings.boostsCarousel}
timelineStart={TimelineStart}
/>

View file

@ -1,6 +1,6 @@
import './settings.css';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { useReducer, useState } from 'preact/hooks';
import Avatar from '../components/avatar';
@ -25,11 +25,6 @@ function Accounts({ onClose }) {
<div id="settings-container" class="sheet" tabIndex="-1">
<header class="header-grid">
<h2>Accounts</h2>
<div class="header-side">
<Link to="/login" class="button plain" onClick={onClose}>
<Icon icon="plus" /> <span>Account</span>
</Link>
</div>
</header>
<main>
<section>
@ -66,7 +61,12 @@ function Accounts({ onClose }) {
account={account.info}
showAcct
onClick={() => {
states.showAccount = `${account.info.username}@${account.instanceURL}`;
if (isCurrent) {
states.showAccount = `${account.info.username}@${account.instanceURL}`;
} else {
store.session.set('currentAccount', account.info.id);
location.reload();
}
}}
/>
</div>
@ -76,18 +76,6 @@ function Accounts({ onClose }) {
<span class="tag">Default</span>{' '}
</>
)}
{!isCurrent && (
<button
type="button"
class="light"
onClick={() => {
store.session.set('currentAccount', account.info.id);
location.reload();
}}
>
<Icon icon="transfer" /> Switch
</button>
)}
<Menu
align="end"
menuButton={
@ -100,6 +88,15 @@ function Accounts({ onClose }) {
</button>
}
>
<MenuItem
onClick={() => {
states.showAccount = `${account.info.username}@${account.instanceURL}`;
}}
>
<Icon icon="user" />
<span>View profile</span>
</MenuItem>
<MenuDivider />
{moreThanOneAccount && (
<MenuItem
disabled={isDefault}
@ -127,7 +124,7 @@ function Accounts({ onClose }) {
}}
>
<Icon icon="exit" />
<span>Log out</span>
<span>Log out</span>
</MenuItem>
</Menu>
</div>
@ -135,6 +132,11 @@ function Accounts({ onClose }) {
);
})}
</ul>
<p>
<Link to="/login" class="button plain2" onClick={onClose}>
<Icon icon="plus" /> <span>Add an existing account</span>
</Link>
</p>
{moreThanOneAccount && (
<p>
<small>

View file

@ -3,8 +3,10 @@ import { useSnapshot } from 'valtio';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import { filteredItems } from '../utils/filters';
import states from '../utils/states';
import { getStatus, saveStatus } from '../utils/states';
import { dedupeBoosts } from '../utils/timeline-utils';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
@ -21,15 +23,18 @@ function Following({ title, path, id, ...props }) {
homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT });
}
const results = await homeIterator.current.next();
const { value } = results;
let { value } = results;
if (value?.length) {
if (firstLoad) {
latestItem.current = value[0].id;
console.log('First load', latestItem.current);
}
value = filteredItems(value, 'home');
value.forEach((item) => {
saveStatus(item, instance);
});
value = dedupeBoosts(value, instance);
// ENFORCE sort by datetime (Latest first)
value.sort((a, b) => {
@ -49,10 +54,15 @@ function Following({ title, path, id, ...props }) {
since_id: latestItem.current,
})
.next();
const { value } = results;
let { value } = results;
console.log('checkForUpdates', latestItem.current, value);
if (value?.length && value.some((item) => !item.reblog)) {
return true;
if (value?.length) {
latestItem.current = value[0].id;
value = dedupeBoosts(value, instance);
value = filteredItems(value, 'home');
if (value.some((item) => !item.reblog)) {
return true;
}
}
return false;
} catch (e) {
@ -119,6 +129,7 @@ function Following({ title, path, id, ...props }) {
useItemID
boostsCarousel={snapStates.settings.boostsCarousel}
{...props}
allowFilters
/>
);
}

View file

@ -13,6 +13,7 @@ import Timeline from '../components/timeline';
import { api } from '../utils/api';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
@ -52,6 +53,10 @@ function Hashtags(props) {
if (firstLoad) {
latestItem.current = value[0].id;
}
value.forEach((item) => {
saveStatus(item, instance);
});
}
return results;
}
@ -110,6 +115,7 @@ function Hashtags(props) {
errorText="Unable to load posts with this tag"
fetchItems={fetchHashtags}
checkForUpdates={checkForUpdates}
useItemID
headerEnd={
<Menu
portal={{

View file

@ -1,546 +0,0 @@
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import Status from '../components/status';
import { api } from '../utils/api';
import db from '../utils/db';
import states, { saveStatus } from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils';
import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Home({ hidden }) {
useTitle('Home', '/');
const { masto, instance } = api();
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
console.debug('RENDER Home');
const homeIterator = useRef();
async function fetchStatuses(firstLoad) {
if (firstLoad) {
// Reset iterator
homeIterator.current = masto.v1.timelines.listHome({
limit: LIMIT,
});
states.homeNew = [];
}
const allStatuses = await homeIterator.current.next();
if (allStatuses.value?.length) {
// ENFORCE sort by datetime (Latest first)
allStatuses.value.sort((a, b) => {
const aDate = new Date(a.createdAt);
const bDate = new Date(b.createdAt);
return bDate - aDate;
});
const homeValues = allStatuses.value.map((status) => {
saveStatus(status, instance);
return {
id: status.id,
reblog: status.reblog?.id,
reply: !!status.inReplyToAccountId,
};
});
// BOOSTS CAROUSEL
if (snapStates.settings.boostsCarousel) {
let specialHome = [];
let boostStash = [];
let serialBoosts = 0;
for (let i = 0; i < homeValues.length; i++) {
const status = homeValues[i];
if (status.reblog) {
boostStash.push(status);
serialBoosts++;
} else {
specialHome.push(status);
if (serialBoosts < 3) {
serialBoosts = 0;
}
}
}
// if boostStash is more than quarter of homeValues
// or if there are 3 or more boosts in a row
if (boostStash.length > homeValues.length / 4 || serialBoosts >= 3) {
// if boostStash is more than 3 quarter of homeValues
const boostStashID = boostStash.map((status) => status.id);
if (boostStash.length > (homeValues.length * 3) / 4) {
// insert boost array at the end of specialHome list
specialHome = [
...specialHome,
{ id: boostStashID, boosts: boostStash },
];
} else {
// insert boosts array in the middle of specialHome list
const half = Math.floor(specialHome.length / 2);
specialHome = [
...specialHome.slice(0, half),
{
id: boostStashID,
boosts: boostStash,
},
...specialHome.slice(half),
];
}
} else {
// Untouched, this is fine
specialHome = homeValues;
}
console.log({
specialHome,
});
if (firstLoad) {
states.homeLast = specialHome[0];
states.home = specialHome;
} else {
states.home.push(...specialHome);
}
} else {
if (firstLoad) {
states.homeLast = homeValues[0];
states.home = homeValues;
} else {
states.home.push(...homeValues);
}
}
}
states.homeLastFetchTime = Date.now();
return allStatuses;
}
const loadStatuses = useDebouncedCallback(
(firstLoad) => {
if (uiState === 'loading') return;
setUIState('loading');
(async () => {
try {
const { done } = await fetchStatuses(firstLoad);
setShowMore(!done);
setUIState('default');
} catch (e) {
console.warn(e);
setUIState('error');
} finally {
}
})();
},
1500,
{
leading: true,
trailing: false,
},
);
useEffect(() => {
loadStatuses(true);
}, []);
const scrollableRef = useRef();
const jRef = useHotkeys('j, shift+j', (_, handler) => {
// focus on next status after active status
// Traverses .timeline li .status-link, focus on .status-link
const activeStatus = document.activeElement.closest(
'.status-link, .status-boost-link',
);
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link',
),
);
if (
activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0
) {
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
let nextStatus = allStatusLinks[activeStatusIndex + 1];
if (handler.shift) {
// get next status that's not .status-boost-link
nextStatus = allStatusLinks.find(
(statusLink, index) =>
index > activeStatusIndex &&
!statusLink.classList.contains('status-boost-link'),
);
}
if (nextStatus) {
nextStatus.focus();
nextStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
const kRef = useHotkeys('k, shift+k', (_, handler) => {
// focus on previous status after active status
// Traverses .timeline li .status-link, focus on .status-link
const activeStatus = document.activeElement.closest(
'.status-link, .status-boost-link',
);
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link',
),
);
if (
activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0
) {
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
let prevStatus = allStatusLinks[activeStatusIndex - 1];
if (handler.shift) {
// get prev status that's not .status-boost-link
prevStatus = allStatusLinks.findLast(
(statusLink, index) =>
index < activeStatusIndex &&
!statusLink.classList.contains('status-boost-link'),
);
}
if (prevStatus) {
prevStatus.focus();
prevStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
const oRef = useHotkeys(['enter', 'o'], () => {
// open active status
const activeStatus = document.activeElement.closest(
'.status-link, .status-boost-link',
);
if (activeStatus) {
activeStatus.click();
}
});
const {
scrollDirection,
reachStart,
nearReachStart,
nearReachEnd,
reachEnd,
} = useScroll({
scrollableElement: scrollableRef.current,
distanceFromEnd: 3,
scrollThresholdStart: 44,
});
useEffect(() => {
if (nearReachEnd || (reachEnd && showMore)) {
loadStatuses();
}
}, [nearReachEnd, reachEnd]);
useEffect(() => {
if (reachStart) {
loadStatuses(true);
}
}, [reachStart]);
useEffect(() => {
(async () => {
const keys = await db.drafts.keys();
if (keys.length) {
const ns = getCurrentAccountNS();
const ownKeys = keys.filter((key) => key.startsWith(ns));
if (ownKeys.length) {
states.showDrafts = true;
}
}
})();
}, []);
// const showUpdatesButton = snapStates.homeNew.length > 0 && reachStart;
const [showUpdatesButton, setShowUpdatesButton] = useState(false);
useEffect(() => {
const isNewAndTop = snapStates.homeNew.length > 0 && reachStart;
console.log(
'isNewAndTop',
isNewAndTop,
snapStates.homeNew.length,
reachStart,
);
setShowUpdatesButton(isNewAndTop);
}, [snapStates.homeNew.length, reachStart]);
return (
<>
<div
id="home-page"
class="deck-container"
hidden={hidden}
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
oRef.current = node;
}}
tabIndex="-1"
>
<div class="timeline-deck deck">
<header
hidden={scrollDirection === 'end' && !nearReachStart}
onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
onDblClick={() => {
loadStatuses(true);
}}
>
<div class="header-grid">
<div class="header-side">
<button
type="button"
class="plain"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showSettings = true;
}}
>
<Icon icon="gear" size="l" alt="Settings" />
</button>
</div>
<h1>Home</h1>
<div class="header-side">
<Loader hidden={uiState !== 'loading'} />{' '}
<Link
to="/notifications"
class={`button plain ${
snapStates.notificationsNew.length > 0 ? 'has-badge' : ''
}`}
onClick={(e) => {
e.stopPropagation();
}}
>
<Icon icon="notification" size="l" alt="Notifications" />
</Link>
</div>
</div>
{snapStates.homeNew.length > 0 &&
uiState !== 'loading' &&
((scrollDirection === 'start' &&
!nearReachStart &&
!nearReachEnd) ||
showUpdatesButton) && (
<button
class="updates-button"
type="button"
onClick={() => {
if (!snapStates.settings.boostsCarousel) {
const uniqueHomeNew = snapStates.homeNew.filter(
(status) =>
!states.home.some((s) => s.id === status.id),
);
states.home.unshift(...uniqueHomeNew);
}
loadStatuses(true);
states.homeNew = [];
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New posts
</button>
)}
</header>
{snapStates.home.length ? (
<>
<ul class="timeline">
{snapStates.home.map(({ id: statusID, reblog, boosts }) => {
const actualStatusID = reblog || statusID;
if (boosts) {
return (
<li key={statusID}>
<BoostsCarousel boosts={boosts} />
</li>
);
}
return (
<li key={statusID}>
<Link class="status-link" to={`/s/${actualStatusID}`}>
<Status statusID={statusID} />
</Link>
</li>
);
})}
{showMore && uiState === 'loading' && (
<>
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
)}
</ul>
{uiState === 'default' &&
(showMore ? (
<button
type="button"
class="plain block"
onClick={() => loadStatuses()}
style={{ marginBlockEnd: '6em' }}
>
Show more&hellip;
</button>
) : (
<p class="ui-state insignificant">The end.</p>
))}
</>
) : uiState === 'loading' ? (
<ul class="timeline">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i}>
<Status skeleton />
</li>
))}
</ul>
) : (
uiState !== 'error' && <p class="ui-state">Nothing to see here.</p>
)}
{uiState === 'error' && (
<p class="ui-state">
Unable to load statuses
<br />
<br />
<button
type="button"
onClick={() => {
loadStatuses(true);
}}
>
Try again
</button>
</p>
)}
</div>
</div>
<button
hidden={scrollDirection === 'end' && !nearReachStart}
type="button"
id="compose-button"
onClick={(e) => {
if (e.shiftKey) {
const newWin = openCompose();
if (!newWin) {
alert('Looks like your browser is blocking popups.');
states.showCompose = true;
}
} else {
states.showCompose = true;
}
}}
>
<Icon icon="quill" size="xxl" alt="Compose" />
</button>
</>
);
}
function BoostsCarousel({ boosts }) {
const carouselRef = useRef();
const { reachStart, reachEnd, init } = useScroll({
scrollableElement: carouselRef.current,
direction: 'horizontal',
});
useEffect(() => {
init?.();
}, []);
return (
<div class="boost-carousel">
<header>
<h3>{boosts.length} Boosts</h3>
<span>
<button
type="button"
class="small plain2"
disabled={reachStart}
onClick={() => {
carouselRef.current?.scrollBy({
left: -Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth',
});
}}
>
<Icon icon="chevron-left" />
</button>{' '}
<button
type="button"
class="small plain2"
disabled={reachEnd}
onClick={() => {
carouselRef.current?.scrollBy({
left: Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth',
});
}}
>
<Icon icon="chevron-right" />
</button>
</span>
</header>
<ul ref={carouselRef}>
{boosts.map((boost) => {
const { id: statusID, reblog } = boost;
const actualStatusID = reblog || statusID;
return (
<li key={statusID}>
<Link class="status-boost-link" to={`/s/${actualStatusID}`}>
<Status statusID={statusID} size="s" />
</Link>
</li>
);
})}
</ul>
</div>
);
}
export default memo(Home);

View file

@ -5,6 +5,8 @@ import Icon from '../components/icon';
import Link from '../components/link';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import { filteredItems } from '../utils/filters';
import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
@ -22,11 +24,16 @@ function List(props) {
});
}
const results = await listIterator.current.next();
const { value } = results;
let { value } = results;
if (value?.length) {
if (firstLoad) {
latestItem.current = value[0].id;
}
value = filteredItems(value, 'home');
value.forEach((item) => {
saveStatus(item, instance);
});
}
return results;
}
@ -37,7 +44,8 @@ function List(props) {
limit: 1,
since_id: latestItem.current,
});
const { value } = results;
let { value } = results;
value = filteredItems(value, 'home');
if (value?.length) {
return true;
}
@ -69,7 +77,9 @@ function List(props) {
instance={instance}
fetchItems={fetchList}
checkForUpdates={checkForUpdates}
useItemID
boostsCarousel
allowFilters
headerStart={
<Link to="/l" class="button plain">
<Icon icon="list" size="l" />

View file

@ -4,7 +4,7 @@
gap: 12px;
animation: appear 0.2s ease-out;
}
.notification.mention {
.notification.notification-mention {
margin-top: 16px;
}
.only-mentions .notification:not(.mention),

View file

@ -318,7 +318,7 @@ function Notification({ notification, instance }) {
: contentText[type];
return (
<div class={`notification ${type}`} tabIndex="0">
<div class={`notification notification-${type}`} tabIndex="0">
<div
class={`notification-type notification-${type}`}
title={new Date(notification.createdAt).toLocaleString()}

View file

@ -6,7 +6,9 @@ import { useSnapshot } from 'valtio';
import Icon from '../components/icon';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import { filteredItems } from '../utils/filters';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
@ -32,11 +34,16 @@ function Public({ local, ...props }) {
});
}
const results = await publicIterator.current.next();
const { value } = results;
let { value } = results;
if (value?.length) {
if (firstLoad) {
latestItem.current = value[0].id;
}
value = filteredItems(value, 'public');
value.forEach((item) => {
saveStatus(item, instance);
});
}
return results;
}
@ -50,7 +57,8 @@ function Public({ local, ...props }) {
since_id: latestItem.current,
})
.next();
const { value } = results;
let { value } = results;
value = filteredItems(value, 'public');
if (value?.length) {
return true;
}
@ -76,8 +84,10 @@ function Public({ local, ...props }) {
errorText="Unable to load posts"
fetchItems={fetchPublic}
checkForUpdates={checkForUpdates}
useItemID
headerStart={<></>}
boostsCarousel={snapStates.settings.boostsCarousel}
allowFilters
headerEnd={
<Menu
portal={{

View file

@ -123,3 +123,22 @@
#settings-container .range-group input[type='range'] {
flex-grow: 1;
}
#settings-container .checkbox-fields {
border: 1px solid var(--outline-color);
background-color: var(--bg-faded-color);
border-radius: 8px;
margin: 8px 0;
max-height: 6.5em;
overflow: auto;
display: flex;
flex-wrap: wrap;
font-size: 90%;
}
#settings-container .checkbox-fieldset label {
flex: 1 0 10em;
padding: 4px;
display: flex;
gap: 4px;
align-items: flex-start;
}

View file

@ -194,6 +194,50 @@ function Settings({ onClose }) {
))}
</select>
</label>
<p class="checkbox-fieldset">
<small>
Hide "Translate" button for
{snapStates.settings.contentTranslationHideLanguages
.length > 0 && (
<>
{' '}
(
{
snapStates.settings.contentTranslationHideLanguages
.length
}
)
</>
)}
:
</small>
<div class="checkbox-fields">
{targetLanguages.map((lang) => (
<label>
<input
type="checkbox"
checked={snapStates.settings.contentTranslationHideLanguages.includes(
lang.code,
)}
onChange={(e) => {
const { checked } = e.target;
if (checked) {
states.settings.contentTranslationHideLanguages.push(
lang.code,
);
} else {
states.settings.contentTranslationHideLanguages =
snapStates.settings.contentTranslationHideLanguages.filter(
(code) => code !== lang.code,
);
}
}}
/>{' '}
{lang.name}
</label>
))}
</div>
</p>
<p>
<small>
Note: This feature uses an external API to translate,

View file

@ -1,6 +1,6 @@
import './status.css';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { Menu, MenuDivider, MenuHeader, MenuItem } from '@szhsin/react-menu';
import debounce from 'just-debounce-it';
import pRetry from 'p-retry';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
@ -25,6 +25,7 @@ import states, {
statusKey,
threadifyStatus,
} from '../utils/states';
import statusPeek from '../utils/status-peek';
import { getCurrentAccount } from '../utils/store-utils';
import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
@ -32,6 +33,7 @@ import useTitle from '../utils/useTitle';
const LIMIT = 40;
const THREAD_LIMIT = 20;
let cachedRepliesToggle = {};
let cachedStatusesMap = {};
function resetScrollPosition(id) {
delete cachedStatusesMap[id];
@ -168,6 +170,16 @@ function StatusPage() {
console.log({ ancestors, descendants, nestedDescendants });
function expandReplies(_replies) {
return _replies?.map((_r) => ({
id: _r.id,
account: _r.account,
repliesCount: _r.repliesCount,
content: _r.content,
replies: expandReplies(_r.__replies),
}));
}
const allStatuses = [
...ancestors.map((s) => ({
id: s.id,
@ -180,26 +192,7 @@ function StatusPage() {
accountID: s.account.id,
descendant: true,
thread: s.account.id === heroStatus.account.id,
replies: s.__replies?.map((r) => ({
id: r.id,
account: r.account,
repliesCount: r.repliesCount,
content: r.content,
replies: r.__replies?.map((r2) => ({
// Level 3
id: r2.id,
account: r2.account,
repliesCount: r2.repliesCount,
content: r2.content,
replies: r2.__replies?.map((r3) => ({
// Level 4
id: r3.id,
account: r3.account,
repliesCount: r3.repliesCount,
content: r3.content,
})),
})),
})),
replies: expandReplies(s.__replies),
})),
];
@ -292,6 +285,7 @@ function StatusPage() {
states.scrollPositions = {};
states.reloadStatusPage = 0;
cachedStatusesMap = {};
cachedRepliesToggle = {};
};
}, []);
@ -306,15 +300,7 @@ function StatusPage() {
}, [heroStatus]);
const heroContentText = useMemo(() => {
if (!heroStatus) return '';
const { spoilerText, content } = heroStatus;
let text;
if (spoilerText) {
text = spoilerText;
} else {
const div = document.createElement('div');
div.innerHTML = content;
text = div.innerText.trim();
}
let text = statusPeek(heroStatus);
if (text.length > 64) {
// "The title should ideally be less than 64 characters in length"
// https://www.w3.org/Provider/Style/TITLE.html
@ -329,6 +315,17 @@ function StatusPage() {
'/:instance?/s/:id',
);
const postInstance = useMemo(() => {
if (!heroStatus) return;
const { url } = heroStatus;
if (!url) return;
return new URL(url).hostname;
}, [heroStatus]);
const postSameInstance = useMemo(() => {
if (!postInstance) return;
return postInstance === instance;
}, [postInstance, instance]);
const closeLink = useMemo(() => {
const { prevLocation } = snapStates;
const pathname =
@ -562,6 +559,15 @@ function StatusPage() {
</button>
}
>
<MenuItem
disabled={uiState === 'loading'}
onClick={() => {
states.reloadStatusPage++;
}}
>
<Icon icon="refresh" />
<span>Refresh</span>
</MenuItem>
<MenuItem
onClick={() => {
// Click all buttons with class .spoiler but not .spoiling
@ -578,6 +584,24 @@ function StatusPage() {
<Icon icon="eye-open" />{' '}
<span>Show all sensitive content</span>
</MenuItem>
<MenuDivider />
<MenuHeader className="plain">Experimental</MenuHeader>
<MenuItem
disabled={postSameInstance}
onClick={() => {
const statusURL = getInstanceStatusURL(heroStatus.url);
if (statusURL) {
navigate(statusURL);
} else {
alert('Unable to switch');
}
}}
>
<Icon icon="transfer" />
<small class="menu-double-lines">
Switch to post's instance (<b>{postInstance}</b>)
</small>
</MenuItem>
</Menu>
)}
<Link
@ -721,6 +745,7 @@ function StatusPage() {
hasManyStatuses={hasManyStatuses}
replies={replies}
hasParentThread={thread}
level={1}
/>
)}
{uiState === 'loading' &&
@ -800,7 +825,13 @@ function StatusPage() {
);
}
function SubComments({ hasManyStatuses, replies, instance, hasParentThread }) {
function SubComments({
hasManyStatuses,
replies,
instance,
hasParentThread,
level,
}) {
// Set isBrief = true:
// - if less than or 2 replies
// - if replies have no sub-replies
@ -839,10 +870,24 @@ function SubComments({ hasManyStatuses, replies, instance, hasParentThread }) {
const open =
(!hasParentThread || replies.length === 1) && (isBrief || !hasManyStatuses);
const openBefore = cachedRepliesToggle[replies[0].id];
return (
<details class="replies" open={open}>
<summary hidden={open}>
<details
class="replies"
open={openBefore || open}
onToggle={(e) => {
const { open } = e.target;
// use first reply as ID
cachedRepliesToggle[replies[0].id] = open;
}}
style={{
'--comments-level': level,
}}
data-comments-level={level}
data-comments-level-overflow={level > 4}
>
<summary class="replies-summary" hidden={open}>
<span class="avatars">
{accounts.map((a) => (
<Avatar
@ -900,6 +945,7 @@ function SubComments({ hasManyStatuses, replies, instance, hasParentThread }) {
instance={instance}
hasManyStatuses={hasManyStatuses}
replies={r.replies}
level={level + 1}
/>
)}
</li>
@ -909,4 +955,14 @@ function SubComments({ hasManyStatuses, replies, instance, hasParentThread }) {
);
}
function getInstanceStatusURL(url) {
// Regex /:username/:id, where username = @username or @username@domain, id = anything
const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/([^\/]+)\/?$/i;
const { hostname, pathname } = new URL(url);
const [, username, domain, id] = pathname.match(statusRegex) || [];
if (id) {
return `/${hostname}/s/${id}`;
}
}
export default StatusPage;

36
src/utils/filters.jsx Normal file
View file

@ -0,0 +1,36 @@
import store from './store';
export function filteredItem(item, filterContext, currentAccountID) {
const { filtered } = item;
if (!filtered?.length) return true;
const isSelf = currentAccountID && item.account?.id === currentAccountID;
if (isSelf) return true;
const appliedFilters = filtered.filter((f) => {
const { filter } = f;
const hasContext = filter.context.includes(filterContext);
if (!hasContext) return false;
if (!filter.expiresAt) return hasContext;
return new Date(filter.expiresAt) > new Date();
});
if (!appliedFilters.length) return true;
const isHidden = appliedFilters.some((f) => f.filter.filterAction === 'hide');
console.log({ isHidden, filtered, appliedFilters, item });
if (isHidden) return false;
const isWarn = appliedFilters.some((f) => f.filter.filterAction === 'warn');
if (isWarn) {
const filterTitles = appliedFilters.map((f) => f.filter.title);
item._filtered = {
titles: filterTitles,
titlesStr: filterTitles.join(' • '),
};
}
return isWarn;
}
export function filteredItems(items, filterContext) {
if (!items?.length) return [];
if (!filterContext) return items;
const currentAccountID = store.session.get('currentAccount');
return items.filter((item) =>
filteredItem(item, filterContext, currentAccountID),
);
}

13
src/utils/getHTMLText.jsx Normal file
View file

@ -0,0 +1,13 @@
const div = document.createElement('div');
function getHTMLText(html) {
if (!html) return '';
div.innerHTML = html
.replace(/<\/p>/g, '</p>\n\n')
.replace(/<\/li>/g, '</li>\n');
div.querySelectorAll('br').forEach((br) => {
br.replaceWith('\n');
});
return div.innerText.replace(/[\r\n]{3,}/g, '\n\n').trim();
}
export default getHTMLText;

View file

@ -48,6 +48,8 @@ const states = proxy({
store.account.get('settings-contentTranslation') ?? true,
contentTranslationTargetLanguage:
store.account.get('settings-contentTranslationTargetLanguage') || null,
contentTranslationHideLanguages:
store.account.get('settings-contentTranslationHideLanguages') || [],
},
});
@ -76,6 +78,12 @@ subscribe(states, (changes) => {
console.log('SET', value);
store.account.set('settings-contentTranslationTargetLanguage', value);
}
if (/^settings\.contentTranslationHideLanguages/i.test(path.join('.'))) {
store.account.set(
'settings-contentTranslationHideLanguages',
states.settings.contentTranslationHideLanguages,
);
}
if (path?.[0] === 'shortcuts') {
store.account.set('shortcuts', states.shortcuts);
}
@ -114,8 +122,11 @@ export function saveStatus(status, instance, opts) {
opts,
);
if (!status) return;
if (!override && getStatus(status.id)) return;
const oldStatus = getStatus(status.id, instance);
if (!override && oldStatus) return;
const key = statusKey(status.id, instance);
if (oldStatus?._pinned) status._pinned = oldStatus._pinned;
if (oldStatus?._filtered) status._filtered = oldStatus._filtered;
states.statuses[key] = status;
if (status.reblog) {
const key = statusKey(status.reblog.id, instance);

34
src/utils/status-peek.jsx Normal file
View file

@ -0,0 +1,34 @@
import getHTMLText from './getHTMLText';
function statusPeek(status) {
const { spoilerText, content, poll, mediaAttachments } = status;
let text = '';
if (spoilerText?.trim()) {
text += spoilerText;
} else {
text += getHTMLText(content);
}
text = text.trim();
if (poll) {
text += ' 📊';
}
if (mediaAttachments?.length) {
text +=
' ' +
mediaAttachments
.map(
(m) =>
({
image: '🖼️',
gifv: '🎞️',
video: '📹',
audio: '🎵',
unknown: '',
}[m.type] || ''),
)
.join('');
}
return text;
}
export default statusPeek;

View file

@ -0,0 +1,123 @@
import { getStatus } from './states';
export function groupBoosts(values) {
let newValues = [];
let boostStash = [];
let serialBoosts = 0;
for (let i = 0; i < values.length; i++) {
const item = values[i];
if (item.reblog) {
boostStash.push(item);
serialBoosts++;
} else {
newValues.push(item);
if (serialBoosts < 3) {
serialBoosts = 0;
}
}
}
// if boostStash is more than quarter of values
// or if there are 3 or more boosts in a row
if (boostStash.length > values.length / 4 || serialBoosts >= 3) {
// if boostStash is more than 3 quarter of values
const boostStashID = boostStash.map((status) => status.id);
if (boostStash.length > (values.length * 3) / 4) {
// insert boost array at the end of specialHome list
newValues = [
...newValues,
{ id: boostStashID, items: boostStash, type: 'boosts' },
];
} else {
// insert boosts array in the middle of specialHome list
const half = Math.floor(newValues.length / 2);
newValues = [
...newValues.slice(0, half),
{
id: boostStashID,
items: boostStash,
type: 'boosts',
},
...newValues.slice(half),
];
}
return newValues;
} else {
return values;
}
}
export function dedupeBoosts(items, instance) {
return items.filter((item) => {
if (!item.reblog) return true;
const s = getStatus(item.reblog.id, instance);
if (s) {
console.warn(
`🚫 Duplicate boost by ${item.account.displayName}`,
item,
s,
);
return false;
}
const s2 = getStatus(item.id, instance);
if (s2) {
console.warn('🚫 Re-boosted boost', item);
return false;
}
return true;
});
}
export function groupContext(items) {
const contexts = [];
let contextIndex = 0;
items.forEach((item) => {
for (let i = 0; i < contexts.length; i++) {
if (contexts[i].find((t) => t.id === item.id)) return;
if (
contexts[i].find((t) => t.id === item.inReplyToId) ||
contexts[i].find((t) => t.inReplyToId === item.id)
) {
contexts[i].push(item);
return;
}
}
const repliedItem = items.find((i) => i.id === item.inReplyToId);
if (repliedItem) {
contexts[contextIndex++] = [item, repliedItem];
}
});
if (contexts.length) console.log('🧵 Contexts', contexts);
const newItems = [];
const appliedContextIndices = [];
items.forEach((item) => {
if (item.reblog) {
newItems.push(item);
return;
}
for (let i = 0; i < contexts.length; i++) {
if (contexts[i].find((t) => t.id === item.id)) {
if (appliedContextIndices.includes(i)) return;
const contextItems = contexts[i];
contextItems.sort((a, b) => {
const aDate = new Date(a.createdAt);
const bDate = new Date(b.createdAt);
return aDate - bDate;
});
const firstItemAccountID = contextItems[0].account.id;
newItems.push({
id: contextItems.map((i) => i.id),
items: contextItems,
type: contextItems.every((it) => it.account.id === firstItemAccountID)
? 'thread'
: 'conversation',
});
appliedContextIndices.push(i);
return;
}
}
newItems.push(item);
});
return newItems;
}

View file

@ -5,7 +5,6 @@ import { resolve } from 'path';
import { defineConfig, loadEnv, splitVendorChunkPlugin } from 'vite';
import generateFile from 'vite-plugin-generate-file';
import htmlPlugin from 'vite-plugin-html-config';
import VitePluginHtmlEnv from 'vite-plugin-html-env';
import { VitePWA } from 'vite-plugin-pwa';
import removeConsole from 'vite-plugin-remove-console';
@ -31,7 +30,6 @@ export default defineConfig({
plugins: [
preact(),
splitVendorChunkPlugin(),
VitePluginHtmlEnv(),
removeConsole({
includes: ['log', 'debug', 'info', 'warn', 'error'],
}),
@ -88,6 +86,7 @@ export default defineConfig({
build: {
sourcemap: true,
rollupOptions: {
treeshake: false,
input: {
main: resolve(__dirname, 'index.html'),
compose: resolve(__dirname, 'compose/index.html'),