Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Natsu Kagami 2023-09-10 23:31:21 +02:00
commit 8937945e49
Signed by: nki
GPG key ID: 55A032EB38B49ADB
65 changed files with 3199 additions and 1343 deletions

16
.github/workflows/prodtag.yml vendored Normal file
View file

@ -0,0 +1,16 @@
name: Auto-create tag on every push to `production`
on:
push:
branches:
- production
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: production
- run: git tag -a "'{date +%Y.%m.%d}.{git rev-parse --short HEAD}'" $(git rev-parse HEAD)
- run: git push

1
.gitignore vendored
View file

@ -25,6 +25,7 @@ dist-ssr
# Custom
.env.dev
src/data/instances-full.json
# Nix
.direnv

View file

@ -78,9 +78,11 @@ Everything is designed and engineered following my taste and vision. This is a p
![Hashtag stuffing collapsing](readme-assets/hashtag-stuffing-collapsing.jpg)
- Any paragraphs, except the first one, with more than 3 hashtags will be collapsed.
- First paragraph of post content with more than 3 hashtags will be collapsed to max 3 lines.
- Subsequent paragraphs after first paragraph with more than 3 hashtags will be collapsed to 1 line.
- Adjacent paragraphs with more than 1 hashtag after collapsed paragraphs will be collapsed to 1 line.
- If there are text around or between the hashtags, they will not be collapsed.
- Collapsed hashtags will be a single line with `...` at the end.
- Collapsed hashtags will be appended with `...` at the end.
- They are also slightly faded out to reduce visual noise.
- Opening the post view will reveal the hashtags uncollapsed.
@ -107,7 +109,7 @@ Prerequisites: Node.js 18+
## Self-hosting
This is a **pure static web app**. You can host it anywhere you want. Build it by running `npm run build` and serve the `dist` folder.
This is a **pure static web app**. You can host it anywhere you want. Build it by running `npm run build` (after `npm install`) and serve the `dist` folder.
Try search for "how to self-host static sites" as there are many ways to do it.
@ -123,6 +125,14 @@ Try search for "how to self-host static sites" as there are many ways to do it.
Some of these may change in the future. The front-end world is ever-changing.
## Costs
Costs involved in running and developing this web app:
- Domain name (.social): **USD$23.18/year** (USD$6.87 1st year)
- Hosting: Free
- Development, design, maintenance: "Free" (My precious time)
## Mascot
[Phanpy](https://bulbapedia.bulbagarden.net/wiki/Phanpy_(Pok%C3%A9mon)) is a Ground-type Pokémon.
@ -145,7 +155,9 @@ And here I am. Building a Mastodon web client.
## Alternative web clients
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) → [Semaphore](https://semaphore.social/)
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/))
- [Semaphore](https://semaphore.social/)
- [Enafore](https://pinafore.easrng.net/)
- [Cuckoo+](https://www.cuckoo.social/)
- [Sengi](https://nicolasconstant.github.io/sengi/)
- [Soapbox](https://fe.soapbox.pub/)
@ -156,6 +168,7 @@ And here I am. Building a Mastodon web client.
- [Tooty](https://github.com/n1k0/tooty)
- [Litterbox](https://litterbox.koyu.space/)
- [Statuzer](https://statuzer.com/)
- [Tusked](https://tusked.app/)
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
## 💁‍♂️ Notice to all other social media client developers

432
package-lock.json generated
View file

@ -8,12 +8,12 @@
"name": "phanpy",
"version": "0.1.0",
"dependencies": {
"@formatjs/intl-localematcher": "~0.4.0",
"@formatjs/intl-localematcher": "~0.4.1",
"@github/text-expander-element": "~2.5.0",
"@iconify-icons/mingcute": "~1.2.7",
"@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~4.0.3",
"@uidotdev/usehooks": "~2.1.0",
"@uidotdev/usehooks": "~2.2.0",
"dayjs": "~1.11.9",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
@ -25,7 +25,7 @@
"mem": "~9.0.2",
"p-retry": "~5.1.2",
"p-throttle": "~5.1.0",
"preact": "~10.16.0",
"preact": "~10.17.1",
"react-hotkeys-hook": "~4.4.1",
"react-intersection-observer": "~9.5.2",
"react-quick-pinch-zoom": "~4.9.0",
@ -35,21 +35,21 @@
"toastify-js": "~1.12.0",
"uid": "~2.0.2",
"use-debounce": "~9.0.4",
"use-long-press": "~3.1.5",
"use-long-press": "~3.2.0",
"use-resize-observer": "~9.1.0",
"valtio": "1.9.0"
},
"devDependencies": {
"@preact/preset-vite": "~2.5.0",
"@trivago/prettier-plugin-sort-imports": "~4.2.0",
"postcss": "~8.4.27",
"postcss-dark-theme-class": "~0.8.0",
"postcss-preset-env": "~9.1.1",
"postcss": "~8.4.29",
"postcss-dark-theme-class": "~1.0.0",
"postcss-preset-env": "~9.1.3",
"twitter-text": "~3.1.0",
"vite": "~4.4.9",
"vite-plugin-generate-file": "~0.0.4",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.16.4",
"vite-plugin-pwa": "~0.16.5",
"vite-plugin-remove-console": "~2.1.1",
"workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.0.0",
@ -1856,9 +1856,9 @@
}
},
"node_modules/@csstools/color-helpers": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-3.0.0.tgz",
"integrity": "sha512-rBODd1rY01QcenD34QxbQxLc1g+Uh7z1X/uzTHNQzJUnFCT9/EZYI7KWq+j0YfWMXJsRJ8lVkqBcB0R/qLr+yg==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-3.0.2.tgz",
"integrity": "sha512-NMVs/l7Y9eIKL5XjbCHEgGcG8LOUT2qVcRjX6EzkCdlvftHVKr2tHIPzHavfrULRZ5Q2gxrJ9f44dAlj6fX97Q==",
"dev": true,
"funding": [
{
@ -1898,9 +1898,9 @@
}
},
"node_modules/@csstools/css-color-parser": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-1.2.3.tgz",
"integrity": "sha512-YaEnCoPTdhE4lPQFH3dU4IEk8S+yCnxS88wMv45JzlnMfZp57hpqA6qf2gX8uv7IJTJ/43u6pTQmhy7hCjlz7g==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-1.3.1.tgz",
"integrity": "sha512-cehc/DQCyb4hL4fspvyL7WiY+uAy8Iuaz0yTyndC/AyBmxkNpgtSgCSsr0aR4vkaSFVZfNNVlKbjHFwOsPGB1Q==",
"dev": true,
"funding": [
{
@ -1913,7 +1913,7 @@
}
],
"dependencies": {
"@csstools/color-helpers": "^3.0.0",
"@csstools/color-helpers": "^3.0.2",
"@csstools/css-calc": "^1.1.3"
},
"engines": {
@ -2015,9 +2015,9 @@
}
},
"node_modules/@csstools/postcss-color-function": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-3.0.1.tgz",
"integrity": "sha512-+vrvCQeUifpMeyd42VQ3JPWGQ8cO19+TnGbtfq1SDSgZzRapCQO4aK9h/jhMOKPnxGzbA57oS0aHgP/12N9gSQ==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-3.0.3.tgz",
"integrity": "sha512-5oNUbO89SX7BuSB0ZiUxDaQt4R2K3A+RQZlxNHOvghZJO/UqgomLPII6JkgrywLQ0Y4JDzbyNuwr0OKo2v0RsQ==",
"dev": true,
"funding": [
{
@ -2030,7 +2030,7 @@
}
],
"dependencies": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
"@csstools/postcss-progressive-custom-properties": "^3.0.0"
@ -2043,9 +2043,9 @@
}
},
"node_modules/@csstools/postcss-color-mix-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-2.0.1.tgz",
"integrity": "sha512-Z5cXkLiccKIVcUTe+fAfjUD7ZUv0j8rq3dSoBpM6I49dcw+50318eYrwUZa3nyb4xNx7ntNNUPmesAc87kPE2Q==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-2.0.3.tgz",
"integrity": "sha512-q/fv8pdRR07GAJTvemXbQ02hwVGmVcOjBJj7+gnlGrAVwSzrPEsJc8zM/EzoqVJTZtm/DwG6TWu+VJIxVpyUBg==",
"dev": true,
"funding": [
{
@ -2058,7 +2058,7 @@
}
],
"dependencies": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
"@csstools/postcss-progressive-custom-properties": "^3.0.0"
@ -2123,9 +2123,9 @@
}
},
"node_modules/@csstools/postcss-gradients-interpolation-method": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-4.0.1.tgz",
"integrity": "sha512-IHeFIcksjI8xKX7PWLzAyigM3UvJdZ4btejeNa7y/wXxqD5dyPPZuY55y8HGTrS6ETVTRqfIznoCPtTzIX7ygQ==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-4.0.3.tgz",
"integrity": "sha512-dEK3WbajX538Zu3lPMtBPAO1pooR7zslJ1mDrWKQzlwQczls3fEz+tlRhd7KWMMlsoIwNGMIGq2W/GqEErDjkg==",
"dev": true,
"funding": [
{
@ -2138,7 +2138,7 @@
}
],
"dependencies": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
"@csstools/postcss-progressive-custom-properties": "^3.0.0"
@ -2151,9 +2151,9 @@
}
},
"node_modules/@csstools/postcss-hwb-function": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-3.0.1.tgz",
"integrity": "sha512-FYe2K8EOYlL1BUm2HTXVBo6bWAj0xl4khOk6EFhQHy/C5p3rlr8OcetzQuwMeNQ3v25nB06QTgqUHoOUwoEqhA==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-3.0.3.tgz",
"integrity": "sha512-2TqrRD8JzSwQCRKKNc9BFhSEmsz+mR3RtwSw5mQSGILC+LIYCVWeYwC33cI+saFWv0DGZ0NXLx5VSX2tdJyU6w==",
"dev": true,
"funding": [
{
@ -2166,7 +2166,7 @@
}
],
"dependencies": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0"
},
@ -2204,9 +2204,9 @@
}
},
"node_modules/@csstools/postcss-is-pseudo-class": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-4.0.0.tgz",
"integrity": "sha512-0I6siRcDymG3RrkNTSvHDMxTQ6mDyYE8awkcaHNgtYacd43msl+4ZWDfQ1yZQ/viczVWjqJkLmPiRHSgxn5nZA==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-4.0.1.tgz",
"integrity": "sha512-KJGLbjjjg+mdNclLyCfsZaJS4xCaRaxKAnmWKpIp1FarEem3ZdoOxTlIELwvlE5BVg1t3QTmp0+DPSlLTTFMhA==",
"dev": true,
"funding": [
{
@ -2407,9 +2407,9 @@
}
},
"node_modules/@csstools/postcss-oklab-function": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-3.0.1.tgz",
"integrity": "sha512-3TIz+dCPlQPzz4yAEYXchUpfuU2gRYK4u1J+1xatNX85Isg4V+IbLyppblWLV4Vb6npFF8qsHN17rNuxOIy/6w==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-3.0.3.tgz",
"integrity": "sha512-8Wdpmy8mvVyHsToJkrnNpwpAgqCPNpQMLNqkR62smbEuFcmRHEiDnb0OlkKjErzmiBMr7vjZAQ6e2lA9oVguQQ==",
"dev": true,
"funding": [
{
@ -2422,7 +2422,7 @@
}
],
"dependencies": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
"@csstools/postcss-progressive-custom-properties": "^3.0.0"
@ -2460,9 +2460,9 @@
}
},
"node_modules/@csstools/postcss-relative-color-syntax": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-2.0.1.tgz",
"integrity": "sha512-9B8br/7q0bjD1fV3yE22izjc7Oy5hDbDgwdFEz207cdJHYC9yQneJzP3H+/w3RgC7uyfEVhyyhkGRx5YAfJtmg==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-2.0.3.tgz",
"integrity": "sha512-9MOzad5i0fnkOI6qXzcznuyGhLYARBkR8wDsyqbANkZ20srHJZ6PAy44g5eNw3+B7yvslUK4hx9ehnbbI9x4rw==",
"dev": true,
"funding": [
{
@ -2475,7 +2475,7 @@
}
],
"dependencies": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
"@csstools/postcss-progressive-custom-properties": "^3.0.0"
@ -2540,9 +2540,9 @@
}
},
"node_modules/@csstools/postcss-text-decoration-shorthand": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-3.0.0.tgz",
"integrity": "sha512-BAa1MIMJmEZlJ+UkPrkyoz3DC7kLlIl2oDya5yXgvUrelpwxddgz8iMp69qBStdXwuMyfPx46oZcSNx8Z0T2eA==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-3.0.2.tgz",
"integrity": "sha512-vO2onX7/TPU3LMrSvg+FhMxTujhU+LELP9zln7SiB5BJqZi+y/ZOJZRBHFvCfM9J1lnNkskMN96bP5g3yg7Jmw==",
"dev": true,
"funding": [
{
@ -2555,7 +2555,7 @@
}
],
"dependencies": {
"@csstools/color-helpers": "^3.0.0",
"@csstools/color-helpers": "^3.0.2",
"postcss-value-parser": "^4.2.0"
},
"engines": {
@ -2989,9 +2989,9 @@
}
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.4.0.tgz",
"integrity": "sha512-bRTd+rKomvfdS4QDlVJ6TA/Jx1F2h/TBVO5LjvhQ7QPPHp19oPNMIum7W2CMEReq/zPxpmCeB31F9+5gl/qtvw==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.4.1.tgz",
"integrity": "sha512-Fs4MhhHlLC0RrspX2u2KP7zlwL9eHrBZsOBxaPOeqrCZYLaOUK4cYXQ1ErpAB0HnGV/GUXNa5smzV/7jCuRzxg==",
"dependencies": {
"tslib": "^2.4.0"
}
@ -3396,9 +3396,9 @@
"dev": true
},
"node_modules/@uidotdev/usehooks": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.1.0.tgz",
"integrity": "sha512-D7SJiNQC1BOHgtE2dy2KvOtnRNaLWTFFHvcBLg7lZ8Jz7YcimxdUY3spqpvf/mVkGCuUHee8i/79p5vVkBgsYQ==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.2.0.tgz",
"integrity": "sha512-K4tirEf3lvEgi/Z6Pv2aNR8r6YCobfg0N5AOkn3hhuWtGS8n7mMSL7RGFpE2Bj56oeLGGZXrVKteEwg4vGitGA==",
"engines": {
"node": ">=16"
},
@ -3590,9 +3590,9 @@
}
},
"node_modules/autoprefixer": {
"version": "10.4.14",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz",
"integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==",
"version": "10.4.15",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz",
"integrity": "sha512-KCuPB8ZCIqFdA4HwKXsvz7j6gvSDNhDP7WnUjBleRkKjPdvCmHFuQ77ocavI8FT6NdvlBnE2UFr2H4Mycn8Vew==",
"dev": true,
"funding": [
{
@ -3602,11 +3602,15 @@
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"browserslist": "^4.21.5",
"caniuse-lite": "^1.0.30001464",
"browserslist": "^4.21.10",
"caniuse-lite": "^1.0.30001520",
"fraction.js": "^4.2.0",
"normalize-range": "^0.1.2",
"picocolors": "^1.0.0",
@ -3782,9 +3786,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001519",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001519.tgz",
"integrity": "sha512-0QHgqR+Jv4bxHMp8kZ1Kn8CH55OikjKJ6JmKkZYP1F3D7w+lnFXF70nG5eNfsZS89jadi5Ywy5UCSKLAglIRkg==",
"version": "1.0.30001532",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001532.tgz",
"integrity": "sha512-FbDFnNat3nMnrROzqrsg314zhqN5LGQ1kyyMk2opcrwGbVGpHRhgCWtAgD5YJUqNAiQ+dklreil/c3Qf1dfCTw==",
"dev": true,
"funding": [
{
@ -4020,9 +4024,9 @@
}
},
"node_modules/cssdb": {
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.7.0.tgz",
"integrity": "sha512-1hN+I3r4VqSNQ+OmMXxYexnumbOONkSil0TWMebVXHtzYW4tRRPovUNHPHj2d4nrgOuYJ8Vs3XwvywsuwwXNNA==",
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.7.2.tgz",
"integrity": "sha512-pQPYP7/kch4QlkTcLuUNiNL2v/E+O+VIdotT+ug62/+2B2/jkzs5fMM6RHCzGCZ9C82pODEMSIzRRUzJOrl78g==",
"dev": true,
"funding": [
{
@ -4318,9 +4322,9 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-glob": {
"version": "3.2.12",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
"integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
"dev": true,
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@ -4340,9 +4344,9 @@
"dev": true
},
"node_modules/fastq": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.14.0.tgz",
"integrity": "sha512-eR2D+V9/ExcbF9ls441yIuN6TI2ED1Y2ZcA5BmMtJsOkWOFRJQ0Jt0g1UwqXJJVAb+V+umH5Dfr8oh4EVP7VVg==",
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
"integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
"dev": true,
"dependencies": {
"reusify": "^1.0.4"
@ -4413,16 +4417,16 @@
}
},
"node_modules/fraction.js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
"integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==",
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.6.tgz",
"integrity": "sha512-n2aZ9tNfYDwaHhvFTkhFErqOMIb8uyzSQ+vGJBjZyanAKZVbGUQ1sngfk9FdkBw7G26O7AgNjLcecLffD1c7eg==",
"dev": true,
"engines": {
"node": "*"
},
"funding": {
"type": "patreon",
"url": "https://www.patreon.com/infusion"
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fs-extra": {
@ -5655,9 +5659,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.27",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz",
"integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==",
"version": "8.4.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.29.tgz",
"integrity": "sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==",
"dev": true,
"funding": [
{
@ -5871,9 +5875,9 @@
}
},
"node_modules/postcss-dark-theme-class": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/postcss-dark-theme-class/-/postcss-dark-theme-class-0.8.0.tgz",
"integrity": "sha512-/zyywenvSJVlG1Ie/MLkQBhoh0sTOKPQa+3exaBVAmeITuGscGZu1NuJc5qpv2+ywIkBujL9OU26bj0DdUgY2Q==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postcss-dark-theme-class/-/postcss-dark-theme-class-1.0.0.tgz",
"integrity": "sha512-7XiFx8Ahvot3YmHjs4/jlxyUggomSNZzoF1hyS5xIZpLyfcPY8vb/3q4QPP8CqhnnA911OrDOZL7OTkjHoEjdw==",
"dev": true,
"funding": [
{
@ -6059,9 +6063,9 @@
}
},
"node_modules/postcss-lab-function": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-6.0.1.tgz",
"integrity": "sha512-/Xl6JitDh7jWkcOLxrHcAlEaqkxyaG3g4iDMy5RyhNaiQPJ9Egf2+Mxp1W2qnH5jB2bj59f3RbdKmC6qx1IcXA==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-6.0.3.tgz",
"integrity": "sha512-+0WxmblCb2Khfj9wpJQKd/9QhtHK/ImIqfnXX4HEoTDmjdtI6IUjXnC83bYX0CaHitpPjWnoQjoasW7qb1TCHw==",
"dev": true,
"funding": [
{
@ -6074,7 +6078,7 @@
}
],
"dependencies": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
"@csstools/postcss-progressive-custom-properties": "^3.0.0"
@ -6219,9 +6223,9 @@
}
},
"node_modules/postcss-preset-env": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.1.1.tgz",
"integrity": "sha512-rMPEqyTLm8JLbvaHnDAdQg6SN4Z/NDOsm+CRefg4HmSOiNpTcBXaw4RAaQbfTNe8BB75l4NpoQ6sMdrutdEpdQ==",
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.1.3.tgz",
"integrity": "sha512-h8iPXykc4i/MDkbu8GuROt90mQJcj4//P49keGW+mcfs9xWeUZFotsT0m2YV9zpdCvSNJojOww1Os6BpVTpHbA==",
"dev": true,
"funding": [
{
@ -6235,14 +6239,14 @@
],
"dependencies": {
"@csstools/postcss-cascade-layers": "^4.0.0",
"@csstools/postcss-color-function": "^3.0.1",
"@csstools/postcss-color-mix-function": "^2.0.1",
"@csstools/postcss-color-function": "^3.0.3",
"@csstools/postcss-color-mix-function": "^2.0.3",
"@csstools/postcss-exponential-functions": "^1.0.0",
"@csstools/postcss-font-format-keywords": "^3.0.0",
"@csstools/postcss-gradients-interpolation-method": "^4.0.1",
"@csstools/postcss-hwb-function": "^3.0.1",
"@csstools/postcss-gradients-interpolation-method": "^4.0.3",
"@csstools/postcss-hwb-function": "^3.0.3",
"@csstools/postcss-ic-unit": "^3.0.0",
"@csstools/postcss-is-pseudo-class": "^4.0.0",
"@csstools/postcss-is-pseudo-class": "^4.0.1",
"@csstools/postcss-logical-float-and-clear": "^2.0.0",
"@csstools/postcss-logical-resize": "^2.0.0",
"@csstools/postcss-logical-viewport-units": "^2.0.1",
@ -6250,20 +6254,20 @@
"@csstools/postcss-media-queries-aspect-ratio-number-values": "^2.0.2",
"@csstools/postcss-nested-calc": "^3.0.0",
"@csstools/postcss-normalize-display-values": "^3.0.0",
"@csstools/postcss-oklab-function": "^3.0.1",
"@csstools/postcss-oklab-function": "^3.0.3",
"@csstools/postcss-progressive-custom-properties": "^3.0.0",
"@csstools/postcss-relative-color-syntax": "^2.0.1",
"@csstools/postcss-relative-color-syntax": "^2.0.3",
"@csstools/postcss-scope-pseudo-class": "^3.0.0",
"@csstools/postcss-stepped-value-functions": "^3.0.1",
"@csstools/postcss-text-decoration-shorthand": "^3.0.0",
"@csstools/postcss-text-decoration-shorthand": "^3.0.2",
"@csstools/postcss-trigonometric-functions": "^3.0.1",
"@csstools/postcss-unset-value": "^3.0.0",
"autoprefixer": "^10.4.14",
"autoprefixer": "^10.4.15",
"browserslist": "^4.21.10",
"css-blank-pseudo": "^6.0.0",
"css-has-pseudo": "^6.0.0",
"css-prefers-color-scheme": "^9.0.0",
"cssdb": "^7.7.0",
"cssdb": "^7.7.1",
"postcss-attribute-case-insensitive": "^6.0.2",
"postcss-clamp": "^4.1.0",
"postcss-color-functional-notation": "^6.0.0",
@ -6280,7 +6284,7 @@
"postcss-gap-properties": "^5.0.0",
"postcss-image-set-function": "^6.0.0",
"postcss-initial": "^4.0.1",
"postcss-lab-function": "^6.0.1",
"postcss-lab-function": "^6.0.3",
"postcss-logical": "^7.0.0",
"postcss-nesting": "^12.0.1",
"postcss-opacity-percentage": "^2.0.0",
@ -6372,9 +6376,9 @@
"dev": true
},
"node_modules/preact": {
"version": "10.16.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.16.0.tgz",
"integrity": "sha512-XTSj3dJ4roKIC93pald6rWuB2qQJO9gO2iLLyTe87MrjQN+HklueLsmskbywEWqCHlclgz3/M4YLL2iBr9UmMA==",
"version": "10.17.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.17.1.tgz",
"integrity": "sha512-X9BODrvQ4Ekwv9GURm9AKAGaomqXmip7NQTZgY7gcNmr7XE83adOMJvd3N42id1tMFU7ojiynRsYnY6/BRFxLA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@ -6397,9 +6401,9 @@
}
},
"node_modules/pretty-bytes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.0.0.tgz",
"integrity": "sha512-6UqkYefdogmzqAZWzJ7laYeJnaXDy2/J+ZqiiMtS7t7OfpXWTlaeGMwX8U6EFvPV/YWWEKRkS8hKS4k60WHTOg==",
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
"integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
"dev": true,
"engines": {
"node": "^14.13.1 || >=16.0.0"
@ -7323,11 +7327,11 @@
}
},
"node_modules/use-long-press": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.1.5.tgz",
"integrity": "sha512-bnwk2SlvLLpeJPkNYSGkc59q5YNV9V/fLDkSOAF2p7Xt0zw3iYHEmgEGkNYkK7zEIEyRFi5CczKsT7MN99UzVQ==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.2.0.tgz",
"integrity": "sha512-uq5o2qFR1VRjHn8Of7Fl344/AGvgk7C5Mcb4aSb1ZRVp6PkgdXJJLdRrlSTJQVkkQcDuqFbFc3mDX4COg7mRTA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
"react": ">=16.8.0"
}
},
"node_modules/use-resize-observer": {
@ -7526,14 +7530,14 @@
}
},
"node_modules/vite-plugin-pwa": {
"version": "0.16.4",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.4.tgz",
"integrity": "sha512-lmwHFIs9zI2H9bXJld/zVTbCqCQHZ9WrpyDMqosICDV0FVnCJwniX1NMDB79HGTIZzOQkY4gSZaVTJTw6maz/Q==",
"version": "0.16.5",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.5.tgz",
"integrity": "sha512-Ahol4dwhMP2UHPQXkllSlXbihOaDFnvBIDPmAxoSZ1EObBUJGP4CMRyCyAVkIHjd6/H+//vH0DM2ON+XxHr81g==",
"dev": true,
"dependencies": {
"debug": "^4.3.4",
"fast-glob": "^3.2.12",
"pretty-bytes": "^6.0.0",
"fast-glob": "^3.3.1",
"pretty-bytes": "^6.1.1",
"workbox-build": "^7.0.0",
"workbox-window": "^7.0.0"
},
@ -9209,9 +9213,9 @@
"requires": {}
},
"@csstools/color-helpers": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-3.0.0.tgz",
"integrity": "sha512-rBODd1rY01QcenD34QxbQxLc1g+Uh7z1X/uzTHNQzJUnFCT9/EZYI7KWq+j0YfWMXJsRJ8lVkqBcB0R/qLr+yg==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-3.0.2.tgz",
"integrity": "sha512-NMVs/l7Y9eIKL5XjbCHEgGcG8LOUT2qVcRjX6EzkCdlvftHVKr2tHIPzHavfrULRZ5Q2gxrJ9f44dAlj6fX97Q==",
"dev": true
},
"@csstools/css-calc": {
@ -9222,12 +9226,12 @@
"requires": {}
},
"@csstools/css-color-parser": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-1.2.3.tgz",
"integrity": "sha512-YaEnCoPTdhE4lPQFH3dU4IEk8S+yCnxS88wMv45JzlnMfZp57hpqA6qf2gX8uv7IJTJ/43u6pTQmhy7hCjlz7g==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-1.3.1.tgz",
"integrity": "sha512-cehc/DQCyb4hL4fspvyL7WiY+uAy8Iuaz0yTyndC/AyBmxkNpgtSgCSsr0aR4vkaSFVZfNNVlKbjHFwOsPGB1Q==",
"dev": true,
"requires": {
"@csstools/color-helpers": "^3.0.0",
"@csstools/color-helpers": "^3.0.2",
"@csstools/css-calc": "^1.1.3"
}
},
@ -9262,24 +9266,24 @@
}
},
"@csstools/postcss-color-function": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-3.0.1.tgz",
"integrity": "sha512-+vrvCQeUifpMeyd42VQ3JPWGQ8cO19+TnGbtfq1SDSgZzRapCQO4aK9h/jhMOKPnxGzbA57oS0aHgP/12N9gSQ==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-3.0.3.tgz",
"integrity": "sha512-5oNUbO89SX7BuSB0ZiUxDaQt4R2K3A+RQZlxNHOvghZJO/UqgomLPII6JkgrywLQ0Y4JDzbyNuwr0OKo2v0RsQ==",
"dev": true,
"requires": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
"@csstools/postcss-progressive-custom-properties": "^3.0.0"
}
},
"@csstools/postcss-color-mix-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-2.0.1.tgz",
"integrity": "sha512-Z5cXkLiccKIVcUTe+fAfjUD7ZUv0j8rq3dSoBpM6I49dcw+50318eYrwUZa3nyb4xNx7ntNNUPmesAc87kPE2Q==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-2.0.3.tgz",
"integrity": "sha512-q/fv8pdRR07GAJTvemXbQ02hwVGmVcOjBJj7+gnlGrAVwSzrPEsJc8zM/EzoqVJTZtm/DwG6TWu+VJIxVpyUBg==",
"dev": true,
"requires": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
"@csstools/postcss-progressive-custom-properties": "^3.0.0"
@ -9306,24 +9310,24 @@
}
},
"@csstools/postcss-gradients-interpolation-method": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-4.0.1.tgz",
"integrity": "sha512-IHeFIcksjI8xKX7PWLzAyigM3UvJdZ4btejeNa7y/wXxqD5dyPPZuY55y8HGTrS6ETVTRqfIznoCPtTzIX7ygQ==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-4.0.3.tgz",
"integrity": "sha512-dEK3WbajX538Zu3lPMtBPAO1pooR7zslJ1mDrWKQzlwQczls3fEz+tlRhd7KWMMlsoIwNGMIGq2W/GqEErDjkg==",
"dev": true,
"requires": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
"@csstools/postcss-progressive-custom-properties": "^3.0.0"
}
},
"@csstools/postcss-hwb-function": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-3.0.1.tgz",
"integrity": "sha512-FYe2K8EOYlL1BUm2HTXVBo6bWAj0xl4khOk6EFhQHy/C5p3rlr8OcetzQuwMeNQ3v25nB06QTgqUHoOUwoEqhA==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-3.0.3.tgz",
"integrity": "sha512-2TqrRD8JzSwQCRKKNc9BFhSEmsz+mR3RtwSw5mQSGILC+LIYCVWeYwC33cI+saFWv0DGZ0NXLx5VSX2tdJyU6w==",
"dev": true,
"requires": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0"
}
@ -9339,9 +9343,9 @@
}
},
"@csstools/postcss-is-pseudo-class": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-4.0.0.tgz",
"integrity": "sha512-0I6siRcDymG3RrkNTSvHDMxTQ6mDyYE8awkcaHNgtYacd43msl+4ZWDfQ1yZQ/viczVWjqJkLmPiRHSgxn5nZA==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-4.0.1.tgz",
"integrity": "sha512-KJGLbjjjg+mdNclLyCfsZaJS4xCaRaxKAnmWKpIp1FarEem3ZdoOxTlIELwvlE5BVg1t3QTmp0+DPSlLTTFMhA==",
"dev": true,
"requires": {
"@csstools/selector-specificity": "^3.0.0",
@ -9415,12 +9419,12 @@
}
},
"@csstools/postcss-oklab-function": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-3.0.1.tgz",
"integrity": "sha512-3TIz+dCPlQPzz4yAEYXchUpfuU2gRYK4u1J+1xatNX85Isg4V+IbLyppblWLV4Vb6npFF8qsHN17rNuxOIy/6w==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-3.0.3.tgz",
"integrity": "sha512-8Wdpmy8mvVyHsToJkrnNpwpAgqCPNpQMLNqkR62smbEuFcmRHEiDnb0OlkKjErzmiBMr7vjZAQ6e2lA9oVguQQ==",
"dev": true,
"requires": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
"@csstools/postcss-progressive-custom-properties": "^3.0.0"
@ -9436,12 +9440,12 @@
}
},
"@csstools/postcss-relative-color-syntax": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-2.0.1.tgz",
"integrity": "sha512-9B8br/7q0bjD1fV3yE22izjc7Oy5hDbDgwdFEz207cdJHYC9yQneJzP3H+/w3RgC7uyfEVhyyhkGRx5YAfJtmg==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-2.0.3.tgz",
"integrity": "sha512-9MOzad5i0fnkOI6qXzcznuyGhLYARBkR8wDsyqbANkZ20srHJZ6PAy44g5eNw3+B7yvslUK4hx9ehnbbI9x4rw==",
"dev": true,
"requires": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
"@csstools/postcss-progressive-custom-properties": "^3.0.0"
@ -9468,12 +9472,12 @@
}
},
"@csstools/postcss-text-decoration-shorthand": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-3.0.0.tgz",
"integrity": "sha512-BAa1MIMJmEZlJ+UkPrkyoz3DC7kLlIl2oDya5yXgvUrelpwxddgz8iMp69qBStdXwuMyfPx46oZcSNx8Z0T2eA==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-3.0.2.tgz",
"integrity": "sha512-vO2onX7/TPU3LMrSvg+FhMxTujhU+LELP9zln7SiB5BJqZi+y/ZOJZRBHFvCfM9J1lnNkskMN96bP5g3yg7Jmw==",
"dev": true,
"requires": {
"@csstools/color-helpers": "^3.0.0",
"@csstools/color-helpers": "^3.0.2",
"postcss-value-parser": "^4.2.0"
}
},
@ -9657,9 +9661,9 @@
"optional": true
},
"@formatjs/intl-localematcher": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.4.0.tgz",
"integrity": "sha512-bRTd+rKomvfdS4QDlVJ6TA/Jx1F2h/TBVO5LjvhQ7QPPHp19oPNMIum7W2CMEReq/zPxpmCeB31F9+5gl/qtvw==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.4.1.tgz",
"integrity": "sha512-Fs4MhhHlLC0RrspX2u2KP7zlwL9eHrBZsOBxaPOeqrCZYLaOUK4cYXQ1ErpAB0HnGV/GUXNa5smzV/7jCuRzxg==",
"requires": {
"tslib": "^2.4.0"
}
@ -10002,9 +10006,9 @@
"dev": true
},
"@uidotdev/usehooks": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.1.0.tgz",
"integrity": "sha512-D7SJiNQC1BOHgtE2dy2KvOtnRNaLWTFFHvcBLg7lZ8Jz7YcimxdUY3spqpvf/mVkGCuUHee8i/79p5vVkBgsYQ==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.2.0.tgz",
"integrity": "sha512-K4tirEf3lvEgi/Z6Pv2aNR8r6YCobfg0N5AOkn3hhuWtGS8n7mMSL7RGFpE2Bj56oeLGGZXrVKteEwg4vGitGA==",
"requires": {}
},
"@vue/compiler-core": {
@ -10162,13 +10166,13 @@
"dev": true
},
"autoprefixer": {
"version": "10.4.14",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz",
"integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==",
"version": "10.4.15",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz",
"integrity": "sha512-KCuPB8ZCIqFdA4HwKXsvz7j6gvSDNhDP7WnUjBleRkKjPdvCmHFuQ77ocavI8FT6NdvlBnE2UFr2H4Mycn8Vew==",
"dev": true,
"requires": {
"browserslist": "^4.21.5",
"caniuse-lite": "^1.0.30001464",
"browserslist": "^4.21.10",
"caniuse-lite": "^1.0.30001520",
"fraction.js": "^4.2.0",
"normalize-range": "^0.1.2",
"picocolors": "^1.0.0",
@ -10286,9 +10290,9 @@
}
},
"caniuse-lite": {
"version": "1.0.30001519",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001519.tgz",
"integrity": "sha512-0QHgqR+Jv4bxHMp8kZ1Kn8CH55OikjKJ6JmKkZYP1F3D7w+lnFXF70nG5eNfsZS89jadi5Ywy5UCSKLAglIRkg==",
"version": "1.0.30001532",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001532.tgz",
"integrity": "sha512-FbDFnNat3nMnrROzqrsg314zhqN5LGQ1kyyMk2opcrwGbVGpHRhgCWtAgD5YJUqNAiQ+dklreil/c3Qf1dfCTw==",
"dev": true
},
"capital-case": {
@ -10442,9 +10446,9 @@
"requires": {}
},
"cssdb": {
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.7.0.tgz",
"integrity": "sha512-1hN+I3r4VqSNQ+OmMXxYexnumbOONkSil0TWMebVXHtzYW4tRRPovUNHPHj2d4nrgOuYJ8Vs3XwvywsuwwXNNA==",
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.7.2.tgz",
"integrity": "sha512-pQPYP7/kch4QlkTcLuUNiNL2v/E+O+VIdotT+ug62/+2B2/jkzs5fMM6RHCzGCZ9C82pODEMSIzRRUzJOrl78g==",
"dev": true
},
"cssesc": {
@ -10664,9 +10668,9 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-glob": {
"version": "3.2.12",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
"integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
"dev": true,
"requires": {
"@nodelib/fs.stat": "^2.0.2",
@ -10683,9 +10687,9 @@
"dev": true
},
"fastq": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.14.0.tgz",
"integrity": "sha512-eR2D+V9/ExcbF9ls441yIuN6TI2ED1Y2ZcA5BmMtJsOkWOFRJQ0Jt0g1UwqXJJVAb+V+umH5Dfr8oh4EVP7VVg==",
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
"integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
"dev": true,
"requires": {
"reusify": "^1.0.4"
@ -10749,9 +10753,9 @@
}
},
"fraction.js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
"integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==",
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.6.tgz",
"integrity": "sha512-n2aZ9tNfYDwaHhvFTkhFErqOMIb8uyzSQ+vGJBjZyanAKZVbGUQ1sngfk9FdkBw7G26O7AgNjLcecLffD1c7eg==",
"dev": true
},
"fs-extra": {
@ -11646,9 +11650,9 @@
"dev": true
},
"postcss": {
"version": "8.4.27",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz",
"integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==",
"version": "8.4.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.29.tgz",
"integrity": "sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==",
"dev": true,
"requires": {
"nanoid": "^3.3.6",
@ -11739,9 +11743,9 @@
}
},
"postcss-dark-theme-class": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/postcss-dark-theme-class/-/postcss-dark-theme-class-0.8.0.tgz",
"integrity": "sha512-/zyywenvSJVlG1Ie/MLkQBhoh0sTOKPQa+3exaBVAmeITuGscGZu1NuJc5qpv2+ywIkBujL9OU26bj0DdUgY2Q==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postcss-dark-theme-class/-/postcss-dark-theme-class-1.0.0.tgz",
"integrity": "sha512-7XiFx8Ahvot3YmHjs4/jlxyUggomSNZzoF1hyS5xIZpLyfcPY8vb/3q4QPP8CqhnnA911OrDOZL7OTkjHoEjdw==",
"dev": true,
"requires": {}
},
@ -11813,12 +11817,12 @@
"requires": {}
},
"postcss-lab-function": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-6.0.1.tgz",
"integrity": "sha512-/Xl6JitDh7jWkcOLxrHcAlEaqkxyaG3g4iDMy5RyhNaiQPJ9Egf2+Mxp1W2qnH5jB2bj59f3RbdKmC6qx1IcXA==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-6.0.3.tgz",
"integrity": "sha512-+0WxmblCb2Khfj9wpJQKd/9QhtHK/ImIqfnXX4HEoTDmjdtI6IUjXnC83bYX0CaHitpPjWnoQjoasW7qb1TCHw==",
"dev": true,
"requires": {
"@csstools/css-color-parser": "^1.2.2",
"@csstools/css-color-parser": "^1.3.1",
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
"@csstools/postcss-progressive-custom-properties": "^3.0.0"
@ -11876,20 +11880,20 @@
}
},
"postcss-preset-env": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.1.1.tgz",
"integrity": "sha512-rMPEqyTLm8JLbvaHnDAdQg6SN4Z/NDOsm+CRefg4HmSOiNpTcBXaw4RAaQbfTNe8BB75l4NpoQ6sMdrutdEpdQ==",
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.1.3.tgz",
"integrity": "sha512-h8iPXykc4i/MDkbu8GuROt90mQJcj4//P49keGW+mcfs9xWeUZFotsT0m2YV9zpdCvSNJojOww1Os6BpVTpHbA==",
"dev": true,
"requires": {
"@csstools/postcss-cascade-layers": "^4.0.0",
"@csstools/postcss-color-function": "^3.0.1",
"@csstools/postcss-color-mix-function": "^2.0.1",
"@csstools/postcss-color-function": "^3.0.3",
"@csstools/postcss-color-mix-function": "^2.0.3",
"@csstools/postcss-exponential-functions": "^1.0.0",
"@csstools/postcss-font-format-keywords": "^3.0.0",
"@csstools/postcss-gradients-interpolation-method": "^4.0.1",
"@csstools/postcss-hwb-function": "^3.0.1",
"@csstools/postcss-gradients-interpolation-method": "^4.0.3",
"@csstools/postcss-hwb-function": "^3.0.3",
"@csstools/postcss-ic-unit": "^3.0.0",
"@csstools/postcss-is-pseudo-class": "^4.0.0",
"@csstools/postcss-is-pseudo-class": "^4.0.1",
"@csstools/postcss-logical-float-and-clear": "^2.0.0",
"@csstools/postcss-logical-resize": "^2.0.0",
"@csstools/postcss-logical-viewport-units": "^2.0.1",
@ -11897,20 +11901,20 @@
"@csstools/postcss-media-queries-aspect-ratio-number-values": "^2.0.2",
"@csstools/postcss-nested-calc": "^3.0.0",
"@csstools/postcss-normalize-display-values": "^3.0.0",
"@csstools/postcss-oklab-function": "^3.0.1",
"@csstools/postcss-oklab-function": "^3.0.3",
"@csstools/postcss-progressive-custom-properties": "^3.0.0",
"@csstools/postcss-relative-color-syntax": "^2.0.1",
"@csstools/postcss-relative-color-syntax": "^2.0.3",
"@csstools/postcss-scope-pseudo-class": "^3.0.0",
"@csstools/postcss-stepped-value-functions": "^3.0.1",
"@csstools/postcss-text-decoration-shorthand": "^3.0.0",
"@csstools/postcss-text-decoration-shorthand": "^3.0.2",
"@csstools/postcss-trigonometric-functions": "^3.0.1",
"@csstools/postcss-unset-value": "^3.0.0",
"autoprefixer": "^10.4.14",
"autoprefixer": "^10.4.15",
"browserslist": "^4.21.10",
"css-blank-pseudo": "^6.0.0",
"css-has-pseudo": "^6.0.0",
"css-prefers-color-scheme": "^9.0.0",
"cssdb": "^7.7.0",
"cssdb": "^7.7.1",
"postcss-attribute-case-insensitive": "^6.0.2",
"postcss-clamp": "^4.1.0",
"postcss-color-functional-notation": "^6.0.0",
@ -11927,7 +11931,7 @@
"postcss-gap-properties": "^5.0.0",
"postcss-image-set-function": "^6.0.0",
"postcss-initial": "^4.0.1",
"postcss-lab-function": "^6.0.1",
"postcss-lab-function": "^6.0.3",
"postcss-logical": "^7.0.0",
"postcss-nesting": "^12.0.1",
"postcss-opacity-percentage": "^2.0.0",
@ -11982,9 +11986,9 @@
"dev": true
},
"preact": {
"version": "10.16.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.16.0.tgz",
"integrity": "sha512-XTSj3dJ4roKIC93pald6rWuB2qQJO9gO2iLLyTe87MrjQN+HklueLsmskbywEWqCHlclgz3/M4YLL2iBr9UmMA=="
"version": "10.17.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.17.1.tgz",
"integrity": "sha512-X9BODrvQ4Ekwv9GURm9AKAGaomqXmip7NQTZgY7gcNmr7XE83adOMJvd3N42id1tMFU7ojiynRsYnY6/BRFxLA=="
},
"prettier": {
"version": "2.8.0",
@ -11994,9 +11998,9 @@
"peer": true
},
"pretty-bytes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.0.0.tgz",
"integrity": "sha512-6UqkYefdogmzqAZWzJ7laYeJnaXDy2/J+ZqiiMtS7t7OfpXWTlaeGMwX8U6EFvPV/YWWEKRkS8hKS4k60WHTOg==",
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
"integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
"dev": true
},
"prop-types": {
@ -12647,9 +12651,9 @@
"requires": {}
},
"use-long-press": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.1.5.tgz",
"integrity": "sha512-bnwk2SlvLLpeJPkNYSGkc59q5YNV9V/fLDkSOAF2p7Xt0zw3iYHEmgEGkNYkK7zEIEyRFi5CczKsT7MN99UzVQ==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.2.0.tgz",
"integrity": "sha512-uq5o2qFR1VRjHn8Of7Fl344/AGvgk7C5Mcb4aSb1ZRVp6PkgdXJJLdRrlSTJQVkkQcDuqFbFc3mDX4COg7mRTA==",
"requires": {}
},
"use-resize-observer": {
@ -12764,14 +12768,14 @@
"requires": {}
},
"vite-plugin-pwa": {
"version": "0.16.4",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.4.tgz",
"integrity": "sha512-lmwHFIs9zI2H9bXJld/zVTbCqCQHZ9WrpyDMqosICDV0FVnCJwniX1NMDB79HGTIZzOQkY4gSZaVTJTw6maz/Q==",
"version": "0.16.5",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.5.tgz",
"integrity": "sha512-Ahol4dwhMP2UHPQXkllSlXbihOaDFnvBIDPmAxoSZ1EObBUJGP4CMRyCyAVkIHjd6/H+//vH0DM2ON+XxHr81g==",
"dev": true,
"requires": {
"debug": "^4.3.4",
"fast-glob": "^3.2.12",
"pretty-bytes": "^6.0.0",
"fast-glob": "^3.3.1",
"pretty-bytes": "^6.1.1",
"workbox-build": "^7.0.0",
"workbox-window": "^7.0.0"
}

View file

@ -10,12 +10,12 @@
"sourcemap": "npx source-map-explorer dist/assets/*.js"
},
"dependencies": {
"@formatjs/intl-localematcher": "~0.4.0",
"@formatjs/intl-localematcher": "~0.4.1",
"@github/text-expander-element": "~2.5.0",
"@iconify-icons/mingcute": "~1.2.7",
"@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~4.0.3",
"@uidotdev/usehooks": "~2.1.0",
"@uidotdev/usehooks": "~2.2.0",
"dayjs": "~1.11.9",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
@ -27,7 +27,7 @@
"mem": "~9.0.2",
"p-retry": "~5.1.2",
"p-throttle": "~5.1.0",
"preact": "~10.16.0",
"preact": "~10.17.1",
"react-hotkeys-hook": "~4.4.1",
"react-intersection-observer": "~9.5.2",
"react-quick-pinch-zoom": "~4.9.0",
@ -37,21 +37,21 @@
"toastify-js": "~1.12.0",
"uid": "~2.0.2",
"use-debounce": "~9.0.4",
"use-long-press": "~3.1.5",
"use-long-press": "~3.2.0",
"use-resize-observer": "~9.1.0",
"valtio": "1.9.0"
},
"devDependencies": {
"@preact/preset-vite": "~2.5.0",
"@trivago/prettier-plugin-sort-imports": "~4.2.0",
"postcss": "~8.4.27",
"postcss-dark-theme-class": "~0.8.0",
"postcss-preset-env": "~9.1.1",
"postcss": "~8.4.29",
"postcss-dark-theme-class": "~1.0.0",
"postcss-preset-env": "~9.1.3",
"twitter-text": "~3.1.0",
"vite": "~4.4.9",
"vite-plugin-generate-file": "~0.0.4",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.16.4",
"vite-plugin-pwa": "~0.16.5",
"vite-plugin-remove-console": "~2.1.1",
"workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.0.0",

View file

@ -94,3 +94,100 @@ const apiRoute = new RegExpRoute(
}),
);
registerRoute(apiRoute);
// PUSH NOTIFICATIONS
// ==================
self.addEventListener('push', (event) => {
const { data } = event;
if (data) {
const payload = data.json();
console.log('PUSH payload', payload);
const {
access_token,
title,
body,
icon,
notification_id,
notification_type,
preferred_locale,
} = payload;
if (!!navigator.setAppBadge) {
if (notification_type === 'mention') {
navigator.setAppBadge(1);
}
}
event.waitUntil(
self.registration.showNotification(title, {
body,
icon,
dir: 'auto',
badge: '/logo-192.png',
lang: preferred_locale,
tag: notification_id,
timestamp: Date.now(),
data: {
access_token,
notification_type,
},
}),
);
}
});
self.addEventListener('notificationclick', (event) => {
const payload = event.notification;
console.log('NOTIFICATION CLICK payload', payload);
const { badge, body, data, dir, icon, lang, tag, timestamp, title } = payload;
const { access_token, notification_type } = data;
const actions = new Promise((resolve) => {
event.notification.close();
const url = `/#/notifications?id=${tag}&access_token=${btoa(access_token)}`;
self.clients
.matchAll({
type: 'window',
includeUncontrolled: true,
})
.then((clients) => {
console.log('NOTIFICATION CLICK clients 1', clients);
if (clients.length && 'navigate' in clients[0]) {
console.log('NOTIFICATION CLICK clients 2', clients);
const bestClient =
clients.find(
(client) =>
client.focused || client.visibilityState === 'visible',
) || clients[0];
console.log('NOTIFICATION CLICK navigate', url);
// Check if URL is root / or /notifications
// const clientURL = new URL(bestClient.url);
// if (
// /^#\/?$/.test(clientURL.hash) ||
// /^#\/notifications/i.test(clientURL.hash)
// ) {
// bestClient.navigate(url).then((client) => client?.focus());
// } else {
// User might be on a different page (e.g. composing a post), so don't navigate anywhere else
if (bestClient) {
console.log('NOTIFICATION CLICK postMessage', bestClient);
bestClient.postMessage?.({
type: 'notification',
id: tag,
accessToken: access_token,
});
bestClient.focus();
} else {
console.log('NOTIFICATION CLICK openWindow', url);
self.clients.openWindow(url);
}
// }
} else {
console.log('NOTIFICATION CLICK openWindow', url);
self.clients.openWindow(url);
}
resolve();
});
});
event.waitUntil(actions);
});

View file

@ -17,8 +17,18 @@ const results = await fetch(url, {
});
const json = await results.json();
// Filters
json.instances = json.instances.filter(
(instance) => Number(instance.connections) > 20,
);
const names = json.instances.map((instance) => instance.name);
// Write to file
const path = './src/data/instances.json';
fs.writeFileSync(path, JSON.stringify(names, null, '\t'), 'utf8');
// Write everything to file, for debugging
const path2 = './src/data/instances-full.json';
fs.writeFileSync(path2, JSON.stringify(json, null, '\t'), 'utf8');

View file

@ -747,6 +747,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
transparent 150%
);
position: relative;
container-type: inline-size;
}
.status-carousel:after {
content: '';
@ -806,7 +807,18 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.status-carousel > ul > li:is(:empty, :has(> a:empty)) {
display: none;
}
@media (hover: hover) or (pointer: fine) or (min-width: 40em) {
/*
Assume that browsers that do support inline-size property also support container queries.
https://www.smashingmagazine.com/2021/05/css-container-queries-use-cases-migration-strategies/#progressive-enhancement-polyfills
*/
@supports not (contain: inline-size) {
@media (hover: hover) or (pointer: fine) or (min-width: 40em) {
.status-carousel > ul {
scroll-snap-type: none;
}
}
}
@container (min-width: 640px) {
.status-carousel > ul {
scroll-snap-type: none;
}
@ -1099,7 +1111,7 @@ button.carousel-dot {
font-weight: bold;
color: var(--text-color);
background-color: var(--bg-faded-blur-color);
border: var(--hairline-width) solid var(--outline-color);
border: 1px solid var(--outline-color);
box-shadow: 0 4px 32px var(--drop-shadow-color);
transition: all 0.2s ease-out;
}
@ -1805,12 +1817,12 @@ meter.donut[hidden] {
width: 100%;
flex-grow: 1;
}
:is(#home-page, #welcome, #columns) ~ .deck-container {
:is(#home-page, #welcome, #columns, #loader-root) ~ .deck-container {
z-index: 10;
position: fixed;
inset: 0;
}
:is(#home-page, #welcome, #columns):has(~ .deck-container) {
:is(#home-page, #welcome, #columns, #loader-root):has(~ .deck-container) {
display: block;
position: absolute;
user-select: none;
@ -2214,15 +2226,32 @@ ul.link-list li a .icon {
transition: var(--back-transition);
transform: translate3d(-2.5vw, 0, 0);
}
.timeline:not(.flat)
> li.timeline-item-container:has(.status-link.is-active) {
border-top-left-radius: var(--item-radius);
border-bottom-left-radius: var(--item-radius);
}
.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 {
transition: var(--back-transition);
transform: translate3d(-1.25vw, 0, 0);
}
.timeline:not(.flat)
> li.timeline-item-container:not(:has(.status-carousel)):has(
+ li .status-link.is-active
) {
border-top-left-radius: var(--item-radius);
}
.timeline:not(.flat)
> li.timeline-item-container:not(:has(.status-carousel)):has(
.status-link.is-active
)
+ li.timeline-item-container {
border-bottom-left-radius: var(--item-radius);
}
.box {
padding: 32px;
}

View file

@ -13,18 +13,22 @@ import {
Routes,
useLocation,
useNavigate,
useParams,
} from 'react-router-dom';
import 'swiped-events';
import { useSnapshot } from 'valtio';
import AccountSheet from './components/account-sheet';
import BackgroundService from './components/background-service';
import Compose from './components/compose';
import ComposeButton from './components/compose-button';
import Drafts from './components/drafts';
import Icon, { ICONS } from './components/icon';
import { ICONS } from './components/icon';
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
import Loader from './components/loader';
import MediaModal from './components/media-modal';
import Modal from './components/modal';
import NotificationService from './components/notification-service';
import SearchCommand from './components/search-command';
import Shortcuts from './components/shortcuts';
import ShortcutsSettings from './components/shortcuts-settings';
import NotFound from './pages/404';
@ -36,7 +40,7 @@ import FollowedHashtags from './pages/followed-hashtags';
import Following from './pages/following';
import Hashtag from './pages/hashtag';
import Home from './pages/home';
import HttpRoute from './pages/HttpRoute';
import HttpRoute from './pages/http-route';
import List from './pages/list';
import Lists from './pages/lists';
import Login from './pages/login';
@ -45,7 +49,7 @@ import Notifications from './pages/notifications';
import Public from './pages/public';
import Search from './pages/search';
import Settings from './pages/settings';
import Status from './pages/status';
import StatusRoute from './pages/status-route';
import Trending from './pages/trending';
import Welcome from './pages/welcome';
import {
@ -56,13 +60,11 @@ import {
initPreferences,
} from './utils/api';
import { getAccessToken } from './utils/auth';
import openCompose from './utils/open-compose';
import showToast from './utils/show-toast';
import states, { getStatus, saveStatus } from './utils/states';
import states, { initStates } from './utils/states';
import store from './utils/store';
import { getCurrentAccount } from './utils/store-utils';
import useInterval from './utils/useInterval';
import usePageVisibility from './utils/usePageVisibility';
import './utils/toast-alert';
window.__STATES__ = states;
@ -111,10 +113,11 @@ function App() {
if (code) {
console.log({ code });
// Clear the code from the URL
window.history.replaceState({}, document.title, '/');
window.history.replaceState({}, document.title, location.pathname || '/');
const clientID = store.session.get('clientID');
const clientSecret = store.session.get('clientSecret');
const vapidKey = store.session.get('vapidKey');
(async () => {
setUIState('loading');
@ -128,8 +131,9 @@ function App() {
const masto = initClient({ instance: instanceURL, accessToken });
await Promise.allSettled([
initInstance(masto, instanceURL),
initAccount(masto, instanceURL, accessToken),
initAccount(masto, instanceURL, accessToken, vapidKey),
]);
initStates();
initPreferences(masto);
setIsLoggedIn(true);
@ -181,7 +185,7 @@ function App() {
}, 100);
return () => clearTimeout(timer);
};
useEffect(focusDeck, [location]);
useEffect(focusDeck, [location, isLoggedIn]);
const showModal =
snapStates.showCompose ||
snapStates.showSettings ||
@ -189,16 +193,20 @@ function App() {
snapStates.showAccount ||
snapStates.showDrafts ||
snapStates.showMediaModal ||
snapStates.showShortcutsSettings;
snapStates.showShortcutsSettings ||
snapStates.showKeyboardShortcutsHelp;
useEffect(() => {
if (!showModal) focusDeck();
}, [showModal]);
const { prevLocation } = snapStates;
const backgroundLocation = useRef(prevLocation || null);
const isModalPage =
matchPath('/:instance/s/:id', location.pathname) ||
matchPath('/s/:id', location.pathname);
const isModalPage = useMemo(() => {
return (
matchPath('/:instance/s/:id', location.pathname) ||
matchPath('/s/:id', location.pathname)
);
}, [location.pathname, matchPath]);
if (isModalPage) {
if (!backgroundLocation.current) backgroundLocation.current = prevLocation;
} else {
@ -241,7 +249,7 @@ function App() {
isLoggedIn ? (
<Home />
) : uiState === 'loading' ? (
<Loader />
<Loader id="loader-root" />
) : (
<Welcome />
)
@ -280,25 +288,7 @@ function App() {
<Route path="/:instance?/s/:id" element={<StatusRoute />} />
</Routes>
)}
{isLoggedIn && (
<button
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="xl" alt="Compose" />
</button>
)}
{isLoggedIn && <ComposeButton />}
{isLoggedIn &&
!snapStates.settings.shortcutsColumnsMode &&
snapStates.settings.shortcutsViewMode !== 'multi-column' && (
@ -389,7 +379,7 @@ function App() {
<AccountSheet
account={snapStates.showAccount?.account || snapStates.showAccount}
instance={snapStates.showAccount?.instance}
onClose={({ destination }) => {
onClose={({ destination } = {}) => {
states.showAccount = false;
if (destination) {
states.showAccounts = false;
@ -445,101 +435,12 @@ function App() {
/>
</Modal>
)}
<NotificationService />
<BackgroundService isLoggedIn={isLoggedIn} />
<SearchCommand onClose={focusDeck} />
<KeyboardShortcutsHelp />
</>
);
}
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;
}
function StatusRoute() {
const params = useParams();
const { id, instance } = params;
return <Status id={id} instance={instance} />;
}
export { App };

View file

@ -1,33 +1,36 @@
body.cloak a {
text-decoration-color: var(--link-color);
}
body.cloak,
.cloak {
a {
text-decoration-color: var(--link-color);
}
body.cloak .name-text,
body.cloak .name-text *,
body.cloak .status .content-container,
body.cloak .status .content-container *,
body.cloak .status .content-compact,
body.cloak .account-container :is(header, main > *:not(.actions)),
body.cloak .account-container :is(header, main > *:not(.actions)) *,
body.cloak .header-account,
body.cloak .account-block {
text-decoration-thickness: 1.1em;
text-decoration-line: line-through;
text-rendering: optimizeSpeed;
filter: opacity(0.5);
}
body.cloak .name-text *,
body.cloak .status .content-container *,
body.cloak .account-container :is(header, main > *:not(.actions)) * {
filter: none;
}
.name-text,
.name-text *,
.status .content-container,
.status .content-container *,
.status .content-compact,
.account-container :is(header, main > *:not(.actions)),
.account-container :is(header, main > *:not(.actions)) *,
.header-account,
.account-block {
text-decoration-thickness: 1.1em;
text-decoration-line: line-through;
text-rendering: optimizeSpeed;
filter: opacity(0.5);
}
.name-text *,
.status .content-container *,
.account-container :is(header, main > *:not(.actions)) * {
filter: none;
}
body.cloak .status :is(img, video, audio),
body.cloak .avatar,
body.cloak .emoji,
body.cloak .header-banner {
filter: contrast(0) !important;
background-color: #000 !important;
.status :is(img, video, audio),
.avatar,
.emoji,
.header-banner {
filter: contrast(0) !important;
background-color: #000 !important;
}
}
/* SPECIAL CASES */

View file

@ -1,7 +1,6 @@
import './account-block.css';
import { useNavigate } from 'react-router-dom';
// import { useNavigate } from 'react-router-dom';
import enhanceContent from '../utils/enhance-content';
import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number';
@ -21,6 +20,8 @@ function AccountBlock({
onClick,
showActivity = false,
showStats = false,
accountInstance,
hideDisplayName = false,
}) {
if (skeleton) {
return (
@ -35,7 +36,7 @@ function AccountBlock({
);
}
const navigate = useNavigate();
// const navigate = useNavigate();
const {
id,
@ -53,7 +54,10 @@ function AccountBlock({
note,
group,
} = account;
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
if (accountInstance) {
acct2 = `@${accountInstance}`;
}
const verifiedField = fields?.find((f) => !!f.verifiedAt && !!f.value);
@ -68,7 +72,8 @@ function AccountBlock({
e.preventDefault();
if (onClick) return onClick(e);
if (internal) {
navigate(`/${instance}/a/${id}`);
// navigate(`/${instance}/a/${id}`);
location.hash = `/${instance}/a/${id}`;
} else {
states.showAccount = {
account,
@ -79,14 +84,18 @@ function AccountBlock({
>
<Avatar url={avatar} size={avatarSize} squircle={bot} />
<span>
{displayName ? (
<b>
<EmojiText text={displayName} emojis={emojis} />
</b>
) : (
<b>{username}</b>
{!hideDisplayName && (
<>
{displayName ? (
<b>
<EmojiText text={displayName} emojis={emojis} />
</b>
) : (
<b>{username}</b>
)}
<br />
</>
)}
<br />
<span class="account-block-acct">
@{acct1}
<wbr />

View file

@ -340,7 +340,7 @@
}
.timeline-start .account-container header .account-block {
font-size: 175%;
margin-bottom: -8px;
/* margin-bottom: -8px; */
line-height: 1.1;
letter-spacing: -0.5px;
mix-blend-mode: multiply;

View file

@ -98,6 +98,7 @@ function AccountInfo({
statusesCount,
url,
username,
memorial,
} = info || {};
let headerIsAvatar = false;
let { header, headerStatic } = info || {};
@ -187,7 +188,9 @@ function AccountInfo({
try {
// Get color from four corners of image
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', {
willReadFrequently: true,
});
canvas.width = e.target.width;
canvas.height = e.target.height;
ctx.drawImage(e.target, 0, 0);
@ -266,19 +269,16 @@ function AccountInfo({
/>
</header>
<main tabIndex="-1">
{bot && (
<>
<span class="tag">
<Icon icon="bot" /> Automated
</span>
</>
{!!memorial && <span class="tag">In Memoriam</span>}
{!!bot && (
<span class="tag">
<Icon icon="bot" /> Automated
</span>
)}
{group && (
<>
<span class="tag">
<Icon icon="group" /> Group
</span>
</>
{!!group && (
<span class="tag">
<Icon icon="group" /> Group
</span>
)}
<div
class="note"

View file

@ -58,6 +58,18 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
});
if (result.accounts.length) {
return result.accounts[0];
} else if (/https?:\/\/[^/]+\/@/.test(account)) {
const accountURL = new URL(account);
const acct = accountURL.pathname.replace(/^\//, '');
const result = await masto.v2.search({
q: acct,
type: 'accounts',
limit: 1,
resolve: authenticated,
});
if (result.accounts.length) {
return result.accounts[0];
}
}
}
} else {

View file

@ -16,7 +16,9 @@ const alphaCache = {};
const canvas = window.OffscreenCanvas
? new OffscreenCanvas(1, 1)
: document.createElement('canvas');
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', {
willReadFrequently: true,
});
function Avatar({ url, size, alt = '', squircle, ...props }) {
size = SIZES[size] || size || SIZES.m;

View file

@ -0,0 +1,106 @@
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api';
import states, { saveStatus } from '../utils/states';
import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility';
export default memo(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) {
let lastReadId;
try {
const markers = await masto.v1.markers.fetch({
timeline: 'notifications',
});
lastReadId = markers?.notifications?.lastReadId;
} catch (e) {}
if (lastReadId) {
if (notifications[0].id !== lastReadId) {
states.notificationsShowNew = true;
}
} else {
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;
});

View file

@ -0,0 +1,34 @@
import { useHotkeys } from 'react-hotkeys-hook';
import openCompose from '../utils/open-compose';
import states from '../utils/states';
import Icon from './icon';
export default function ComposeButton() {
function handleButton(e) {
if (e.shiftKey) {
const newWin = openCompose();
if (!newWin) {
alert('Looks like your browser is blocking popups.');
states.showCompose = true;
}
} else {
states.showCompose = true;
}
}
useHotkeys('c, shift+c', handleButton, {
ignoreEventWhen: (e) => {
const hasModal = !!document.querySelector('#modal-container > *');
return hasModal;
},
});
return (
<button type="button" id="compose-button" onClick={handleButton}>
<Icon icon="quill" size="xl" alt="Compose" />
</button>
);
}

View file

@ -25,6 +25,19 @@
position: sticky;
top: 0;
z-index: 100;
white-space: nowrap;
}
#compose-container .compose-top .account-block {
text-align: start;
pointer-events: none;
overflow: hidden;
color: var(--text-insignificant-color);
line-height: 1.1;
font-size: 90%;
background-color: var(--bg-faded-blur-color);
backdrop-filter: blur(16px);
padding-inline-end: 1em;
border-radius: 9999px;
}
#compose-container textarea {
@ -326,7 +339,7 @@
#compose-container .media-preview > * {
width: 80px;
height: 80px;
object-fit: contain;
object-fit: scale-down;
vertical-align: middle;
pointer-events: none;
}
@ -507,7 +520,7 @@
width: 100%;
height: 100%;
max-height: 50vh;
object-fit: contain;
object-fit: scale-down;
vertical-align: middle;
}

View file

@ -28,7 +28,8 @@ import supports from '../utils/supports';
import useInterval from '../utils/useInterval';
import visibilityIconsMap from '../utils/visibility-icons-map';
import Avatar from './avatar';
import AccountBlock from './account-block';
// import Avatar from './avatar';
import Icon from './icon';
import Loader from './loader';
import Modal from './modal';
@ -508,11 +509,16 @@ function Compose({
<div id="compose-container" class={standalone ? 'standalone' : ''}>
<div class="compose-top">
{currentAccountInfo?.avatarStatic && (
<Avatar
url={currentAccountInfo.avatarStatic}
size="xl"
alt={currentAccountInfo.username}
squircle={currentAccountInfo?.bot}
// <Avatar
// url={currentAccountInfo.avatarStatic}
// size="xl"
// alt={currentAccountInfo.username}
// squircle={currentAccountInfo?.bot}
// />
<AccountBlock
account={currentAccountInfo}
accountInstance={currentAccount.instanceURL}
hideDisplayName
/>
)}
{!standalone ? (
@ -854,6 +860,7 @@ function Compose({
class="spoiler-text-field"
lang={language}
spellCheck="true"
dir="auto"
style={{
opacity: sensitive ? 1 : 0,
pointerEvents: sensitive ? 'auto' : 'none',
@ -1363,8 +1370,12 @@ const Textarea = forwardRef((props, ref) => {
onInput={(e) => {
const { scrollHeight, offsetHeight, clientHeight, value } = e.target;
setText(value);
const offset = offsetHeight - clientHeight;
e.target.style.height = value ? scrollHeight + offset + 'px' : null;
if (offsetHeight < window.innerHeight) {
// NOTE: This check is needed because the offsetHeight return 50000 (really large number) on first render
// No idea why it does that, will re-investigate in far future
const offset = offsetHeight - clientHeight;
e.target.style.height = value ? scrollHeight + offset + 'px' : null;
}
props.onInput?.(e);
}}
style={{
@ -1579,6 +1590,7 @@ function Poll({
placeholder={`Choice ${i + 1}`}
lang={lang}
spellCheck="true"
dir="auto"
onInput={(e) => {
const { value } = e.target;
options[i] = value;

View file

@ -96,6 +96,9 @@ export const ICONS = {
'arrow-down-circle': () =>
import('@iconify-icons/mingcute/arrow-down-circle-line'),
clipboard: () => import('@iconify-icons/mingcute/clipboard-line'),
'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'),
'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
};
function Icon({

View file

@ -0,0 +1,44 @@
#keyboard-shortcuts-help-container {
table {
tr > * {
border-top: 1px solid var(--outline-color);
vertical-align: middle;
}
th {
font-weight: normal;
text-align: start;
padding: 0.25em 0;
line-height: 1;
width: 60%;
}
td {
padding: 0.25em 1em;
}
}
kbd {
border-radius: 4px;
display: inline-block;
padding: 0.2em 0.3em;
margin: 1px 0;
line-height: 1;
border: 1px solid var(--outline-color);
background-color: var(--bg-faded-color);
background-image: linear-gradient(
to top,
var(--bg-blur-color),
transparent
);
text-shadow: 0 1px var(--bg-color);
box-shadow: 0 1px var(--drop-shadow-color),
0 1px 1px var(--drop-shadow-color), 0 1px 8px var(--drop-shadow-color),
inset 0 1px var(--bg-blur-color);
&:active {
box-shadow: 0 1px 4px var(--drop-shadow-color),
inset 0 1px var(--bg-blur-color);
transform: translateY(1px);
filter: brightness(0.95);
}
}
}

View file

@ -0,0 +1,165 @@
import './keyboard-shortcuts-help.css';
import { memo } from 'preact/compat';
import { useHotkeys } from 'react-hotkeys-hook';
import { useSnapshot } from 'valtio';
import states from '../utils/states';
import Icon from './icon';
import Modal from './modal';
export default memo(function KeyboardShortcutsHelp() {
const snapStates = useSnapshot(states);
function onClose() {
states.showKeyboardShortcutsHelp = false;
}
useHotkeys(
'?, shift+?',
(e) => {
console.log('help');
states.showKeyboardShortcutsHelp = true;
},
{
ignoreEventWhen: (e) => {
const hasModal = !!document.querySelector('#modal-container > *');
return hasModal;
},
},
);
const escRef = useHotkeys('esc', onClose, []);
return (
!!snapStates.showKeyboardShortcutsHelp && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
id="keyboard-shortcuts-help-container"
class="sheet"
tabindex="-1"
ref={escRef}
>
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<h2>Keyboard shortcuts</h2>
</header>
<main>
<table>
{[
{
action: 'Keyboard shortcuts help',
keys: <kbd>?</kbd>,
},
{
action: 'Next post',
keys: <kbd>j</kbd>,
},
{
action: 'Previous post',
keys: <kbd>k</kbd>,
},
{
action: 'Skip carousel to next post',
keys: (
<>
<kbd>Shift</kbd> + <kbd>j</kbd>
</>
),
},
{
action: 'Skip carousel to previous post',
keys: (
<>
<kbd>Shift</kbd> + <kbd>k</kbd>
</>
),
},
{
action: 'Open post details',
keys: (
<>
<kbd>Enter</kbd> or <kbd>o</kbd>
</>
),
},
{
action: 'Toggle expanded/collapsed thread',
keys: <kbd>x</kbd>,
},
{
action: 'Close post or dialogs',
keys: (
<>
<kbd>Esc</kbd> or <kbd>Backspace</kbd>
</>
),
},
{
action: 'Focus column in multi-column mode',
keys: (
<>
<kbd>1</kbd> to <kbd>9</kbd>
</>
),
},
{
action: 'Compose new post',
keys: <kbd>c</kbd>,
},
{
action: 'Send post',
keys: (
<>
<kbd>Ctrl</kbd> + <kbd>Enter</kbd> or <kbd></kbd> +{' '}
<kbd>Enter</kbd>
</>
),
},
{
action: 'Search',
keys: <kbd>/</kbd>,
},
{
action: 'Reply',
keys: <kbd>r</kbd>,
},
{
action: 'Favourite',
keys: <kbd>f</kbd>,
},
{
action: 'Boost',
keys: (
<>
<kbd>Shift</kbd> + <kbd>b</kbd>
</>
),
},
{
action: 'Bookmark',
keys: <kbd>d</kbd>,
},
].map(({ action, keys }) => (
<tr key={action}>
<th>{action}</th>
<td>{keys}</td>
</tr>
))}
</table>
</main>
</div>
</Modal>
)
);
});

View file

@ -19,7 +19,19 @@ const Link = forwardRef((props, ref) => {
let hash = (location.hash || '').replace(/^#/, '').trim();
if (hash === '') hash = '/';
const { to, ...restProps } = props;
const isActive = decodeURIComponent(hash) === to;
// Handle encodeURIComponent of searchParams values
if (!!hash && hash !== '/' && hash.includes('?')) {
const parsedHash = new URL(hash, location.origin); // Fake base URL
if (parsedHash.searchParams.size) {
const searchParamsStr = Array.from(parsedHash.searchParams.entries())
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
hash = parsedHash.pathname + '?' + searchParamsStr;
}
}
const isActive = hash === to || decodeURIComponent(hash) === to;
return (
<a
ref={ref}

View file

@ -95,6 +95,7 @@ function ListAddEdit({ list, onClose }) {
name="title"
required
disabled={uiState === 'loading'}
dir="auto"
/>
</label>
</div>

View file

@ -1,8 +1,9 @@
import './loader.css';
function Loader({ abrupt, hidden }) {
function Loader({ abrupt, hidden, ...props }) {
return (
<div
{...props}
class={`loader-container ${abrupt ? 'abrupt' : ''} ${
hidden ? 'hidden' : ''
}`}

View file

@ -326,7 +326,7 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
loading="lazy"
/>
<div class="media-play">
<Icon icon="play" size="xxl" />
<Icon icon="play" size="xl" />
</div>
</>
)}
@ -355,7 +355,7 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
) : null}
{!showOriginal && (
<div class="media-play">
<Icon icon="play" size="xxl" />
<Icon icon="play" size="xl" />
</div>
)}
</Parent>

View file

@ -1,5 +1,6 @@
import { Menu, MenuItem, SubMenu } from '@szhsin/react-menu';
import { cloneElement } from 'preact';
import { useRef } from 'preact/hooks';
function MenuConfirm({
subMenu = false,
@ -20,8 +21,10 @@ function MenuConfirm({
return children;
}
const Parent = subMenu ? SubMenu : Menu;
const menuRef = useRef();
return (
<Parent
instanceRef={menuRef}
openTrigger="clickOnly"
direction="bottom"
overflow="auto"
@ -31,6 +34,19 @@ function MenuConfirm({
{...restProps}
menuButton={subMenu ? undefined : children}
label={subMenu ? children : undefined}
// Test fix for bug; submenus not opening on Android
itemProps={{
onPointerMove: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
onPointerLeave: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
}}
>
<MenuItem className={menuItemClassName} onClick={onClick}>
{confirmLabel}

View file

@ -1,5 +1,7 @@
import './name-text.css';
import { memo } from 'preact/compat';
import states from '../utils/states';
import Avatar from './avatar';
@ -43,6 +45,7 @@ function NameText({
onClick={(e) => {
if (external) return;
e.preventDefault();
e.stopPropagation();
if (onClick) return onClick(e);
states.showAccount = {
account,
@ -86,4 +89,4 @@ function NameText({
);
}
export default NameText;
export default memo(NameText);

View file

@ -1,12 +1,22 @@
@media (min-width: 23em) {
.nav-menu {
display: flex;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
'top top'
'left right';
padding: 0;
width: 22em;
}
.nav-menu .top-menu {
grid-area: top;
padding-top: 8px;
margin-bottom: -8px;
}
.nav-menu section {
padding: 8px 0;
width: 50%;
/* width: 50%; */
}
@keyframes phanpying {
0% {
@ -49,3 +59,17 @@
width: 28em;
}
}
@keyframes sparkle-icon {
0% {
transform: scale(1);
color: var(--red-color);
}
100% {
transform: scale(1.2);
color: var(--orange-color);
}
}
.sparkle-icon {
animation: sparkle-icon 0.3s ease-in-out infinite alternate;
}

View file

@ -116,28 +116,28 @@ function NavMenu(props) {
boundingBoxPadding={boundingBoxPadding}
unmountOnClose
>
{!!snapStates.appVersion?.commitHash &&
__COMMIT_HASH__ !== snapStates.appVersion.commitHash && (
<div class="top-menu">
<MenuItem
onClick={() => {
const yes = confirm('Reload page now to update?');
if (yes) {
(async () => {
try {
location.reload();
} catch (e) {}
})();
}
}}
>
<Icon icon="sparkles" class="sparkle-icon" size="l" />{' '}
<span>New update available</span>
</MenuItem>
<MenuDivider />
</div>
)}
<section>
{!!snapStates.appVersion?.commitHash &&
__COMMIT_HASH__ !== snapStates.appVersion.commitHash && (
<>
<MenuItem
onClick={() => {
const yes = confirm('Reload page now to update?');
if (yes) {
(async () => {
try {
location.reload();
} catch (e) {}
})();
}
}}
>
<Icon icon="sparkles" size="l" />{' '}
<span>New update available</span>
</MenuItem>
<MenuDivider />
</>
)}
<MenuLink to="/">
<Icon icon="home" size="l" /> <span>Home</span>
</MenuLink>
@ -209,6 +209,14 @@ function NavMenu(props) {
>
<Icon icon="group" size="l" /> <span>Accounts&hellip;</span>
</MenuItem>
<MenuItem
onClick={() => {
states.showKeyboardShortcutsHelp = true;
}}
>
<Icon icon="keyboard" size="l" />{' '}
<span>Keyboard shortcuts</span>
</MenuItem>
<MenuItem
onClick={() => {
states.showShortcutsSettings = true;

View file

@ -0,0 +1,183 @@
import { memo } from 'preact/compat';
import { useLayoutEffect, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import { api } from '../utils/api';
import states from '../utils/states';
import {
getAccountByAccessToken,
getCurrentAccount,
} from '../utils/store-utils';
import usePageVisibility from '../utils/usePageVisibility';
import Icon from './icon';
import Link from './link';
import Modal from './modal';
import Notification from './notification';
export default memo(function NotificationService() {
if (!('serviceWorker' in navigator)) return null;
const snapStates = useSnapshot(states);
const { routeNotification } = snapStates;
console.log('🛎️ Notification service', routeNotification);
const { id, accessToken } = routeNotification || {};
const [showNotificationSheet, setShowNotificationSheet] = useState(false);
useLayoutEffect(() => {
if (!id || !accessToken) return;
const { instance: currentInstance } = api();
const { masto, instance } = api({
accessToken,
});
console.log('API', { accessToken, currentInstance, instance });
const sameInstance = currentInstance === instance;
const account = accessToken
? getAccountByAccessToken(accessToken)
: getCurrentAccount();
(async () => {
const notification = await masto.v1.notifications.fetch(id);
if (notification && account) {
console.log('🛎️ Notification', { id, notification, account });
const accountInstance = account.instanceURL;
const { type, status, account: notificationAccount } = notification;
const hasModal = !!document.querySelector('#modal-container > *');
const isFollow = type === 'follow' && !!notificationAccount?.id;
const hasAccount = !!notificationAccount?.id;
const hasStatus = !!status?.id;
if (isFollow && sameInstance) {
// Show account sheet, can handle different instances
states.showAccount = {
account: notificationAccount,
instance: accountInstance,
};
} else if (hasModal || !sameInstance || (hasAccount && hasStatus)) {
// Show sheet of notification, if
// - there is a modal open
// - the notification is from another instance
// - the notification has both account and status, gives choice for users to go to account or status
setShowNotificationSheet({
id,
account,
notification,
sameInstance,
});
} else {
if (hasStatus) {
// Go to status page
location.hash = `/${currentInstance}/s/${status.id}`;
} else if (isFollow) {
// Go to profile page
location.hash = `/${currentInstance}/a/${notificationAccount.id}`;
} else {
// Go to notifications page
location.hash = '/notifications';
}
}
} else {
console.warn(
'🛎️ Notification not found',
notificationID,
notificationAccessToken,
);
}
})();
}, [id, accessToken]);
useLayoutEffect(() => {
// Listen to message from service worker
const handleMessage = (event) => {
console.log('💥💥💥 Message event', event);
const { type, id, accessToken } = event?.data || {};
if (type === 'notification') {
states.routeNotification = {
id,
accessToken,
};
}
};
console.log('👂👂👂 Listen to message');
navigator.serviceWorker.addEventListener('message', handleMessage);
return () => {
console.log('👂👂👂 Remove listen to message');
navigator.serviceWorker.removeEventListener('message', handleMessage);
};
}, []);
usePageVisibility((visible) => {
if (visible && navigator?.clearAppBadge) {
console.log('🔰 Clear app badge');
navigator.clearAppBadge();
}
});
const onClose = () => {
setShowNotificationSheet(false);
states.routeNotification = null;
// If url is #/notifications?id=123, go to #/notifications
if (/\/notifications\?id=/i.test(location.hash)) {
location.hash = '/notifications';
}
};
if (showNotificationSheet) {
const { id, account, notification, sameInstance } = showNotificationSheet;
return (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div class="sheet" tabIndex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<b>Notification</b>
</header>
<main>
{!sameInstance && (
<p>This notification is from your other account.</p>
)}
<div
class="notification-peek"
// style={{
// pointerEvents: sameInstance ? '' : 'none',
// }}
onClick={(e) => {
const { target } = e;
// If button or links
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A') {
onClose();
}
}}
>
<Notification
instance={account.instanceURL}
notification={notification}
isStatic
/>
</div>
<div
style={{
textAlign: 'end',
}}
>
<Link to="/notifications" class="button light" onClick={onClose}>
<span>View all notifications</span> <Icon icon="arrow-right" />
</Link>
</div>
</main>
</div>
</Modal>
);
}
return null;
});

View file

@ -18,6 +18,8 @@ const NOTIFICATION_ICONS = {
favourite: 'heart',
poll: 'poll',
update: 'pencil',
'admin.signup': 'account-edit',
'admin.report': 'account-warning',
};
/*
@ -39,26 +41,40 @@ const contentText = {
mention: 'mentioned you in their post.',
status: 'published a post.',
reblog: 'boosted your post.',
'reblog+account': (count) => `boosted ${count} of your posts.`,
reblog_reply: 'boosted your reply.',
follow: 'followed you.',
follow_request: 'requested to follow you.',
favourite: 'favourited your post.',
'favourite+account': (count) => `favourited ${count} of your posts.`,
favourite_reply: 'favourited your reply.',
poll: 'A poll you have voted in or created has ended.',
'poll-self': 'A poll you have created has ended.',
'poll-voted': 'A poll you have voted in has ended.',
update: 'A post you interacted with has been edited.',
'favourite+reblog': 'boosted & favourited your post.',
'favourite+reblog+account': (count) =>
`boosted & favourited ${count} of your posts.`,
'favourite+reblog_reply': 'boosted & favourited your reply.',
'admin.signup': 'signed up.',
'admin.report': 'reported a post.',
};
function Notification({ notification, instance, reload }) {
const { id, status, account, _accounts } = notification;
function Notification({ notification, instance, reload, isStatic }) {
const { id, status, account, _accounts, _statuses } = notification;
let { type } = notification;
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
const actualStatusID = status?.reblog?.id || status?.id;
const actualStatus = status?.reblog || status;
const actualStatusID = actualStatus?.id;
const currentAccount = store.session.get('currentAccount');
const isSelf = currentAccount === account?.id;
const isVoted = status?.poll?.voted;
const isReplyToOthers =
!!status?.inReplyToAccountId &&
status?.inReplyToAccountId !== currentAccount &&
status?.account?.id === currentAccount;
let favsCount = 0;
let reblogsCount = 0;
@ -75,21 +91,46 @@ function Notification({ notification, instance, reload }) {
if (!favsCount && reblogsCount) type = 'reblog';
}
const text =
type === 'poll'
? contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll']
: contentText[type];
let text;
if (type === 'poll') {
text = contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll'];
} else if (
type === 'reblog' ||
type === 'favourite' ||
type === 'favourite+reblog'
) {
if (_statuses?.length > 1) {
text = contentText[`${type}+account`];
} else if (isReplyToOthers) {
text = contentText[`${type}_reply`];
} else {
text = contentText[type];
}
} else if (contentText[type]) {
text = contentText[type];
} else {
// Anticipate unhandled notification types, possibly from Mastodon forks or non-Mastodon instances
// This surfaces the error to the user, hoping that users will report it
text = `[Unknown notification type: ${type}]`;
}
if (typeof text === 'function') {
text = text(_statuses?.length || _accounts?.length);
}
if (type === 'mention' && !status) {
// Could be deleted
return null;
}
const formattedCreatedAt =
notification.createdAt && new Date(notification.createdAt).toLocaleString();
return (
<div class={`notification notification-${type}`} tabIndex="0">
<div
class={`notification-type notification-${type}`}
title={new Date(notification.createdAt).toLocaleString()}
title={formattedCreatedAt}
>
{type === 'favourite+reblog' ? (
<>
@ -189,7 +230,23 @@ function Notification({ notification, instance, reload }) {
))}
</p>
)}
{status && (
{_statuses?.length > 1 && (
<ul class="notification-group-statuses">
{_statuses.map((status) => (
<li key={status.id}>
<Link
class={`status-link status-type-${type}`}
to={
instance ? `/${instance}/s/${status.id}` : `/s/${status.id}`
}
>
<Status status={status} size="s" />
</Link>
</li>
))}
</ul>
)}
{status && (!_statuses?.length || _statuses?.length <= 1) && (
<Link
class={`status-link status-type-${type}`}
to={
@ -198,7 +255,11 @@ function Notification({ notification, instance, reload }) {
: `/s/${actualStatusID}`
}
>
<Status statusID={actualStatusID} size="s" />
{isStatic ? (
<Status status={actualStatus} size="s" />
) : (
<Status statusID={actualStatusID} size="s" />
)}
</Link>
)}
</div>

View file

@ -0,0 +1,54 @@
#search-command-container {
position: fixed;
inset: 0;
z-index: 1002;
background-color: var(--backdrop-darker-color);
background-image: radial-gradient(
farthest-corner at top,
var(--backdrop-color),
transparent
);
display: flex;
justify-content: center;
align-items: flex-start;
padding: 16px;
transition: opacity 0.1s ease-in-out;
}
#search-command-container[hidden] {
opacity: 0;
pointer-events: none;
}
#search-command-container form {
width: calc(40em - 32px);
max-width: 100%;
transition: transform 0.1s ease-in-out;
}
#search-command-container[hidden] form {
transform: translateY(-64px) scale(0.9);
}
#search-command-container input {
width: 100%;
padding: 16px;
border-radius: 999px;
background-color: var(--bg-faded-color);
border: 2px solid var(--outline-color);
box-shadow: 0 2px 16px var(--drop-shadow-color),
0 32px 64px var(--drop-shadow-color);
}
#search-command-container input:focus {
outline: 0;
background-color: var(--bg-color);
border-color: var(--link-color);
}
@media (min-width: 40em) {
#search-command-container {
align-items: center;
background-image: radial-gradient(
closest-side,
var(--backdrop-color),
transparent
);
}
}

View file

@ -0,0 +1,68 @@
import './search-command.css';
import { memo } from 'preact/compat';
import { useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import SearchForm from './search-form';
export default memo(function SearchCommand({ onClose = () => {} }) {
const [showSearch, setShowSearch] = useState(false);
const searchFormRef = useRef(null);
useHotkeys(
'/',
(e) => {
setShowSearch(true);
setTimeout(() => {
searchFormRef.current?.focus?.();
}, 0);
},
{
preventDefault: true,
ignoreEventWhen: (e) => {
const isSearchPage = /\/search/.test(location.hash);
const hasModal = !!document.querySelector('#modal-container > *');
return isSearchPage || hasModal;
},
},
);
const closeSearch = () => {
setShowSearch(false);
onClose();
};
useHotkeys(
'esc',
(e) => {
searchFormRef.current?.blur?.();
closeSearch();
},
{
enabled: showSearch,
enableOnFormTags: true,
preventDefault: true,
},
);
return (
<div
id="search-command-container"
hidden={!showSearch}
onClick={(e) => {
console.log(e);
if (e.target === e.currentTarget) {
closeSearch();
}
}}
>
<SearchForm
ref={searchFormRef}
onSubmit={() => {
closeSearch();
}}
/>
</div>
);
});

View file

@ -0,0 +1,244 @@
import { forwardRef } from 'preact/compat';
import { useImperativeHandle, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
import { api } from '../utils/api';
import Icon from './icon';
import Link from './link';
const SearchForm = forwardRef((props, ref) => {
const { instance } = api();
const [searchParams, setSearchParams] = useSearchParams();
const [searchMenuOpen, setSearchMenuOpen] = useState(false);
const [query, setQuery] = useState(searchParams.get('q') || '');
const type = searchParams.get('type');
const formRef = useRef(null);
const searchFieldRef = useRef(null);
useImperativeHandle(ref, () => ({
setValue: (value) => {
setQuery(value);
},
focus: () => {
searchFieldRef.current.focus();
},
blur: () => {
searchFieldRef.current.blur();
},
}));
return (
<form
ref={formRef}
class="search-popover-container"
onSubmit={(e) => {
e.preventDefault();
const isSearchPage = /\/search/.test(location.hash);
if (isSearchPage) {
if (query) {
const params = {
q: query,
};
if (type) params.type = type; // Preserve type
setSearchParams(params);
} else {
setSearchParams({});
}
} else {
if (query) {
location.hash = `/search?q=${encodeURIComponent(query)}${
type ? `&type=${type}` : ''
}`;
} else {
location.hash = `/search`;
}
}
props?.onSubmit?.(e);
}}
>
<input
ref={searchFieldRef}
value={query}
name="q"
type="search"
// autofocus
placeholder="Search"
dir="auto"
onSearch={(e) => {
if (!e.target.value) {
setSearchParams({});
}
}}
onInput={(e) => {
setQuery(e.target.value);
setSearchMenuOpen(true);
}}
onFocus={() => {
setSearchMenuOpen(true);
}}
onBlur={() => {
setTimeout(() => {
setSearchMenuOpen(false);
}, 100);
formRef.current
?.querySelector('.search-popover-item.focus')
?.classList.remove('focus');
}}
onKeyDown={(e) => {
const { key } = e;
switch (key) {
case 'Escape':
setSearchMenuOpen(false);
break;
case 'Down':
case 'ArrowDown':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = formRef.current.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let nextItem = focusItem.nextElementSibling;
while (nextItem && nextItem.hidden) {
nextItem = nextItem.nextElementSibling;
}
if (nextItem) {
nextItem.classList.add('focus');
const siblings = Array.from(
nextItem.parentElement.children,
).filter((el) => el !== nextItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const firstItem = formRef.current.querySelector(
'.search-popover-item',
);
if (firstItem) {
firstItem.classList.add('focus');
}
}
}
break;
case 'Up':
case 'ArrowUp':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let prevItem = focusItem.previousElementSibling;
while (prevItem && prevItem.hidden) {
prevItem = prevItem.previousElementSibling;
}
if (prevItem) {
prevItem.classList.add('focus');
const siblings = Array.from(
prevItem.parentElement.children,
).filter((el) => el !== prevItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const lastItem = document.querySelector(
'.search-popover-item:last-child',
);
if (lastItem) {
lastItem.classList.add('focus');
}
}
}
break;
case 'Enter':
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
e.preventDefault();
focusItem.click();
}
setSearchMenuOpen(false);
props?.onSubmit?.(e);
}
break;
}
}}
/>
<div class="search-popover" hidden={!searchMenuOpen || !query}>
{!!query &&
[
{
label: (
<>
Posts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=statuses`,
hidden: /^https?:/.test(query),
},
{
label: (
<>
Posts tagged with <mark>#{query.replace(/^#/, '')}</mark>
</>
),
to: `/${instance}/t/${query.replace(/^#/, '')}`,
hidden:
/^@/.test(query) || /^https?:/.test(query) || /\s/.test(query),
top: /^#/.test(query),
type: 'link',
},
{
label: (
<>
Look up <mark>{query}</mark>
</>
),
to: `/${query}`,
hidden: !/^https?:/.test(query),
top: /^https?:/.test(query),
type: 'link',
},
{
label: (
<>
Accounts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=accounts`,
},
]
.sort((a, b) => {
if (a.top && !b.top) return -1;
if (!a.top && b.top) return 1;
return 0;
})
.map(({ label, to, hidden, type }) => (
<Link
to={to}
class="search-popover-item"
hidden={hidden}
onClick={(e) => {
props?.onSubmit?.(e);
}}
>
<Icon
icon={type === 'link' ? 'arrow-right' : 'search'}
class="more-insignificant"
/>
<span>{label}</span>{' '}
</Link>
))}
</div>
</form>
);
});
export default SearchForm;

View file

@ -121,11 +121,14 @@
.status-card > * {
pointer-events: none;
}
.status-card :is(.content, .poll, .media-container) {
.status-card:not(.status-carousel .status)
:is(.content, .poll, .media-container) {
max-height: 160px !important;
overflow: hidden;
}
.status.small .status-card :is(.content, .poll, .media-container) {
.status.small:not(.status-carousel .status)
.status-card
:is(.content, .poll, .media-container) {
max-height: 80px !important;
}
.status-card :is(.content, .poll) {
@ -525,6 +528,23 @@
.timeline-deck .status .content.truncated ~ .card {
display: none;
}
.status .content .inner-content a:not(.mention, .has-url-text) {
color: var(--link-text-color);
}
.status
.content
.inner-content
a:not(.mention, .has-url-text):is(:hover, :focus) {
color: var(--text-color);
text-decoration-color: var(--link-color);
}
.status .content :is(.h-card, .mention) {
unicode-bidi: isolate;
}
.status .spoiler-content > *,
.status .content .inner-content > * {
unicode-bidi: plaintext;
}
.status .content p {
/* 12px = 75% of 16px */
margin-block: min(0.75em, 12px);
@ -581,7 +601,7 @@
grid-auto-rows: 1fr;
gap: 2px;
/* height: 160px; */
min-height: 44px;
min-height: 88px;
height: auto;
max-height: max(160px, 33vh);
}
@ -606,6 +626,7 @@
max-height: 60vh;
}
.status .media-container .media {
box-sizing: content-box;
--media-border-width: 1px;
--media-radius: 16px;
--media-radius-inner: 4px;
@ -625,9 +646,9 @@
.status .media-container.media-eq1 .media {
display: inline-block;
max-width: 100% !important;
min-width: 44px;
min-width: 88px;
/* width: auto; */
min-height: 44px;
min-height: 88px;
/* --maxAspectHeight: max(160px, 33vh);
--aspectWidth: calc(--width / --height * var(--maxAspectHeight)); */
width: min(var(--aspectWidth), var(--width), 100%);
@ -727,6 +748,7 @@
}
.status .media:active {
filter: brightness(0.8);
transform: scale(0.99);
}
.status .media :is(img, video) {
width: 100%;
@ -749,8 +771,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
.status .media video {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: calc(var(--media-radius) - var(--media-border-width));
object-fit: scale-down;
/* border-radius: calc(var(--media-radius) - var(--media-border-width)); */
border-radius: inherit;
}
.status :is(.media-video, .media-audio, .media-gif) {
position: relative;
@ -758,15 +781,16 @@ body:has(#modal-container .carousel) .status .media img:hover {
}
.status :is(.media-video, .media-audio)[data-formatted-duration] .media-play {
pointer-events: none;
width: 70px;
height: 70px;
width: 44px;
height: 44px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: var(--text-insignificant-color);
background-color: var(--bg-faded-blur-color);
backdrop-filter: blur(6px) saturate(3) invert(0.2);
color: var(--text-color);
background-color: var(--bg-blur-color);
/* backdrop-filter: blur(6px) saturate(3) invert(0.2); */
box-shadow: 0 0 16px var(--drop-shadow-color);
display: flex;
place-content: center;
place-items: center;
@ -774,10 +798,12 @@ body:has(#modal-container .carousel) .status .media img:hover {
transition: all 0.2s ease-in-out;
}
.status
:is(.media-video, .media-audio)[data-formatted-duration]:hover
:is(.media-video, .media-audio)[data-formatted-duration]:hover:not(:active)
.media-play {
color: var(--text-color);
background-color: var(--bg-blur-color);
transform: translate(-50%, -50%) scale(1.1);
background-color: var(--bg-color);
box-shadow: 0 0 16px var(--drop-shadow-color),
0 0 8px var(--drop-shadow-color);
}
.status :is(.media-video, .media-audio)[data-formatted-duration]:after {
font-size: 12px;
@ -818,7 +844,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
width: fit-content;
}
.status .media-contain video {
object-fit: contain !important;
object-fit: scale-down !important;
}
/* .status .media-audio {
border: 0;
@ -1371,7 +1397,7 @@ a.card:is(:hover, :focus) {
width: 1.2em;
height: 1.2em;
vertical-align: text-bottom;
object-fit: contain;
object-fit: scale-down;
}
/* EDIT HISTORY */

View file

@ -19,6 +19,7 @@ import {
useRef,
useState,
} from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useLongPress } from 'use-long-press';
import useResizeObserver from 'use-resize-observer';
@ -549,7 +550,9 @@ function Status({
</MenuHeader>
<MenuLink
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
onClick={onStatusLinkClick}
onClick={(e) => {
onStatusLinkClick(e, status);
}}
>
<Icon icon="arrow-right" />
<span>View post by @{username || acct}</span>
@ -621,8 +624,9 @@ function Status({
onClick={() => {
try {
favouriteStatus();
if (!isSizeLarge)
if (!isSizeLarge) {
showToast(favourited ? 'Unfavourited' : 'Favourited');
}
} catch (e) {}
}}
>
@ -644,8 +648,9 @@ function Status({
onClick={() => {
try {
bookmarkStatus();
if (!isSizeLarge)
if (!isSizeLarge) {
showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked');
}
} catch (e) {}
}}
>
@ -832,12 +837,77 @@ function Status({
const showContextMenu = size !== 'l' && !previewMode && !_deleted && !quoted;
const hotkeysEnabled = !readOnly && !previewMode;
const rRef = useHotkeys('r', replyStatus, {
enabled: hotkeysEnabled,
});
const fRef = useHotkeys(
'f',
() => {
try {
favouriteStatus();
if (!isSizeLarge) {
showToast(favourited ? 'Unfavourited' : 'Favourited');
}
} catch (e) {}
},
{
enabled: hotkeysEnabled,
},
);
const dRef = useHotkeys(
'd',
() => {
try {
bookmarkStatus();
if (!isSizeLarge) {
showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked');
}
} catch (e) {}
},
{
enabled: hotkeysEnabled,
},
);
const bRef = useHotkeys(
'shift+b',
() => {
(async () => {
try {
const done = await confirmBoostStatus();
if (!isSizeLarge && done) {
showToast(reblogged ? 'Unboosted' : 'Boosted');
}
} catch (e) {}
})();
},
{
enabled: hotkeysEnabled && canBoost,
},
);
return (
<article
ref={statusRef}
ref={(node) => {
statusRef.current = node;
// Use parent node if it's in focus
// Use case: <a><status /></a>
// When navigating (j/k), the <a> is focused instead of <status />
// Hotkey binding doesn't bubble up thus this hack
const nodeRef =
node?.closest?.(
'.timeline-item, .timeline-item-alt, .status-link, .status-focus',
) || node;
rRef.current = nodeRef;
fRef.current = nodeRef;
dRef.current = nodeRef;
bRef.current = nodeRef;
}}
tabindex="-1"
class={`status ${
!withinContext && inReplyToAccount ? 'status-reply-to' : ''
!withinContext && inReplyToId && inReplyToAccount
? 'status-reply-to'
: ''
} visibility-${visibility} ${_pinned ? 'status-pinned' : ''} ${
{
s: 'small',
@ -970,7 +1040,7 @@ function Status({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStatusLinkClick?.();
onStatusLinkClick?.(e, status);
}}
class={`time ${open ? 'is-open' : ''}`}
>
@ -1003,7 +1073,7 @@ function Status({
)}
{!withinContext && (
<>
{inReplyToAccountId === status.account?.id ||
{(!!inReplyToId && inReplyToAccountId === status.account?.id) ||
!!snapStates.statusThreadNumber[sKey] ? (
<div class="status-thread-badge">
<Icon icon="thread" size="s" />
@ -1045,7 +1115,7 @@ function Status({
{!!spoilerText && (
<>
<div
class="content"
class="content spoiler-content"
lang={language}
dir="auto"
ref={spoilerContentRef}
@ -1073,14 +1143,11 @@ function Status({
</button>
</>
)}
<div
class="content"
lang={language}
dir="auto"
ref={contentRef}
data-read-more={readMoreText}
>
<div class="content" ref={contentRef} data-read-more={readMoreText}>
<div
lang={language}
dir="auto"
class="inner-content"
onClick={handleContentLinks({ mentions, instance, previewMode })}
dangerouslySetInnerHTML={{
__html: enhanceContent(content, {
@ -1173,9 +1240,7 @@ function Status({
(option) =>
`- ${option.title}${
option.votesCount >= 0
? ` (${option.votesCount} vote${
option.votesCount !== 1 ? 's' : ''
})`
? ` (${option.votesCount})`
: ''
}`,
)
@ -1439,6 +1504,7 @@ function Card({ card, instance }) {
url,
type,
embedUrl,
language,
} = card;
/* type
@ -1502,6 +1568,7 @@ function Card({ card, instance }) {
target={cardStatusURL ? null : '_blank'}
rel="nofollow noopener noreferrer"
class={`card link ${blurhashImage ? '' : size}`}
lang={language}
>
<div class="card-image">
<img
@ -1570,6 +1637,7 @@ function Card({ card, instance }) {
target={cardStatusURL ? null : '_blank'}
rel="nofollow noopener noreferrer"
class={`card link no-image`}
lang={language}
>
<div class="meta-container">
<p class="meta domain">

File diff suppressed because it is too large Load diff

View file

@ -934,6 +934,11 @@
"Asturian",
"Asturianu"
],
[
"chr",
"Cherokee",
"ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ"
],
[
"ckb",
"Sorani (Kurdish)",
@ -994,6 +999,11 @@
"Toki Pona",
"toki pona"
],
[
"xal",
"Kalmyk",
"Хальмг келн"
],
[
"zba",
"Balaibalan",

View file

@ -30,6 +30,11 @@
--link-faded-color: #4169e155;
--link-bg-hover-color: #f0f2f599;
--link-visited-color: mediumslateblue;
--link-text-color: color-mix(
in lch,
var(--link-color) 60%,
var(--text-color) 40%
);
--focus-ring-color: var(--link-color);
--button-bg-color: var(--blue-color);
--button-bg-blur-color: #4169e1aa;
@ -47,6 +52,7 @@
--outline-hover-color: rgba(128, 128, 128, 0.7);
--divider-color: rgba(0, 0, 0, 0.1);
--backdrop-color: rgba(0, 0, 0, 0.05);
--backdrop-darker-color: rgba(0, 0, 0, 0.25);
--backdrop-solid-color: #ccc;
--img-bg-color: rgba(128, 128, 128, 0.2);
--loader-color: #1c1e2199;
@ -309,7 +315,8 @@ pre {
tab-size: 2;
}
pre code,
code {
code,
kbd {
font-size: 90%;
font-family: var(--monospace-font);
}

View file

@ -143,7 +143,7 @@ function Accounts({ onClose }) {
accounts.splice(i, 1);
store.local.setJSON('accounts', accounts);
// location.reload();
location.href = '/';
location.href = location.pathname || '/';
}}
>
<Icon icon="exit" />

View file

@ -128,6 +128,15 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
states.notificationsLast = notifications[0];
states.notifications = groupedNotifications;
// Update last read marker
masto.v1.markers
.create({
notifications: {
lastReadId: notifications[0].id,
},
})
.catch(() => {});
}
states.notificationsShowNew = false;

View file

@ -51,3 +51,11 @@
margin: 0;
padding: 0;
}
#instances-eg {
margin: 0.2em 0 0;
padding: 8px;
height: 2.5em;
color: var(--text-insignificant-color);
font-style: italic;
}

View file

@ -1,6 +1,7 @@
import './login.css';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
import Link from '../components/link';
import Loader from '../components/loader';
@ -15,8 +16,10 @@ function Login() {
const instanceURLRef = useRef();
const cachedInstanceURL = store.local.get('instanceURL');
const [uiState, setUIState] = useState('default');
const [searchParams] = useSearchParams();
const instance = searchParams.get('instance');
const [instanceText, setInstanceText] = useState(
cachedInstanceURL?.toLowerCase() || gtsDtth,
instance || cachedInstanceURL?.toLowerCase() || gtsDtth,
);
const [instancesList, setInstancesList] = useState([]);
@ -45,13 +48,15 @@ function Login() {
(async () => {
setUIState('loading');
try {
const { client_id, client_secret } = await registerApplication({
instanceURL,
});
const { client_id, client_secret, vapid_key } =
await registerApplication({
instanceURL,
});
if (client_id && client_secret) {
store.session.set('clientID', client_id);
store.session.set('clientSecret', client_secret);
store.session.set('vapidKey', vapid_key);
location.href = await getAuthorizationURL({
instanceURL,
@ -84,6 +89,24 @@ function Login() {
submitInstance(instanceURL);
};
const instancesSuggestions = instanceText
? instancesList
.filter((instance) => instance.includes(instanceText))
.sort((a, b) => {
// Move text that starts with instanceText to the start
const aStartsWith = a
.toLowerCase()
.startsWith(instanceText.toLowerCase());
const bStartsWith = b
.toLowerCase()
.startsWith(instanceText.toLowerCase());
if (aStartsWith && !bStartsWith) return -1;
if (!aStartsWith && bStartsWith) return 1;
return 0;
})
.slice(0, 10)
: [];
return (
<main id="login" style={{ textAlign: 'center' }}>
<form onSubmit={onSubmit}>
@ -108,11 +131,9 @@ function Login() {
setInstanceText(e.target.value);
}}
/>
<ul id="instances-suggestions">
{instancesList
.filter((instance) => instance.includes(instanceText))
.slice(0, 10)
.map((instance) => (
{instancesSuggestions?.length > 0 ? (
<ul id="instances-suggestions">
{instancesSuggestions.map((instance) => (
<li>
<button
type="button"
@ -125,7 +146,10 @@ function Login() {
</button>
</li>
))}
</ul>
</ul>
) : (
<div id="instances-eg">e.g. &ldquo;mastodon.social&rsquo;</div>
)}
{/* <datalist id="instances-list">
{instancesList.map((instance) => (
<option value={instance} />

View file

@ -92,6 +92,7 @@
filter: saturate(0.25);
}
.notification .status-link:not(.status-type-mention) > .status {
font-size: calc(var(--text-size) * 0.9);
max-height: 160px;
overflow: hidden;
/* fade out mask gradient bottom */
@ -134,6 +135,38 @@
margin-bottom: 8px;
}
.notification-group-statuses {
margin: 0;
padding: 0;
list-style: none;
}
.notification-group-statuses > li {
margin: 0;
padding: 0;
list-style: none;
position: relative;
counter-increment: index;
}
.notification-group-statuses > li:before {
content: counter(index);
position: absolute;
left: 0;
font-size: 10px;
padding: 8px;
font-weight: bold;
}
.notification-group-statuses > li + li {
margin-top: -1px;
}
.notification-group-statuses > li:not(:last-child) .status-link {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.notification-group-statuses > li:not(:first-child) .status-link {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
#mentions-option {
float: right;
margin-top: 0.5em;

View file

@ -3,6 +3,7 @@ import './notifications.css';
import { useIdle } from '@uidotdev/usehooks';
import { memo } from 'preact/compat';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
import AccountBlock from '../components/account-block';
@ -17,6 +18,7 @@ import enhanceContent from '../utils/enhance-content';
import groupNotifications from '../utils/group-notifications';
import handleContentLinks from '../utils/handle-content-links';
import niceDateTime from '../utils/nice-date-time';
import { getRegistration } from '../utils/push-notifications';
import shortenNumber from '../utils/shorten-number';
import states, { saveStatus } from '../utils/states';
import { getCurrentInstance } from '../utils/store-utils';
@ -24,12 +26,16 @@ import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
const LIMIT = 30; // 30 is the maximum limit :(
const emptySearchParams = new URLSearchParams();
function Notifications() {
function Notifications({ columnMode }) {
useTitle('Notifications', '/notifications');
const { masto, instance } = api();
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const [searchParams] = columnMode ? [emptySearchParams] : useSearchParams();
const notificationID = searchParams.get('id');
const notificationAccessToken = searchParams.get('access_token');
const [showMore, setShowMore] = useState(false);
const [onlyMentions, setOnlyMentions] = useState(false);
const scrollableRef = useRef();
@ -67,6 +73,15 @@ function Notifications() {
if (firstLoad) {
states.notificationsLast = notifications[0];
states.notifications = groupedNotifications;
// Update last read marker
masto.v1.markers
.create({
notifications: {
lastReadId: notifications[0].id,
},
})
.catch(() => {});
} else {
states.notifications.push(...groupedNotifications);
}
@ -188,6 +203,33 @@ function Notifications() {
const announcementsListRef = useRef();
useEffect(() => {
if (notificationID) {
states.routeNotification = {
id: notificationID,
accessToken: atob(notificationAccessToken),
};
}
}, [notificationID, notificationAccessToken]);
useEffect(() => {
if (uiState === 'default') {
(async () => {
try {
const registration = await getRegistration();
if (registration?.getNotifications) {
const notifications = await registration.getNotifications();
console.log('🔔 Push notifications', notifications);
// Close all notifications?
// notifications.forEach((notification) => {
// notification.close();
// });
}
} catch (e) {}
})();
}
}, [uiState]);
return (
<div
id="notifications-page"

View file

@ -1,13 +1,7 @@
import './search.css';
import { forwardRef } from 'preact/compat';
import {
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from 'preact/hooks';
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useParams, useSearchParams } from 'react-router-dom';
@ -16,6 +10,7 @@ import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import NavMenu from '../components/nav-menu';
import SearchForm from '../components/search-form';
import Status from '../components/status';
import { api } from '../utils/api';
import useTitle from '../utils/useTitle';
@ -125,15 +120,24 @@ function Search(props) {
}
useEffect(() => {
// searchFieldRef.current?.focus?.();
// searchFormRef.current?.focus?.();
if (q) {
// searchFieldRef.current.value = q;
searchFormRef.current?.setValue?.(q);
loadResults(true);
} else {
searchFormRef.current?.focus?.();
}
}, [q, type, instance]);
useHotkeys(
'/',
(e) => {
searchFormRef.current?.focus?.();
},
{
preventDefault: true,
},
);
return (
<div id="search-page" class="deck-container" ref={scrollableRef}>
<div class="timeline-deck deck">
@ -149,22 +153,26 @@ function Search(props) {
<main>
{!!q && (
<div class="filter-bar">
{!!type && <Link to={`/search${q ? `?q=${q}` : ''}`}> All</Link>}
{!!type && (
<Link to={`/search${q ? `?q=${encodeURIComponent(q)}` : ''}`}>
All
</Link>
)}
{[
{
label: 'Accounts',
type: 'accounts',
to: `/search?q=${q}&type=accounts`,
to: `/search?q=${encodeURIComponent(q)}&type=accounts`,
},
{
label: 'Hashtags',
type: 'hashtags',
to: `/search?q=${q}&type=hashtags`,
to: `/search?q=${encodeURIComponent(q)}&type=hashtags`,
},
{
label: 'Posts',
type: 'statuses',
to: `/search?q=${q}&type=statuses`,
to: `/search?q=${encodeURIComponent(q)}&type=statuses`,
},
]
.sort((a, b) => {
@ -358,212 +366,3 @@ function Search(props) {
}
export default Search;
const SearchForm = forwardRef((props, ref) => {
const { instance } = api();
const [searchParams, setSearchParams] = useSearchParams();
const [searchMenuOpen, setSearchMenuOpen] = useState(false);
const [query, setQuery] = useState(searchParams.get('q') || '');
const type = searchParams.get('type');
const formRef = useRef(null);
const searchFieldRef = useRef(null);
useImperativeHandle(ref, () => ({
setValue: (value) => {
setQuery(value);
},
focus: () => {
searchFieldRef.current.focus();
},
}));
return (
<form
ref={formRef}
class="search-popover-container"
onSubmit={(e) => {
e.preventDefault();
if (query) {
const params = {
q: query,
};
if (type) params.type = type; // Preserve type
setSearchParams(params);
} else {
setSearchParams({});
}
}}
>
<input
ref={searchFieldRef}
value={query}
name="q"
type="search"
// autofocus
placeholder="Search"
onSearch={(e) => {
if (!e.target.value) {
setSearchParams({});
}
}}
onInput={(e) => {
setQuery(e.target.value);
setSearchMenuOpen(true);
}}
onFocus={() => {
setSearchMenuOpen(true);
}}
onBlur={() => {
setTimeout(() => {
setSearchMenuOpen(false);
}, 100);
formRef.current
?.querySelector('.search-popover-item.focus')
?.classList.remove('focus');
}}
onKeyDown={(e) => {
const { key } = e;
switch (key) {
case 'Escape':
setSearchMenuOpen(false);
break;
case 'Down':
case 'ArrowDown':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = formRef.current.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let nextItem = focusItem.nextElementSibling;
while (nextItem && nextItem.hidden) {
nextItem = nextItem.nextElementSibling;
}
if (nextItem) {
nextItem.classList.add('focus');
const siblings = Array.from(
nextItem.parentElement.children,
).filter((el) => el !== nextItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const firstItem = formRef.current.querySelector(
'.search-popover-item',
);
if (firstItem) {
firstItem.classList.add('focus');
}
}
}
break;
case 'Up':
case 'ArrowUp':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let prevItem = focusItem.previousElementSibling;
while (prevItem && prevItem.hidden) {
prevItem = prevItem.previousElementSibling;
}
if (prevItem) {
prevItem.classList.add('focus');
const siblings = Array.from(
prevItem.parentElement.children,
).filter((el) => el !== prevItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const lastItem = document.querySelector(
'.search-popover-item:last-child',
);
if (lastItem) {
lastItem.classList.add('focus');
}
}
}
break;
case 'Enter':
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
e.preventDefault();
focusItem.click();
}
setSearchMenuOpen(false);
}
break;
}
}}
/>
<div class="search-popover" hidden={!searchMenuOpen || !query}>
{!!query &&
[
{
label: (
<>
Posts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=statuses`,
hidden: /^https?:/.test(query),
},
{
label: (
<>
Posts tagged with <mark>#{query.replace(/^#/, '')}</mark>
</>
),
to: `/${instance}/t/${query.replace(/^#/, '')}`,
hidden:
/^@/.test(query) || /^https?:/.test(query) || /\s/.test(query),
top: /^#/.test(query),
type: 'link',
},
{
label: (
<>
Look up <mark>{query}</mark>
</>
),
to: `/${query}`,
hidden: !/^https?:/.test(query),
top: /^https?:/.test(query),
type: 'link',
},
{
label: (
<>
Accounts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=accounts`,
},
]
.sort((a, b) => {
if (a.top && !b.top) return -1;
if (!a.top && b.top) return 1;
return 0;
})
.map(({ label, to, hidden, type }) => (
<Link to={to} class="search-popover-item" hidden={hidden}>
<Icon
icon={type === 'link' ? 'arrow-right' : 'search'}
class="more-insignificant"
/>
<span>{label}</span>{' '}
</Link>
))}
</div>
</form>
);
});

View file

@ -7,6 +7,7 @@
text-transform: uppercase;
color: var(--text-insignificant-color);
font-weight: normal;
padding-inline: 16px;
}
#settings-container section {
@ -128,3 +129,9 @@
gap: 4px;
align-items: flex-start;
}
#settings-container .section-postnote {
margin-bottom: 48px;
padding-inline: 16px;
color: var(--text-insignificant-color);
}

View file

@ -5,11 +5,18 @@ import { useSnapshot } from 'valtio';
import logo from '../assets/logo.svg';
import Icon from '../components/icon';
import Link from '../components/link';
import RelativeTime from '../components/relative-time';
import targetLanguages from '../data/lingva-target-languages';
import { api } from '../utils/api';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import localeCode2Text from '../utils/localeCode2Text';
import {
initSubscription,
isPushSupported,
removeSubscription,
updateSubscription,
} from '../utils/push-notifications';
import states from '../utils/states';
import store from '../utils/store';
@ -391,6 +398,7 @@ function Settings({ onClose }) {
</li>
</ul>
</section>
<PushNotificationsSection onClose={onClose} />
<h3>About</h3>
<section>
<div
@ -467,4 +475,244 @@ function Settings({ onClose }) {
);
}
function PushNotificationsSection({ onClose }) {
if (!isPushSupported()) return null;
const { instance } = api();
const [uiState, setUIState] = useState('default');
const pushFormRef = useRef();
const [allowNofitications, setAllowNotifications] = useState(false);
const [needRelogin, setNeedRelogin] = useState(false);
const previousPolicyRef = useRef();
useEffect(() => {
(async () => {
setUIState('loading');
try {
const { subscription, backendSubscription } = await initSubscription();
if (
backendSubscription?.policy &&
backendSubscription.policy !== 'none'
) {
setAllowNotifications(true);
const { alerts, policy } = backendSubscription;
previousPolicyRef.current = policy;
const { elements } = pushFormRef.current;
const policyEl = elements.namedItem(policy);
if (policyEl) policyEl.value = policy;
// alerts is {}, iterate it
Object.keys(alerts).forEach((alert) => {
const el = elements.namedItem(alert);
if (el?.type === 'checkbox') {
el.checked = true;
}
});
}
setUIState('default');
} catch (err) {
console.warn(err);
if (/outside.*authorized/i.test(err.message)) {
setNeedRelogin(true);
} else {
alert(err?.message || err);
}
setUIState('error');
}
})();
}, []);
const isLoading = uiState === 'loading';
return (
<form
ref={pushFormRef}
onChange={() => {
const values = Object.fromEntries(new FormData(pushFormRef.current));
const allowNofitications = !!values['policy-allow'];
const params = {
policy: values.policy,
data: {
alerts: {
mention: !!values.mention,
favourite: !!values.favourite,
reblog: !!values.reblog,
follow: !!values.follow,
follow_request: !!values.followRequest,
poll: !!values.poll,
update: !!values.update,
status: !!values.status,
},
},
};
let alertsCount = 0;
// Remove false values from data.alerts
// API defaults to false anyway
Object.keys(params.data.alerts).forEach((key) => {
if (!params.data.alerts[key]) {
delete params.data.alerts[key];
} else {
alertsCount++;
}
});
const policyChanged = previousPolicyRef.current !== params.policy;
console.log('PN Form', { values, allowNofitications, params });
if (allowNofitications && alertsCount > 0) {
if (policyChanged) {
console.debug('Policy changed.');
removeSubscription()
.then(() => {
updateSubscription(params);
})
.catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
});
} else {
updateSubscription(params).catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
});
}
} else {
removeSubscription().catch((err) => {
console.warn(err);
alert('Failed to remove subscription. Please try again.');
});
}
}}
>
<h3>Push Notifications (beta)</h3>
<section>
<ul>
<li>
<label>
<input
type="checkbox"
disabled={isLoading || needRelogin}
name="policy-allow"
checked={allowNofitications}
onChange={async (e) => {
const { checked } = e.target;
if (checked) {
// Request permission
const permission = await Notification.requestPermission();
if (permission === 'granted') {
setAllowNotifications(true);
} else {
setAllowNotifications(false);
if (permission === 'denied') {
alert(
'Push notifications are blocked. Please enable them in your browser settings.',
);
}
}
} else {
setAllowNotifications(false);
}
}}
/>{' '}
Allow from{' '}
<select
name="policy"
disabled={isLoading || needRelogin || !allowNofitications}
>
{[
{
value: 'all',
label: 'anyone',
},
{
value: 'followed',
label: 'people I follow',
},
{
value: 'follower',
label: 'followers',
},
].map((type) => (
<option value={type.value}>{type.label}</option>
))}
</select>
</label>
<div
class="shazam-container no-animation"
style={{
width: '100%',
}}
hidden={!allowNofitications}
>
<div class="shazam-container-inner">
<div class="sub-section">
<ul>
{[
{
value: 'mention',
label: 'Mentions',
},
{
value: 'favourite',
label: 'Favourites',
},
{
value: 'reblog',
label: 'Boosts',
},
{
value: 'follow',
label: 'Follows',
},
{
value: 'followRequest',
label: 'Follow requests',
},
{
value: 'poll',
label: 'Polls',
},
{
value: 'update',
label: 'Post edits',
},
{
value: 'status',
label: 'New posts',
},
].map((alert) => (
<li>
<label>
<input type="checkbox" name={alert.value} />{' '}
{alert.label}
</label>
</li>
))}
</ul>
</div>
</div>
</div>
{needRelogin && (
<div class="sub-section">
<p>
Push permission was not granted since your last login. You'll
need to{' '}
<Link to={`/login?instance=${instance}`} onClick={onClose}>
<b>log in</b> again to grant push permission
</Link>
.
</p>
</div>
)}
</li>
</ul>
</section>
<p class="section-postnote">
<small>
NOTE: Push notifications only work for <b>one account</b>.
</small>
</p>
</form>
);
}
export default Settings;

View file

@ -0,0 +1,9 @@
import { useParams } from 'react-router-dom';
import Status from './status';
export default function StatusRoute() {
const params = useParams();
const { id, instance } = params;
return <Status id={id} instance={instance} />;
}

View file

@ -48,9 +48,10 @@ const MAX_WEIGHT = 5;
let cachedRepliesToggle = {};
let cachedStatusesMap = {};
let scrollPositions = {};
function resetScrollPosition(id) {
delete cachedStatusesMap[id];
delete states.scrollPositions[id];
delete scrollPositions[id];
}
function StatusPage(params) {
@ -109,6 +110,23 @@ function StatusPage(params) {
? mediaStatus?.mediaAttachments
: heroStatus?.mediaAttachments;
const handleMediaClose = useCallback(() => {
if (
!window.matchMedia('(min-width: calc(40em + 350px))').matches &&
snapStates.prevLocation
) {
history.back();
} else {
if (showMediaOnly) {
location.hash = closeLink;
} else {
searchParams.delete('media');
searchParams.delete('mediaStatusID');
setSearchParams(searchParams);
}
}
}, [showMediaOnly, closeLink, snapStates.prevLocation]);
return (
<div class="deck-backdrop">
{showMedia ? (
@ -118,22 +136,7 @@ function StatusPage(params) {
statusID={mediaStatusID || id}
instance={instance}
index={mediaIndex - 1}
onClose={() => {
if (
!window.matchMedia('(min-width: calc(40em + 350px))').matches &&
snapStates.prevLocation
) {
history.back();
} else {
if (showMediaOnly) {
location.hash = closeLink;
} else {
searchParams.delete('media');
searchParams.delete('mediaStatusID');
setSearchParams(searchParams);
}
}
}}
onClose={handleMediaClose}
/>
) : (
<div class="media-modal-container loading">
@ -184,7 +187,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
if (!scrollableRef.current) return;
const { scrollTop } = scrollableRef.current;
if (uiState !== 'loading') {
states.scrollPositions[id] = scrollTop;
scrollPositions[id] = scrollTop;
}
}, 50);
scrollableRef.current?.addEventListener('scroll', onScroll, {
@ -336,6 +339,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
ancestor: true,
isThread: ancestorsIsThread,
accountID: s.account.id,
account: s.account,
repliesCount: s.repliesCount,
weight: calcStatusWeight(s),
})),
@ -390,7 +394,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
useEffect(() => {
if (!statuses.length) return;
console.debug('STATUSES', statuses);
const scrollPosition = snapStates.scrollPositions[id];
const scrollPosition = scrollPositions[id];
console.debug('scrollPosition', scrollPosition);
if (!!scrollPosition) {
console.debug('Case 1', {
@ -448,7 +452,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
useEffect(() => {
return () => {
// RESET
states.scrollPositions = {};
scrollPositions = {};
states.reloadStatusPage = 0;
cachedStatusesMap = {};
cachedRepliesToggle = {};
@ -632,6 +636,10 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
[id],
);
const handleStatusLinkClick = useCallback((e, status) => {
resetScrollPosition(status.id);
}, []);
return (
<div
tabIndex="-1"
@ -705,6 +713,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
block: 'start',
});
}}
title="Go to main post"
>
<Icon
icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'}
@ -727,12 +736,31 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
});
}}
hidden={!ancestors.length || nearReachStart}
title={`${ancestors.length} posts above Go to top`}
>
<Icon icon="arrow-up" />
<Icon icon="comment" />{' '}
<span class="insignificant">
{shortenNumber(ancestors.length)}
</span>
{ancestors
.filter(
(a, i, arr) =>
arr.findIndex((b) => b.accountID === a.accountID) === i,
)
.slice(0, 3)
.map((ancestor) => (
<Avatar
key={ancestor.account.id}
url={ancestor.account.avatar}
alt={ancestor.account.displayName}
/>
))}
{/* <Icon icon="comment" />{' '} */}
{ancestors.length > 3 && (
<>
{' '}
<span class="insignificant">
{shortenNumber(ancestors.length)}
</span>
</>
)}
</button>
</>
)}
@ -958,9 +986,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
size={thread || ancestor ? 'm' : 's'}
enableTranslate
onMediaClick={handleMediaClick}
onStatusLinkClick={() => {
resetScrollPosition(statusID);
}}
onStatusLinkClick={handleStatusLinkClick}
/>
{ancestor && isThread && repliesCount > 1 && (
<div class="replies-link">

View file

@ -16,6 +16,7 @@
padding-block: 60px;
height: 100vh;
height: 100svh;
max-height: 1024px;
display: flex;
flex-direction: column;
}

View file

@ -1,7 +1,12 @@
import { createClient } from 'masto';
import store from './store';
import { getAccount, getCurrentAccount, saveAccount } from './store-utils';
import {
getAccount,
getAccountByAccessToken,
getCurrentAccount,
saveAccount,
} from './store-utils';
// Default *fallback* instance
const DEFAULT_INSTANCE = 'mastodon.social';
@ -18,6 +23,7 @@ const apis = {};
// Just in case if I need this one day.
// E.g. accountApis['mastodon.social']['ACCESS_TOKEN']
const accountApis = {};
window.__ACCOUNT_APIS__ = accountApis;
// Current account masto instance
let currentAccountApi;
@ -92,7 +98,7 @@ export async function initInstance(client, instance) {
}
// Get the account information and store it
export async function initAccount(client, instance, accessToken) {
export async function initAccount(client, instance, accessToken, vapidKey) {
const masto = client;
const mastoAccount = await masto.v1.accounts.verifyCredentials();
@ -102,6 +108,7 @@ export async function initAccount(client, instance, accessToken) {
info: mastoAccount,
instanceURL: instance.toLowerCase(),
accessToken,
vapidKey,
});
}
@ -136,6 +143,35 @@ export function api({ instance, accessToken, accountID, account } = {}) {
};
}
if (accessToken) {
// If only accessToken is provided, get the masto instance for that accessToken
console.log('X 1', accountApis);
for (const instance in accountApis) {
if (accountApis[instance][accessToken]) {
console.log('X 2', accountApis, instance, accessToken);
return {
masto: accountApis[instance][accessToken],
authenticated: true,
instance,
};
} else {
console.log('X 3', accountApis, instance, accessToken);
const account = getAccountByAccessToken(accessToken);
if (account) {
const accessToken = account.accessToken;
const instance = account.instanceURL.toLowerCase().trim();
return {
masto: initClient({ instance, accessToken }),
authenticated: true,
instance,
};
} else {
throw new Error(`Access token ${accessToken} not found`);
}
}
}
}
// If account is provided, get the masto instance for that account
if (account || accountID) {
account = account || getAccount(accountID);

View file

@ -1,11 +1,13 @@
const { VITE_CLIENT_NAME: CLIENT_NAME, VITE_WEBSITE: WEBSITE } = import.meta
.env;
const SCOPES = 'read write follow push';
export async function registerApplication({ instanceURL }) {
const registrationParams = new URLSearchParams({
client_name: CLIENT_NAME,
redirect_uris: location.origin,
scopes: 'read write follow',
redirect_uris: location.origin + location.pathname,
scopes: SCOPES,
website: WEBSITE,
});
const registrationResponse = await fetch(
@ -26,8 +28,8 @@ export async function registerApplication({ instanceURL }) {
export async function getAuthorizationURL({ instanceURL, client_id }) {
const authorizationParams = new URLSearchParams({
client_id,
scope: 'read write follow',
redirect_uri: location.origin,
scope: SCOPES,
redirect_uri: location.origin + location.pathname,
// redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
response_type: 'code',
});
@ -44,10 +46,10 @@ export async function getAccessToken({
const params = new URLSearchParams({
client_id,
client_secret,
redirect_uri: location.origin,
redirect_uri: location.origin + location.pathname,
grant_type: 'authorization_code',
code,
scope: 'read write follow',
scope: SCOPES,
});
const tokenResponse = await fetch(`https://${instanceURL}/oauth/token`, {
method: 'POST',

View file

@ -21,17 +21,38 @@ function enhanceContent(content, opts = {}) {
});
}
// Add 'has-url-text' to all links that contains a url
if (hasLink) {
const links = Array.from(dom.querySelectorAll('a[href]'));
links.forEach((link) => {
if (/^https?:\/\//i.test(link.textContent.trim())) {
link.classList.add('has-url-text');
}
});
}
// Spanify un-spanned mentions
if (hasLink) {
const notMentionLinks = Array.from(dom.querySelectorAll('a[href]'));
notMentionLinks.forEach((link) => {
const links = Array.from(dom.querySelectorAll('a[href]'));
const usernames = [];
links.forEach((link) => {
const text = link.innerText.trim();
const hasChildren = link.querySelector('*');
// If text looks like @username@domain, then it's a mention
if (/^@[^@]+(@[^@]+)?$/g.test(text)) {
// Only show @username
const username = text.split('@')[1];
if (!hasChildren) link.innerHTML = `@<span>${username}</span>`;
const [_, username, domain] = text.split('@');
if (!hasChildren) {
if (
!usernames.find(([u]) => u === username) ||
usernames.find(([u, d]) => u === username && d === domain)
) {
link.innerHTML = `@<span>${username}</span>`;
usernames.push([username, domain]);
} else {
link.innerHTML = `@<span>${username}@${domain}</span>`;
}
}
link.classList.add('mention');
}
// If text looks like #hashtag, then it's a hashtag
@ -110,7 +131,7 @@ function enhanceContent(content, opts = {}) {
p.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
});
const codeText = nextParagraphs.map((p) => p.innerHTML).join('\n\n');
pre.innerHTML = `<code>${codeText}</code>`;
pre.innerHTML = `<code tabindex="0">${codeText}</code>`;
block.replaceWith(pre);
nextParagraphs.forEach((p) => p.remove());
}
@ -164,35 +185,49 @@ function enhanceContent(content, opts = {}) {
// ================
// Get the <p> that contains a lot of hashtags, add a class to it
if (enhancedContent.indexOf('#') !== -1) {
const hashtagStuffedParagraph = Array.from(dom.querySelectorAll('p')).find(
(p) => {
let hashtagCount = 0;
for (let i = 0; i < p.childNodes.length; i++) {
const node = p.childNodes[i];
let prevIndex = null;
const hashtagStuffedParagraphs = Array.from(
dom.querySelectorAll('p'),
).filter((p, index) => {
let hashtagCount = 0;
for (let i = 0; i < p.childNodes.length; i++) {
const node = p.childNodes[i];
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent.trim();
if (text !== '') {
return false;
}
} else if (node.tagName === 'A') {
const linkText = node.textContent.trim();
if (!linkText || !linkText.startsWith('#')) {
return false;
} else {
hashtagCount++;
}
} else {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent.trim();
if (text !== '') {
return false;
}
} else if (node.tagName === 'BR') {
// Ignore <br />
} else if (node.tagName === 'A') {
const linkText = node.textContent.trim();
if (!linkText || !linkText.startsWith('#')) {
return false;
} else {
hashtagCount++;
}
} else {
return false;
}
// Only consider "stuffing" if there are more than 3 hashtags
return hashtagCount > 3;
},
);
if (hashtagStuffedParagraph) {
hashtagStuffedParagraph.classList.add('hashtag-stuffing');
hashtagStuffedParagraph.title = hashtagStuffedParagraph.innerText;
}
// Only consider "stuffing" if:
// - there are more than 3 hashtags
// - there are more than 1 hashtag in adjacent paragraphs
if (hashtagCount > 3) {
prevIndex = index;
return true;
}
if (hashtagCount > 1 && prevIndex && index === prevIndex + 1) {
prevIndex = index;
return true;
}
});
if (hashtagStuffedParagraphs?.length) {
hashtagStuffedParagraphs.forEach((p) => {
p.classList.add('hashtag-stuffing');
p.title = p.innerText;
});
}
}

View file

@ -37,7 +37,40 @@ function groupNotifications(notifications) {
cleanNotifications[j++] = n;
}
}
return cleanNotifications;
// 2nd pass to group "favourite+reblog"-type notifications by account if _accounts.length <= 1
// This means one acount has favourited and reblogged the multiple statuses
// The grouped notification
// - type: "favourite+reblog+account"
// - _statuses: [status, status, ...]
const notificationsMap2 = {};
const cleanNotifications2 = [];
for (let i = 0, j = 0; i < cleanNotifications.length; i++) {
const notification = cleanNotifications[i];
const { account, _accounts, type, createdAt } = notification;
const date = new Date(createdAt).toLocaleDateString();
if (type === 'favourite+reblog' && account && _accounts.length === 1) {
const key = `${account?.id}-${type}-${date}`;
const mappedNotification = notificationsMap2[key];
if (mappedNotification) {
mappedNotification._statuses.push(notification.status);
} else {
let n = (notificationsMap2[key] = {
...notification,
type,
_statuses: [notification.status],
});
cleanNotifications2[j++] = n;
}
} else {
cleanNotifications2[j++] = notification;
}
}
console.log({ notifications, cleanNotifications, cleanNotifications2 });
// return cleanNotifications;
return cleanNotifications2;
}
export default groupNotifications;

View file

@ -8,7 +8,11 @@ function handleContentLinks(opts) {
if (!target) return;
const prevText = target.previousSibling?.textContent;
const textBeforeLinkIsAt = prevText?.endsWith('@');
if (target.classList.contains('u-url') || textBeforeLinkIsAt) {
const textStartsWithAt = target.innerText.startsWith('@');
if (
(target.classList.contains('u-url') && textStartsWithAt) ||
(textBeforeLinkIsAt && !textStartsWithAt)
) {
const targetText = (
target.querySelector('span') || target
).innerText.trim();

View file

@ -5,7 +5,7 @@ export default function openCompose(opts) {
const top = Math.max(0, (screenHeight - 450) / 2);
const width = Math.min(screenWidth, 600);
const height = Math.min(screenHeight, 450);
const winUID = opts.uid || Math.random();
const winUID = opts?.uid || Math.random();
const newWin = window.open(
url,
'compose' + winUID,

View file

@ -0,0 +1,233 @@
// Utils for push notifications
import { api } from './api';
import { getCurrentAccount } from './store-utils';
// Subscription is an object with the following structure:
// {
// data: {
// alerts: {
// admin: {
// report: boolean,
// signUp: boolean,
// },
// favourite: boolean,
// follow: boolean,
// mention: boolean,
// poll: boolean,
// reblog: boolean,
// status: boolean,
// update: boolean,
// }
// },
// policy: "all" | "followed" | "follower" | "none",
// subscription: {
// endpoint: string,
// keys: {
// auth: string,
// p256dh: string,
// },
// },
// }
// Back-end CRUD
// =============
function createBackendPushSubscription(subscription) {
const { masto } = api();
return masto.v1.webPushSubscriptions.create(subscription);
}
function fetchBackendPushSubscription() {
const { masto } = api();
return masto.v1.webPushSubscriptions.fetch();
}
function updateBackendPushSubscription(subscription) {
const { masto } = api();
return masto.v1.webPushSubscriptions.update(subscription);
}
function removeBackendPushSubscription() {
const { masto } = api();
return masto.v1.webPushSubscriptions.remove();
}
// Front-end
// =========
export function isPushSupported() {
return 'serviceWorker' in navigator && 'PushManager' in window;
}
export function getRegistration() {
// return navigator.serviceWorker.ready;
return navigator.serviceWorker.getRegistration();
}
async function getSubscription() {
const registration = await getRegistration();
const subscription = registration
? await registration.pushManager.getSubscription()
: undefined;
return { registration, subscription };
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = `${base64String}${padding}`
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Front-end <-> back-end
// ======================
export async function initSubscription() {
if (!isPushSupported()) return;
const { subscription } = await getSubscription();
let backendSubscription = null;
try {
backendSubscription = await fetchBackendPushSubscription();
} catch (err) {
if (/(not found|unknown)/i.test(err.message)) {
// No subscription found
} else {
// Other error
throw err;
}
}
console.log('INIT subscription', {
subscription,
backendSubscription,
});
// Check if the subscription changed
if (backendSubscription && subscription) {
const sameEndpoint = backendSubscription.endpoint === subscription.endpoint;
const { vapidKey } = getCurrentAccount();
const sameKey = backendSubscription.serverKey === vapidKey;
if (!sameEndpoint) {
throw new Error('Backend subscription endpoint changed');
}
if (sameKey) {
// Subscription didn't change
} else {
// Subscription changed
console.error('🔔 Subscription changed', {
sameEndpoint,
serverKey: backendSubscription.serverKey,
vapIdKey: vapidKey,
endpoint1: backendSubscription.endpoint,
endpoint2: subscription.endpoint,
sameKey,
key1: backendSubscription.serverKey,
key2: vapidKey,
});
throw new Error('Backend subscription key and vapid key changed');
// Only unsubscribe from backend, not from browser
// await removeBackendPushSubscription();
// // Now let's resubscribe
// // NOTE: I have no idea if this works
// return await updateSubscription({
// data: backendSubscription.data,
// policy: backendSubscription.policy,
// });
}
}
if (subscription && !backendSubscription) {
// check if account's vapidKey is same as subscription's applicationServerKey
const { vapidKey } = getCurrentAccount();
const { applicationServerKey } = subscription.options;
const vapidKeyStr = urlBase64ToUint8Array(vapidKey).toString();
const applicationServerKeyStr = new Uint8Array(
applicationServerKey,
).toString();
const sameKey = vapidKeyStr === applicationServerKeyStr;
if (sameKey) {
// Subscription didn't change
} else {
// Subscription changed
console.error('🔔 Subscription changed', {
vapidKeyStr,
applicationServerKeyStr,
sameKey,
});
// Unsubscribe since backend doesn't have a subscription
await subscription.unsubscribe();
throw new Error('Subscription key and vapid key changed');
}
}
// Check if backend subscription returns 404
// if (subscription && !backendSubscription) {
// // Re-subscribe to backend
// backendSubscription = await createBackendPushSubscription({
// subscription,
// data: {},
// policy: 'all',
// });
// }
return { subscription, backendSubscription };
}
export async function updateSubscription({ data, policy }) {
console.log('🔔 Updating subscription', { data, policy });
if (!isPushSupported()) return;
let { registration, subscription } = await getSubscription();
let backendSubscription = null;
if (subscription) {
try {
backendSubscription = await updateBackendPushSubscription({
data,
policy,
});
// TODO: save subscription in user settings
} catch (error) {
// Backend doesn't have a subscription for this user
// Create a new one
backendSubscription = await createBackendPushSubscription({
subscription,
data,
policy,
});
// TODO: save subscription in user settings
}
} else {
// User is not subscribed
const { vapidKey } = getCurrentAccount();
if (!vapidKey) throw new Error('No server key found');
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
});
backendSubscription = await createBackendPushSubscription({
subscription,
data,
policy,
});
// TODO: save subscription in user settings
}
return { subscription, backendSubscription };
}
export async function removeSubscription() {
if (!isPushSupported()) return;
const { subscription } = await getSubscription();
if (subscription) {
await removeBackendPushSubscription();
await subscription.unsubscribe();
}
}

View file

@ -1,31 +1,35 @@
import mem from 'mem';
const root = document.documentElement;
const style = getComputedStyle(root);
const defaultBoundingBoxPadding = 8;
function _safeBoundingBoxPadding(paddings = []) {
// paddings = [top, right, bottom, left]
let safeAreaInsets = [0, 0, 0, 0];
function getSafeAreaInsets() {
// Get safe area inset variables from root
const safeAreaInsetTop = style.getPropertyValue('--sai-top');
const safeAreaInsetRight = style.getPropertyValue('--sai-right');
const safeAreaInsetBottom = style.getPropertyValue('--sai-bottom');
const safeAreaInsetLeft = style.getPropertyValue('--sai-left');
const str = [
safeAreaInsetTop,
safeAreaInsetRight,
safeAreaInsetBottom,
safeAreaInsetLeft,
]
.map(
(v, i) =>
(parseInt(v, 10) || defaultBoundingBoxPadding) + (paddings[i] || 0),
)
safeAreaInsets = [
// top, right, bottom, left (clockwise)
Math.max(0, parseInt(safeAreaInsetTop, 10)),
Math.max(0, parseInt(safeAreaInsetRight, 10)),
Math.max(0, parseInt(safeAreaInsetBottom, 10)),
Math.max(0, parseInt(safeAreaInsetLeft, 10)),
];
}
requestAnimationFrame(getSafeAreaInsets);
function safeBoundingBoxPadding(paddings = []) {
const str = safeAreaInsets
.map((v, i) => (v || defaultBoundingBoxPadding) + (paddings[i] || 0))
.join(' ');
// console.log(str);
return str;
}
const safeBoundingBoxPadding = mem(_safeBoundingBoxPadding, {
maxAge: 10000, // 10 seconds
});
// Update safe area insets when orientation or resize
if (CSS.supports('top: env(safe-area-inset-top)')) {
window.addEventListener('resize', getSafeAreaInsets, { passive: true });
}
export default safeBoundingBoxPadding;

View file

@ -29,6 +29,7 @@ const states = proxy({
unfurledLinks: {},
statusQuotes: {},
accounts: {},
routeNotification: null,
// Modals
showCompose: false,
showSettings: false,
@ -37,6 +38,7 @@ const states = proxy({
showDrafts: false,
showMediaModal: false,
showShortcutsSettings: false,
showKeyboardShortcutsHelp: false,
// Shortcuts
shortcuts: store.account.get('shortcuts') ?? [],
// Settings
@ -60,6 +62,30 @@ const states = proxy({
export default states;
export function initStates() {
// init all account based states
// all keys that uses store.account.get() should be initialized here
states.notificationsLast = store.account.get('notificationsLast') || null;
states.shortcuts = store.account.get('shortcuts') ?? [];
states.settings.autoRefresh =
store.account.get('settings-autoRefresh') ?? false;
states.settings.shortcutsViewMode =
store.account.get('settings-shortcutsViewMode') ?? null;
states.settings.shortcutsColumnsMode =
store.account.get('settings-shortcutsColumnsMode') ?? false;
states.settings.boostsCarousel =
store.account.get('settings-boostsCarousel') ?? true;
states.settings.contentTranslation =
store.account.get('settings-contentTranslation') ?? true;
states.settings.contentTranslationTargetLanguage =
store.account.get('settings-contentTranslationTargetLanguage') || null;
states.settings.contentTranslationHideLanguages =
store.account.get('settings-contentTranslationHideLanguages') || [];
states.settings.contentTranslationAutoInline =
store.account.get('settings-contentTranslationAutoInline') ?? false;
states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false;
}
subscribeKey(states, 'notificationsLast', (v) => {
console.log('CHANGE', v);
store.account.set('notificationsLast', states.notificationsLast);
@ -112,6 +138,7 @@ export function hideAllModals() {
states.showDrafts = false;
states.showMediaModal = false;
states.showShortcutsSettings = false;
states.showKeyboardShortcutsHelp = false;
}
export function statusKey(id, instance) {

View file

@ -5,6 +5,11 @@ export function getAccount(id) {
return accounts.find((a) => a.info.id === id) || accounts[0];
}
export function getAccountByAccessToken(accessToken) {
const accounts = store.local.getJSON('accounts') || [];
return accounts.find((a) => a.accessToken === accessToken);
}
export function getCurrentAccount() {
const currentAccount = store.session.get('currentAccount');
const account = getAccount(currentAccount);
@ -27,6 +32,7 @@ export function saveAccount(account) {
acc.info = account.info;
acc.instanceURL = account.instanceURL;
acc.accessToken = account.accessToken;
acc.vapidKey = account.vapidKey;
} else {
accounts.push(account);
}

View file

@ -30,7 +30,8 @@ export default function useTitle(title, path) {
}
useLayoutEffect(() => {
const unsub = subscribeKey(states, 'currentLocation', setTitle);
setTitle();
return subscribeKey(states, 'currentLocation', setTitle);
return unsub;
}, [title, path]);
}

View file

@ -28,6 +28,7 @@ const rollbarCode = fs.readFileSync(
// https://vitejs.dev/config/
export default defineConfig({
base: './',
mode: NODE_ENV,
define: {
__BUILD_TIME__: JSON.stringify(now),