commit
8fc5e10be9
|
@ -200,6 +200,7 @@ These are self-hosted by other wonderful folks.
|
|||
- [phanpy.bauxite.tech](https://phanpy.bauxite.tech) by [@b4ux1t3@hachyderm.io](https://hachyderm.io/@b4ux1t3)
|
||||
- [phanpy.hear-me.social](https://phanpy.hear-me.social) by [@admin@hear-me.social](https://hear-me.social/@admin)
|
||||
- [phanpy.fulda.social](https://phanpy.fulda.social) by [@Ganneff@fulda.social](https://fulda.social/@Ganneff)
|
||||
- [phanpy.crmbl.uk](https://phanpy.crmbl.uk) by [@snail@crmbl.uk](https://mstdn.crmbl.uk/@snail)
|
||||
|
||||
> Note: Add yours by creating a pull request.
|
||||
|
||||
|
@ -250,6 +251,7 @@ And here I am. Building a Mastodon web client.
|
|||
- [Statuzer](https://statuzer.com/)
|
||||
- [Tusked](https://tusked.app/)
|
||||
- [Mastodon Glitch Edition (standalone frontend)](https://iceshrimp.dev/iceshrimp/masto-fe-standalone)
|
||||
- [Mangane](https://github.com/BDX-town/Mangane)
|
||||
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
|
||||
|
||||
## 💁♂️ Notice to all other social media client developers
|
||||
|
|
572
package-lock.json
generated
572
package-lock.json
generated
|
@ -25,11 +25,11 @@
|
|||
"idb-keyval": "~6.2.1",
|
||||
"just-debounce-it": "~3.2.0",
|
||||
"lz-string": "~1.5.0",
|
||||
"masto": "~6.6.4",
|
||||
"masto": "~6.7.0",
|
||||
"moize": "~6.1.6",
|
||||
"p-retry": "~6.2.0",
|
||||
"p-throttle": "~6.1.0",
|
||||
"preact": "~10.19.6",
|
||||
"preact": "~10.20.1",
|
||||
"react-hotkeys-hook": "~4.5.0",
|
||||
"react-intersection-observer": "~9.8.1",
|
||||
"react-quick-pinch-zoom": "~5.1.0",
|
||||
|
@ -46,14 +46,14 @@
|
|||
"devDependencies": {
|
||||
"@preact/preset-vite": "~2.8.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "~4.3.0",
|
||||
"postcss": "~8.4.35",
|
||||
"postcss": "~8.4.38",
|
||||
"postcss-dark-theme-class": "~1.2.1",
|
||||
"postcss-preset-env": "~9.5.1",
|
||||
"postcss-preset-env": "~9.5.2",
|
||||
"twitter-text": "~3.1.0",
|
||||
"vite": "~5.1.6",
|
||||
"vite": "~5.2.6",
|
||||
"vite-plugin-generate-file": "~0.1.1",
|
||||
"vite-plugin-html-config": "~1.0.11",
|
||||
"vite-plugin-pwa": "~0.19.4",
|
||||
"vite-plugin-pwa": "~0.19.7",
|
||||
"vite-plugin-remove-console": "~2.2.0",
|
||||
"workbox-cacheable-response": "~7.0.0",
|
||||
"workbox-expiration": "~7.0.0",
|
||||
|
@ -2015,9 +2015,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-1.6.0.tgz",
|
||||
"integrity": "sha512-Wc1X6jZvGhT8Bii4jUF6tC3Je3wgDFg7D/SvGKndrnakDsCPk4TMxtt4AQHyWdMBrBJ1hLjXbppaXgP1DUIpBw==",
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-1.6.2.tgz",
|
||||
"integrity": "sha512-mlt0PomBlDXMGcbPAqCG36Fw35LZTtaSgCQCHEs4k8QTv1cUKe0rJDlFSJMHtqrgQiLC7LAAS9+s9kKQp2ou/Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -2132,9 +2132,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@csstools/postcss-color-function": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-3.0.11.tgz",
|
||||
"integrity": "sha512-z53Pp2tsemiIq72PKu4vjD0CtcQlXdvA22elEHuDOvCIlqphNjd5ZD5HBns/ZjaJF7BjPls2zaAT58hfLyS0MQ==",
|
||||
"version": "3.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-3.0.12.tgz",
|
||||
"integrity": "sha512-amPGGDI4Xmgu7VN2ciKQe0pP/j5raaETT50nzbnkydp9FMw7imKxSUnXdVQU4NmRgpLKIc5Q7jox0MFhMBImIg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -2147,7 +2147,7 @@
|
|||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@csstools/css-color-parser": "^1.6.0",
|
||||
"@csstools/css-color-parser": "^1.6.2",
|
||||
"@csstools/css-parser-algorithms": "^2.6.1",
|
||||
"@csstools/css-tokenizer": "^2.2.4",
|
||||
"@csstools/postcss-progressive-custom-properties": "^3.1.1",
|
||||
|
@ -2161,9 +2161,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@csstools/postcss-color-mix-function": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-2.0.11.tgz",
|
||||
"integrity": "sha512-Jz1R5ZXxpT5FIY95F3VSJtwQYWCYOtCBUBS/ShDxS+fUtd3sAdAtD3a9tAdz3FG3BvkmqtlURyoIhJRu/wfo/A==",
|
||||
"version": "2.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-2.0.12.tgz",
|
||||
"integrity": "sha512-qpAEGwVVqHSa88i3gLb43IMpT4/LyZEE8HzZylQKKXFVJ7XykXaORTmXySxyH6H+flT+NyCnutKG2fegCVyCug==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -2176,7 +2176,7 @@
|
|||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@csstools/css-color-parser": "^1.6.0",
|
||||
"@csstools/css-color-parser": "^1.6.2",
|
||||
"@csstools/css-parser-algorithms": "^2.6.1",
|
||||
"@csstools/css-tokenizer": "^2.2.4",
|
||||
"@csstools/postcss-progressive-custom-properties": "^3.1.1",
|
||||
|
@ -2243,9 +2243,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@csstools/postcss-gamut-mapping": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-1.0.4.tgz",
|
||||
"integrity": "sha512-jjHP44awnSijgddNJpZEFfmb8csFx+BiYYpX+ydyScWwLzSpve5eLXneu4uIhZmKom+WXLXWc4y7CvOfVLQ2VQ==",
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-1.0.5.tgz",
|
||||
"integrity": "sha512-AJ74/4nHXgghLWY4/ydEhu3mzwN8c56EjIGrJsoEhKaNuGBAOtUfE5qbkc9XQQ0G2FMhHggqE+9eRrApeK7ebQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -2258,7 +2258,7 @@
|
|||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@csstools/css-color-parser": "^1.6.0",
|
||||
"@csstools/css-color-parser": "^1.6.2",
|
||||
"@csstools/css-parser-algorithms": "^2.6.1",
|
||||
"@csstools/css-tokenizer": "^2.2.4"
|
||||
},
|
||||
|
@ -2270,9 +2270,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@csstools/postcss-gradients-interpolation-method": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-4.0.12.tgz",
|
||||
"integrity": "sha512-F1mOb6MuIMAV7qq9dYLhi2tlmmQn+osCVl+VdDNI+4AO6y3l6dTWmc7XVQMsVxIZCKEZMie9KLtE0PRp3i1UyQ==",
|
||||
"version": "4.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-4.0.13.tgz",
|
||||
"integrity": "sha512-dBbyxs9g+mrIzmEH+UtrqJUmvcJB/60j0ijhBcVJMHCgl/rKjj8ey6r/pJOI0EhkVsckOu3Prc9AGzH88C+1pQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -2285,7 +2285,7 @@
|
|||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@csstools/css-color-parser": "^1.6.0",
|
||||
"@csstools/css-color-parser": "^1.6.2",
|
||||
"@csstools/css-parser-algorithms": "^2.6.1",
|
||||
"@csstools/css-tokenizer": "^2.2.4",
|
||||
"@csstools/postcss-progressive-custom-properties": "^3.1.1",
|
||||
|
@ -2299,9 +2299,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@csstools/postcss-hwb-function": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-3.0.10.tgz",
|
||||
"integrity": "sha512-wYyhFLQ1zkirAhfRxh5BK9WRIJGBb7jtE9H9a2wPOf20kGbS/PmqxHtGmE+o1vSz/MaBIbW+6lqyS16yEzjQJA==",
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-3.0.11.tgz",
|
||||
"integrity": "sha512-c36FtMFptwGn5CmsfdONA40IlWG2lHeoC/TDyED/7lwiTht5okxe6iLAa9t2LjBBo5AHQSHfeMvOASdXk/SHog==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -2314,7 +2314,7 @@
|
|||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@csstools/css-color-parser": "^1.6.0",
|
||||
"@csstools/css-color-parser": "^1.6.2",
|
||||
"@csstools/css-parser-algorithms": "^2.6.1",
|
||||
"@csstools/css-tokenizer": "^2.2.4",
|
||||
"@csstools/postcss-progressive-custom-properties": "^3.1.1",
|
||||
|
@ -2654,9 +2654,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@csstools/postcss-oklab-function": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-3.0.11.tgz",
|
||||
"integrity": "sha512-nIeOZqTFn/zJXSb70JwUcyUBb9658FED7saZlaZNEEhQ3GYxjRhdlV7hgflNi0FDdqNqaEeeI/B/BqnPG9+Q/Q==",
|
||||
"version": "3.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-3.0.12.tgz",
|
||||
"integrity": "sha512-RNitTHamFvUUh8x+MJuPd2tCekYexUrylGKfUoor5D2GGcgzY1WB6Bl3pIj9t8bAq5h/lcacKaB2wmvUOTfGgQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -2669,7 +2669,7 @@
|
|||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@csstools/css-color-parser": "^1.6.0",
|
||||
"@csstools/css-color-parser": "^1.6.2",
|
||||
"@csstools/css-parser-algorithms": "^2.6.1",
|
||||
"@csstools/css-tokenizer": "^2.2.4",
|
||||
"@csstools/postcss-progressive-custom-properties": "^3.1.1",
|
||||
|
@ -2708,9 +2708,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@csstools/postcss-relative-color-syntax": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-2.0.11.tgz",
|
||||
"integrity": "sha512-YmYGwGLoqZp71wXqjyFuG+JApL+CoZqUZ+MJshlokdqqryKX/zj/NrSrwMTAwB4xSx2DgHJUQK3iWumUse8rXw==",
|
||||
"version": "2.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-2.0.12.tgz",
|
||||
"integrity": "sha512-VreDGDgE634niwCytLtkoE5kRxfva7bnMzSoyok7Eh9VPYFOm8CK/oJXt9y3df71Bxc9PG4KC8RA3CxTknudnw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -2723,7 +2723,7 @@
|
|||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@csstools/css-color-parser": "^1.6.0",
|
||||
"@csstools/css-color-parser": "^1.6.2",
|
||||
"@csstools/css-parser-algorithms": "^2.6.1",
|
||||
"@csstools/css-tokenizer": "^2.2.4",
|
||||
"@csstools/postcss-progressive-custom-properties": "^3.1.1",
|
||||
|
@ -2929,15 +2929,78 @@
|
|||
"postcss": "^8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.19.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.5.tgz",
|
||||
"integrity": "sha512-mvXGcKqqIqyKoxq26qEDPHJuBYUA5KizJncKOAf9eJQez+L9O+KfvNFu6nl7SCZ/gFb2QPaRqqmG0doSWlgkqw==",
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
|
||||
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
|
||||
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
|
@ -2946,6 +3009,294 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
|
||||
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
|
||||
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
|
||||
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
|
||||
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
|
||||
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
|
||||
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
|
||||
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
|
||||
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/ecma402-abstract": {
|
||||
"version": "1.18.2",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.18.2.tgz",
|
||||
|
@ -4502,12 +4853,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.19.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.5.tgz",
|
||||
"integrity": "sha512-bUxalY7b1g8vNhQKdB24QDmHeY4V4tw/s6Ak5z+jJX9laP5MoQseTOMemAr0gxssjNcH0MCViG8ONI2kksvfFQ==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
|
||||
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
|
@ -4515,28 +4865,29 @@
|
|||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/android-arm": "0.19.5",
|
||||
"@esbuild/android-arm64": "0.19.5",
|
||||
"@esbuild/android-x64": "0.19.5",
|
||||
"@esbuild/darwin-arm64": "0.19.5",
|
||||
"@esbuild/darwin-x64": "0.19.5",
|
||||
"@esbuild/freebsd-arm64": "0.19.5",
|
||||
"@esbuild/freebsd-x64": "0.19.5",
|
||||
"@esbuild/linux-arm": "0.19.5",
|
||||
"@esbuild/linux-arm64": "0.19.5",
|
||||
"@esbuild/linux-ia32": "0.19.5",
|
||||
"@esbuild/linux-loong64": "0.19.5",
|
||||
"@esbuild/linux-mips64el": "0.19.5",
|
||||
"@esbuild/linux-ppc64": "0.19.5",
|
||||
"@esbuild/linux-riscv64": "0.19.5",
|
||||
"@esbuild/linux-s390x": "0.19.5",
|
||||
"@esbuild/linux-x64": "0.19.5",
|
||||
"@esbuild/netbsd-x64": "0.19.5",
|
||||
"@esbuild/openbsd-x64": "0.19.5",
|
||||
"@esbuild/sunos-x64": "0.19.5",
|
||||
"@esbuild/win32-arm64": "0.19.5",
|
||||
"@esbuild/win32-ia32": "0.19.5",
|
||||
"@esbuild/win32-x64": "0.19.5"
|
||||
"@esbuild/aix-ppc64": "0.20.2",
|
||||
"@esbuild/android-arm": "0.20.2",
|
||||
"@esbuild/android-arm64": "0.20.2",
|
||||
"@esbuild/android-x64": "0.20.2",
|
||||
"@esbuild/darwin-arm64": "0.20.2",
|
||||
"@esbuild/darwin-x64": "0.20.2",
|
||||
"@esbuild/freebsd-arm64": "0.20.2",
|
||||
"@esbuild/freebsd-x64": "0.20.2",
|
||||
"@esbuild/linux-arm": "0.20.2",
|
||||
"@esbuild/linux-arm64": "0.20.2",
|
||||
"@esbuild/linux-ia32": "0.20.2",
|
||||
"@esbuild/linux-loong64": "0.20.2",
|
||||
"@esbuild/linux-mips64el": "0.20.2",
|
||||
"@esbuild/linux-ppc64": "0.20.2",
|
||||
"@esbuild/linux-riscv64": "0.20.2",
|
||||
"@esbuild/linux-s390x": "0.20.2",
|
||||
"@esbuild/linux-x64": "0.20.2",
|
||||
"@esbuild/netbsd-x64": "0.20.2",
|
||||
"@esbuild/openbsd-x64": "0.20.2",
|
||||
"@esbuild/sunos-x64": "0.20.2",
|
||||
"@esbuild/win32-arm64": "0.20.2",
|
||||
"@esbuild/win32-ia32": "0.20.2",
|
||||
"@esbuild/win32-x64": "0.20.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
|
@ -5679,9 +6030,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/masto": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/masto/-/masto-6.6.4.tgz",
|
||||
"integrity": "sha512-qoq08UAzdRhVNo9eUG+LIxVNx8UWq+ZqyyeoPGYKLsCOdNyxZpkdK7r3M1negyBscfsNzppdMDpuRhmxDOizxw==",
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/masto/-/masto-6.7.0.tgz",
|
||||
"integrity": "sha512-R1UyuCdiyBuA9xuIEVIYa2187oIoHhpL1T0glIY+RICAo7JYOAEPdi4aAmROyPcWOYwMlaVDmRRb1zmNbvTnVg==",
|
||||
"dependencies": {
|
||||
"change-case": "^4.1.2",
|
||||
"events-to-async": "^2.0.1",
|
||||
|
@ -6003,9 +6354,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.35",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
|
||||
"integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
|
||||
"version": "8.4.38",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
|
||||
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -6024,7 +6375,7 @@
|
|||
"dependencies": {
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
"source-map-js": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
|
@ -6072,9 +6423,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss-color-functional-notation": {
|
||||
"version": "6.0.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-6.0.6.tgz",
|
||||
"integrity": "sha512-2GENDVgEk1dt+OdVhPO+zO4Dzj31Xs9EuKgQLbY9RSkKS3jUqnbTAh33bUhKce5JM1ZmsXm0azCb7Bh8j6W6Nw==",
|
||||
"version": "6.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-6.0.7.tgz",
|
||||
"integrity": "sha512-VwzaVfu1kEYDK2yM8ixeKA/QbgQ8k0uxpRevLH9Wam+R3C1sg68vnRB7m2AMhYfjqb5khp4p0EQk5aO90ASAkw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -6087,7 +6438,7 @@
|
|||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@csstools/css-color-parser": "^1.6.0",
|
||||
"@csstools/css-color-parser": "^1.6.2",
|
||||
"@csstools/css-parser-algorithms": "^2.6.1",
|
||||
"@csstools/css-tokenizer": "^2.2.4",
|
||||
"@csstools/postcss-progressive-custom-properties": "^3.1.1",
|
||||
|
@ -6420,9 +6771,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss-lab-function": {
|
||||
"version": "6.0.11",
|
||||
"resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-6.0.11.tgz",
|
||||
"integrity": "sha512-toTAozTlBBhqSynSJ32O6ssukZFphS58AAQcVqMA8kG/E04+v+e7E5OKRqq68M/VJaWIeMdpyeBEO51buMrdvw==",
|
||||
"version": "6.0.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-6.0.12.tgz",
|
||||
"integrity": "sha512-flHW2jdRCRe8ClhMgrylR1BCiyyqLLvp1qKfO5wuAclUihldfRsoDIFQWFVW7rJbruil9/LCoHNUvY9JwTlLPw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -6435,7 +6786,7 @@
|
|||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@csstools/css-color-parser": "^1.6.0",
|
||||
"@csstools/css-color-parser": "^1.6.2",
|
||||
"@csstools/css-parser-algorithms": "^2.6.1",
|
||||
"@csstools/css-tokenizer": "^2.2.4",
|
||||
"@csstools/postcss-progressive-custom-properties": "^3.1.1",
|
||||
|
@ -6584,9 +6935,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss-preset-env": {
|
||||
"version": "9.5.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.5.1.tgz",
|
||||
"integrity": "sha512-m2biepZ2amqH/ygGRV+lQxnT9+AsYG2OScMwBRLa9YefDOXaCVKzsPtmnvdUG7QENdhAl9tE9nsHbYHVYsqJmQ==",
|
||||
"version": "9.5.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.5.2.tgz",
|
||||
"integrity": "sha512-/KIAHELdg5BxsKA/Vc6Nok/66EM7lps8NulKcQWX2S52HdzxAqh+6HcuAFj7trRSW587vlOA4zCjlRFgR+W6Ag==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -6600,13 +6951,13 @@
|
|||
],
|
||||
"dependencies": {
|
||||
"@csstools/postcss-cascade-layers": "^4.0.3",
|
||||
"@csstools/postcss-color-function": "^3.0.11",
|
||||
"@csstools/postcss-color-mix-function": "^2.0.11",
|
||||
"@csstools/postcss-color-function": "^3.0.12",
|
||||
"@csstools/postcss-color-mix-function": "^2.0.12",
|
||||
"@csstools/postcss-exponential-functions": "^1.0.5",
|
||||
"@csstools/postcss-font-format-keywords": "^3.0.2",
|
||||
"@csstools/postcss-gamut-mapping": "^1.0.4",
|
||||
"@csstools/postcss-gradients-interpolation-method": "^4.0.12",
|
||||
"@csstools/postcss-hwb-function": "^3.0.10",
|
||||
"@csstools/postcss-gamut-mapping": "^1.0.5",
|
||||
"@csstools/postcss-gradients-interpolation-method": "^4.0.13",
|
||||
"@csstools/postcss-hwb-function": "^3.0.11",
|
||||
"@csstools/postcss-ic-unit": "^3.0.5",
|
||||
"@csstools/postcss-initial": "^1.0.1",
|
||||
"@csstools/postcss-is-pseudo-class": "^4.0.5",
|
||||
|
@ -6620,9 +6971,9 @@
|
|||
"@csstools/postcss-media-queries-aspect-ratio-number-values": "^2.0.7",
|
||||
"@csstools/postcss-nested-calc": "^3.0.2",
|
||||
"@csstools/postcss-normalize-display-values": "^3.0.2",
|
||||
"@csstools/postcss-oklab-function": "^3.0.11",
|
||||
"@csstools/postcss-oklab-function": "^3.0.12",
|
||||
"@csstools/postcss-progressive-custom-properties": "^3.1.1",
|
||||
"@csstools/postcss-relative-color-syntax": "^2.0.11",
|
||||
"@csstools/postcss-relative-color-syntax": "^2.0.12",
|
||||
"@csstools/postcss-scope-pseudo-class": "^3.0.1",
|
||||
"@csstools/postcss-stepped-value-functions": "^3.0.6",
|
||||
"@csstools/postcss-text-decoration-shorthand": "^3.0.4",
|
||||
|
@ -6636,7 +6987,7 @@
|
|||
"cssdb": "^7.11.1",
|
||||
"postcss-attribute-case-insensitive": "^6.0.3",
|
||||
"postcss-clamp": "^4.1.0",
|
||||
"postcss-color-functional-notation": "^6.0.6",
|
||||
"postcss-color-functional-notation": "^6.0.7",
|
||||
"postcss-color-hex-alpha": "^9.0.4",
|
||||
"postcss-color-rebeccapurple": "^9.0.3",
|
||||
"postcss-custom-media": "^10.0.4",
|
||||
|
@ -6649,7 +7000,7 @@
|
|||
"postcss-font-variant": "^5.0.0",
|
||||
"postcss-gap-properties": "^5.0.1",
|
||||
"postcss-image-set-function": "^6.0.3",
|
||||
"postcss-lab-function": "^6.0.11",
|
||||
"postcss-lab-function": "^6.0.12",
|
||||
"postcss-logical": "^7.0.1",
|
||||
"postcss-nesting": "^12.1.0",
|
||||
"postcss-opacity-percentage": "^2.0.0",
|
||||
|
@ -6748,9 +7099,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.19.6",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.19.6.tgz",
|
||||
"integrity": "sha512-gympg+T2Z1fG1unB8NH29yHJwnEaCH37Z32diPDku316OTnRPeMbiRV9kTrfZpocXjdfnWuFUl/Mj4BHaf6gnw==",
|
||||
"version": "10.20.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.20.1.tgz",
|
||||
"integrity": "sha512-JIFjgFg9B2qnOoGiYMVBtrcFxHqn+dNXbq76bVmcaHYJFYR4lW67AOcXgAYQQTDYXDOg/kTZrKPNCdRgJ2UJmw==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
|
@ -7273,11 +7624,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
|
||||
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
|
||||
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
@ -7858,14 +8208,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.6.tgz",
|
||||
"integrity": "sha512-yYIAZs9nVfRJ/AiOLCA91zzhjsHUgMjB+EigzFb6W2XTLO8JixBCKCjvhKZaye+NKYHCrkv3Oh50dH9EdLU2RA==",
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.6.tgz",
|
||||
"integrity": "sha512-FPtnxFlSIKYjZ2eosBQamz4CbyrTizbZ3hnGJlh/wMtCrlp1Hah6AzBLjGI5I2urTfNnpovpHdrL6YRuBOPnCA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.19.3",
|
||||
"postcss": "^8.4.35",
|
||||
"rollup": "^4.2.0"
|
||||
"esbuild": "^0.20.1",
|
||||
"postcss": "^8.4.36",
|
||||
"rollup": "^4.13.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
|
@ -7939,9 +8289,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite-plugin-pwa": {
|
||||
"version": "0.19.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.19.4.tgz",
|
||||
"integrity": "sha512-KiEFXaYEj2Hg1it+yECy75oqNmlXimI7BaLx7Sxl7Qsd9EIVxaf3GX1mZdLpHe83pDgHBNwm9USGQxSCNp5m7A==",
|
||||
"version": "0.19.7",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.19.7.tgz",
|
||||
"integrity": "sha512-18TECxoGPQE7tVZzKxbf5Icrl5688n1JGMPSgGotTsh89vLDxevY7ICfD3CFVfonZXh8ckuyJXg0NXE5+FAl2A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
|
|
12
package.json
12
package.json
|
@ -27,11 +27,11 @@
|
|||
"idb-keyval": "~6.2.1",
|
||||
"just-debounce-it": "~3.2.0",
|
||||
"lz-string": "~1.5.0",
|
||||
"masto": "~6.6.4",
|
||||
"masto": "~6.7.0",
|
||||
"moize": "~6.1.6",
|
||||
"p-retry": "~6.2.0",
|
||||
"p-throttle": "~6.1.0",
|
||||
"preact": "~10.19.6",
|
||||
"preact": "~10.20.1",
|
||||
"react-hotkeys-hook": "~4.5.0",
|
||||
"react-intersection-observer": "~9.8.1",
|
||||
"react-quick-pinch-zoom": "~5.1.0",
|
||||
|
@ -48,14 +48,14 @@
|
|||
"devDependencies": {
|
||||
"@preact/preset-vite": "~2.8.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "~4.3.0",
|
||||
"postcss": "~8.4.35",
|
||||
"postcss": "~8.4.38",
|
||||
"postcss-dark-theme-class": "~1.2.1",
|
||||
"postcss-preset-env": "~9.5.1",
|
||||
"postcss-preset-env": "~9.5.2",
|
||||
"twitter-text": "~3.1.0",
|
||||
"vite": "~5.1.6",
|
||||
"vite": "~5.2.6",
|
||||
"vite-plugin-generate-file": "~0.1.1",
|
||||
"vite-plugin-html-config": "~1.0.11",
|
||||
"vite-plugin-pwa": "~0.19.4",
|
||||
"vite-plugin-pwa": "~0.19.7",
|
||||
"vite-plugin-remove-console": "~2.2.0",
|
||||
"workbox-cacheable-response": "~7.0.0",
|
||||
"workbox-expiration": "~7.0.0",
|
||||
|
|
147
src/app.css
147
src/app.css
|
@ -1925,11 +1925,11 @@ body > .szh-menu-container {
|
|||
.szh-menu__item:not(.szh-menu__item--disabled):not(
|
||||
.szh-menu__item--hover
|
||||
).danger {
|
||||
color: var(--red-color);
|
||||
color: var(--red-text-color);
|
||||
}
|
||||
.szh-menu
|
||||
.szh-menu__item.danger:not(.szh-menu__item--disabled).szh-menu__item--hover {
|
||||
background-color: var(--red-color);
|
||||
background-color: var(--red-text-color);
|
||||
}
|
||||
.szh-menu
|
||||
.szh-menu__item:not(.szh-menu__item--disabled):not(
|
||||
|
@ -2026,71 +2026,86 @@ body > .szh-menu-container {
|
|||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* DONUT METER */
|
||||
/* CHAR COUNTER */
|
||||
|
||||
meter.donut {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
meter.donut::-webkit-meter-inner-element,
|
||||
meter.donut::-webkit-meter-bar,
|
||||
meter.donut::-webkit-meter-optimum-value,
|
||||
meter.donut::-webkit-meter-suboptimum-value,
|
||||
meter.donut::-webkit-meter-even-less-good-value {
|
||||
display: none;
|
||||
}
|
||||
|
||||
meter.donut::-moz-meter-bar {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
meter.donut {
|
||||
position: relative;
|
||||
.char-counter {
|
||||
--dimension: 24px;
|
||||
--border-width: 2px;
|
||||
--middle-circle-radius: calc(var(--dimension) / 2 - var(--border-width));
|
||||
width: var(--dimension);
|
||||
height: var(--dimension);
|
||||
border-radius: 50%;
|
||||
--fill: calc(var(--percentage) * 1%);
|
||||
--color: var(--link-color);
|
||||
--middle-circle: radial-gradient(
|
||||
circle at 50% 50%,
|
||||
var(--bg-color) var(--middle-circle-radius),
|
||||
transparent var(--middle-circle-radius)
|
||||
);
|
||||
background-image: var(--middle-circle),
|
||||
conic-gradient(var(--color) var(--fill), var(--outline-color) 0);
|
||||
transform: scale(0.7);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
meter.donut.warning {
|
||||
--color: var(--orange-color);
|
||||
transform: scale(1);
|
||||
}
|
||||
meter.donut.danger {
|
||||
--color: var(--red-color);
|
||||
transform: scale(1);
|
||||
}
|
||||
meter.donut.explode {
|
||||
background-image: none;
|
||||
transform: scale(1);
|
||||
}
|
||||
meter.donut:is(.warning, .danger, .explode):after {
|
||||
content: attr(data-left);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 12px;
|
||||
color: var(--text-insignificant-color);
|
||||
}
|
||||
meter.donut:is(.danger, .explode):after {
|
||||
color: var(--red-color);
|
||||
}
|
||||
meter.donut[hidden] {
|
||||
min-width: var(--dimension);
|
||||
min-height: var(--dimension);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
|
||||
&[hidden] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
meter {
|
||||
appearance: none;
|
||||
position: relative;
|
||||
--border-width: 2px;
|
||||
--middle-circle-radius: calc(var(--dimension) / 2 - var(--border-width));
|
||||
width: var(--dimension);
|
||||
height: var(--dimension);
|
||||
border-radius: 50%;
|
||||
--fill: calc(var(--percentage) * 1%);
|
||||
--color: var(--link-color);
|
||||
--middle-circle: radial-gradient(
|
||||
circle at 50% 50%,
|
||||
var(--bg-color) var(--middle-circle-radius),
|
||||
transparent var(--middle-circle-radius)
|
||||
);
|
||||
background-image: var(--middle-circle),
|
||||
conic-gradient(var(--color) var(--fill), var(--outline-color) 0);
|
||||
transform: scale(0.7);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
|
||||
&::-webkit-meter-inner-element,
|
||||
&::-webkit-meter-bar,
|
||||
&::-webkit-meter-optimum-value,
|
||||
&::-webkit-meter-suboptimum-value,
|
||||
&::-webkit-meter-even-less-good-value {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::-moz-meter-bar {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
--color: var(--orange-color);
|
||||
transform: scale(1);
|
||||
}
|
||||
&.danger {
|
||||
--color: var(--red-color);
|
||||
transform: scale(1);
|
||||
}
|
||||
&.explode {
|
||||
background-image: none;
|
||||
transform: scale(1);
|
||||
}
|
||||
&:is(.warning, .danger, .explode) + .counter {
|
||||
opacity: 1;
|
||||
color: var(--text-insignificant-color);
|
||||
}
|
||||
&:is(.danger, .explode) + .counter {
|
||||
opacity: 1;
|
||||
color: var(--red-color);
|
||||
}
|
||||
}
|
||||
|
||||
.counter {
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* SHINY PILL */
|
||||
|
@ -2288,10 +2303,10 @@ ul.link-list li a .icon {
|
|||
filter: none !important;
|
||||
}
|
||||
.nav-menu-button .avatar {
|
||||
transition: box-shadow 0.3s ease-out;
|
||||
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-light-color) !important;
|
||||
}
|
||||
.nav-menu-button:is(:hover, :focus, .active) .avatar {
|
||||
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-light-color);
|
||||
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-color) !important;
|
||||
}
|
||||
.nav-menu-button.with-avatar .icon {
|
||||
position: absolute;
|
||||
|
|
|
@ -27,6 +27,7 @@ import AccountStatuses from './pages/account-statuses';
|
|||
import Bookmarks from './pages/bookmarks';
|
||||
// import Catchup from './pages/catchup';
|
||||
import Favourites from './pages/favourites';
|
||||
import Filters from './pages/filters';
|
||||
import FollowedHashtags from './pages/followed-hashtags';
|
||||
import Following from './pages/following';
|
||||
import Hashtag from './pages/hashtag';
|
||||
|
@ -463,7 +464,8 @@ function SecondaryRoutes({ isLoggedIn }) {
|
|||
<Route index element={<Lists />} />
|
||||
<Route path=":id" element={<List />} />
|
||||
</Route>
|
||||
<Route path="/ft" element={<FollowedHashtags />} />
|
||||
<Route path="/fh" element={<FollowedHashtags />} />
|
||||
<Route path="/ft" element={<Filters />} />
|
||||
<Route
|
||||
path="/catchup"
|
||||
element={
|
||||
|
|
|
@ -78,6 +78,7 @@ export const ICONS = {
|
|||
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
|
||||
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
|
||||
filter: () => import('@iconify-icons/mingcute/filter-2-line'),
|
||||
filters: () => import('@iconify-icons/mingcute/filter-line'),
|
||||
chart: () => import('@iconify-icons/mingcute/chart-line-line'),
|
||||
react: () => import('@iconify-icons/mingcute/react-line'),
|
||||
layout4: () => import('@iconify-icons/mingcute/layout-4-line'),
|
||||
|
@ -104,4 +105,6 @@ export const ICONS = {
|
|||
code: () => import('@iconify-icons/mingcute/code-line'),
|
||||
copy: () => import('@iconify-icons/mingcute/copy-2-line'),
|
||||
quote: () => import('@iconify-icons/mingcute/quote-left-line'),
|
||||
settings: () => import('@iconify-icons/mingcute/settings-6-line'),
|
||||
'heart-break': () => import('@iconify-icons/mingcute/heart-crack-line'),
|
||||
};
|
||||
|
|
|
@ -33,7 +33,7 @@ function AccountBlock({
|
|||
<span>
|
||||
<b>████████</b>
|
||||
<br />
|
||||
<span class="account-block-acct">@██████</span>
|
||||
<span class="account-block-acct">██████</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
@ -87,7 +87,7 @@ function AccountBlock({
|
|||
class="account-block"
|
||||
href={url}
|
||||
target={external ? '_blank' : null}
|
||||
title={`@${acct}`}
|
||||
title={acct2 ? acct : `@${acct}`}
|
||||
onClick={(e) => {
|
||||
if (external) return;
|
||||
e.preventDefault();
|
||||
|
@ -121,7 +121,8 @@ function AccountBlock({
|
|||
</>
|
||||
)}{' '}
|
||||
<span class="account-block-acct">
|
||||
@{acct1}
|
||||
{acct2 ? '' : '@'}
|
||||
{acct1}
|
||||
<wbr />
|
||||
{acct2}
|
||||
{locked && (
|
||||
|
|
|
@ -781,3 +781,53 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#edit-profile-container {
|
||||
p {
|
||||
margin-block: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
input,
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 5em;
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
color: var(--text-insignificant-color);
|
||||
font-weight: normal;
|
||||
font-size: 0.8em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tbody tr td:first-child {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
|
||||
* {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import { api } from '../utils/api';
|
|||
import enhanceContent from '../utils/enhance-content';
|
||||
import getHTMLText from '../utils/getHTMLText';
|
||||
import handleContentLinks from '../utils/handle-content-links';
|
||||
import { getLists } from '../utils/lists';
|
||||
import niceDateTime from '../utils/nice-date-time';
|
||||
import pmem from '../utils/pmem';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
|
@ -340,6 +341,17 @@ function AccountInfo({
|
|||
[standalone, id, statusesCount],
|
||||
);
|
||||
|
||||
const onProfileUpdate = useCallback(
|
||||
(newAccount) => {
|
||||
if (newAccount.id === id) {
|
||||
console.log('Updated account info', newAccount);
|
||||
setInfo(newAccount);
|
||||
states.accounts[`${newAccount.id}@${instance}`] = newAccount;
|
||||
}
|
||||
},
|
||||
[id, instance],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex="-1"
|
||||
|
@ -792,8 +804,10 @@ function AccountInfo({
|
|||
<RelatedActions
|
||||
info={info}
|
||||
instance={instance}
|
||||
standalone={standalone}
|
||||
authenticated={authenticated}
|
||||
onRelationshipChange={onRelationshipChange}
|
||||
onProfileUpdate={onProfileUpdate}
|
||||
/>
|
||||
</footer>
|
||||
</>
|
||||
|
@ -808,8 +822,10 @@ const FAMILIAR_FOLLOWERS_LIMIT = 3;
|
|||
function RelatedActions({
|
||||
info,
|
||||
instance,
|
||||
standalone,
|
||||
authenticated,
|
||||
onRelationshipChange = () => {},
|
||||
onProfileUpdate = () => {},
|
||||
}) {
|
||||
if (!info) return null;
|
||||
const {
|
||||
|
@ -920,6 +936,7 @@ function RelatedActions({
|
|||
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
|
||||
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
|
||||
const [showPrivateNoteModal, setShowPrivateNoteModal] = useState(false);
|
||||
const [showEditProfile, setShowEditProfile] = useState(false);
|
||||
const [lists, setLists] = useState([]);
|
||||
|
||||
return (
|
||||
|
@ -1029,6 +1046,70 @@ function RelatedActions({
|
|||
{privateNote ? 'Edit private note' : 'Add private note'}
|
||||
</span>
|
||||
</MenuItem>
|
||||
{following && !!relationship && (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setRelationshipUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const rel = await currentMasto.v1.accounts
|
||||
.$select(accountID.current)
|
||||
.follow({
|
||||
notify: !notifying,
|
||||
});
|
||||
if (rel) setRelationship(rel);
|
||||
setRelationshipUIState('default');
|
||||
showToast(
|
||||
rel.notifying
|
||||
? `Notifications enabled for @${username}'s posts.`
|
||||
: ` Notifications disabled for @${username}'s posts.`,
|
||||
);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
setRelationshipUIState('error');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<Icon icon="notification" />
|
||||
<span>
|
||||
{notifying
|
||||
? 'Disable notifications'
|
||||
: 'Enable notifications'}
|
||||
</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setRelationshipUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const rel = await currentMasto.v1.accounts
|
||||
.$select(accountID.current)
|
||||
.follow({
|
||||
reblogs: !showingReblogs,
|
||||
});
|
||||
if (rel) setRelationship(rel);
|
||||
setRelationshipUIState('default');
|
||||
showToast(
|
||||
rel.showingReblogs
|
||||
? `Boosts from @${username} disabled.`
|
||||
: `Boosts from @${username} enabled.`,
|
||||
);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
setRelationshipUIState('error');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<Icon icon="rocket" />
|
||||
<span>
|
||||
{showingReblogs ? 'Disable boosts' : 'Enable boosts'}
|
||||
</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{/* Add/remove from lists is only possible if following the account */}
|
||||
{following && (
|
||||
<MenuItem
|
||||
|
@ -1276,6 +1357,19 @@ function RelatedActions({
|
|||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{currentAuthenticated && isSelf && standalone && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setShowEditProfile(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="pencil" />
|
||||
<span>Edit profile</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{import.meta.env.DEV && currentAuthenticated && isSelf && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
|
@ -1417,6 +1511,22 @@ function RelatedActions({
|
|||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!showEditProfile && (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
setShowEditProfile(false);
|
||||
}}
|
||||
>
|
||||
<EditProfileSheet
|
||||
onClose={({ state, account } = {}) => {
|
||||
setShowEditProfile(false);
|
||||
if (state === 'success' && account) {
|
||||
onProfileUpdate(account);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1494,13 +1604,12 @@ function AddRemoveListsSheet({ accountID, onClose }) {
|
|||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const lists = await masto.v1.lists.list();
|
||||
lists.sort((a, b) => a.title.localeCompare(b.title));
|
||||
const lists = await getLists();
|
||||
setLists(lists);
|
||||
const listsContainingAccount = await masto.v1.accounts
|
||||
.$select(accountID)
|
||||
.lists.list();
|
||||
console.log({ lists, listsContainingAccount });
|
||||
setLists(lists);
|
||||
setListsContainingAccount(listsContainingAccount);
|
||||
setUIState('default');
|
||||
} catch (e) {
|
||||
|
@ -1705,4 +1814,190 @@ function PrivateNoteSheet({
|
|||
);
|
||||
}
|
||||
|
||||
function EditProfileSheet({ onClose = () => {} }) {
|
||||
const { masto } = api();
|
||||
const [uiState, setUIState] = useState('loading');
|
||||
const [account, setAccount] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const acc = await masto.v1.accounts.verifyCredentials();
|
||||
setAccount(acc);
|
||||
setUIState('default');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setUIState('error');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
console.log('EditProfileSheet', account);
|
||||
const { displayName, source } = account || {};
|
||||
const { note, fields } = source || {};
|
||||
const fieldsAttributesRef = useRef(null);
|
||||
|
||||
return (
|
||||
<div class="sheet" id="edit-profile-container">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<b>Edit profile</b>
|
||||
</header>
|
||||
<main>
|
||||
{uiState === 'loading' ? (
|
||||
<p class="ui-state">
|
||||
<Loader abrupt />
|
||||
</p>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const displayName = formData.get('display_name');
|
||||
const note = formData.get('note');
|
||||
const fieldsAttributesFields =
|
||||
fieldsAttributesRef.current.querySelectorAll(
|
||||
'input[name^="fields_attributes"]',
|
||||
);
|
||||
const fieldsAttributes = [];
|
||||
fieldsAttributesFields.forEach((field) => {
|
||||
const name = field.name;
|
||||
const [_, index, key] =
|
||||
name.match(/fields_attributes\[(\d+)\]\[(.+)\]/) || [];
|
||||
const value = field.value ? field.value.trim() : '';
|
||||
if (index && key && value) {
|
||||
if (!fieldsAttributes[index]) fieldsAttributes[index] = {};
|
||||
fieldsAttributes[index][key] = value;
|
||||
}
|
||||
});
|
||||
// Fill in the blanks
|
||||
fieldsAttributes.forEach((field) => {
|
||||
if (field.name && !field.value) {
|
||||
field.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const newAccount = await masto.v1.accounts.updateCredentials({
|
||||
displayName,
|
||||
note,
|
||||
fieldsAttributes,
|
||||
});
|
||||
console.log('updated account', newAccount);
|
||||
onClose?.({
|
||||
state: 'success',
|
||||
account: newAccount,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(e?.message || 'Unable to update profile.');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<label>
|
||||
Name{' '}
|
||||
<input
|
||||
type="text"
|
||||
name="display_name"
|
||||
defaultValue={displayName}
|
||||
maxLength={30}
|
||||
disabled={uiState === 'loading'}
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
Bio
|
||||
<textarea
|
||||
defaultValue={note}
|
||||
name="note"
|
||||
maxLength={500}
|
||||
rows="5"
|
||||
disabled={uiState === 'loading'}
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
{/* Table for fields; name and values are in fields, min 4 rows */}
|
||||
<p>Extra fields</p>
|
||||
<table ref={fieldsAttributesRef}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Content</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: Math.max(4, fields.length) }).map(
|
||||
(_, i) => {
|
||||
const { name = '', value = '' } = fields[i] || {};
|
||||
return (
|
||||
<FieldsAttributesRow
|
||||
key={i}
|
||||
name={name}
|
||||
value={value}
|
||||
index={i}
|
||||
disabled={uiState === 'loading'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<footer>
|
||||
<button
|
||||
type="button"
|
||||
class="light"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={uiState === 'loading'}>
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldsAttributesRow({ name, value, disabled, index: i }) {
|
||||
const [hasValue, setHasValue] = useState(!!value);
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
name={`fields_attributes[${i}][name]`}
|
||||
defaultValue={name}
|
||||
disabled={disabled}
|
||||
maxLength={255}
|
||||
required={hasValue}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
name={`fields_attributes[${i}][value]`}
|
||||
defaultValue={value}
|
||||
disabled={disabled}
|
||||
maxLength={255}
|
||||
onChange={(e) => setHasValue(!!e.currentTarget.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountInfo;
|
||||
|
|
|
@ -39,6 +39,8 @@ function Columns() {
|
|||
if (!Component) return null;
|
||||
// Don't show Search column with no query, for now
|
||||
if (type === 'search' && !params.query) return null;
|
||||
// Don't show List column with no list, for now
|
||||
if (type === 'list' && !params.id) return null;
|
||||
return (
|
||||
<Component key={type + JSON.stringify(params)} {...params} columnMode />
|
||||
);
|
||||
|
|
|
@ -1662,27 +1662,31 @@ function CharCountMeter({ maxCharacters = 500, hidden }) {
|
|||
const charCount = snapStates.composerCharacterCount;
|
||||
const leftChars = maxCharacters - charCount;
|
||||
if (hidden) {
|
||||
return <meter class="donut" hidden />;
|
||||
return <span class="char-counter" hidden />;
|
||||
}
|
||||
return (
|
||||
<meter
|
||||
class={`donut ${
|
||||
leftChars <= -10
|
||||
? 'explode'
|
||||
: leftChars <= 0
|
||||
? 'danger'
|
||||
: leftChars <= 20
|
||||
? 'warning'
|
||||
: ''
|
||||
}`}
|
||||
value={charCount}
|
||||
max={maxCharacters}
|
||||
data-left={leftChars}
|
||||
<span
|
||||
class="char-counter"
|
||||
title={`${leftChars}/${maxCharacters}`}
|
||||
style={{
|
||||
'--percentage': (charCount / maxCharacters) * 100,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<meter
|
||||
class={`${
|
||||
leftChars <= -10
|
||||
? 'explode'
|
||||
: leftChars <= 0
|
||||
? 'danger'
|
||||
: leftChars <= 20
|
||||
? 'warning'
|
||||
: ''
|
||||
}`}
|
||||
value={charCount}
|
||||
max={maxCharacters}
|
||||
/>
|
||||
<span class="counter">{leftChars}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
17
src/components/custom-emoji.jsx
Normal file
17
src/components/custom-emoji.jsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
export default function CustomEmoji({ staticUrl, alt, url }) {
|
||||
return (
|
||||
<picture>
|
||||
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
|
||||
<img
|
||||
key={alt}
|
||||
src={url}
|
||||
alt={alt}
|
||||
class="shortcode-emoji emoji"
|
||||
width="16"
|
||||
height="16"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
import { memo } from 'preact/compat';
|
||||
|
||||
import CustomEmoji from './custom-emoji';
|
||||
|
||||
function EmojiText({ text, emojis }) {
|
||||
if (!text) return '';
|
||||
if (!emojis?.length) return text;
|
||||
|
@ -12,21 +14,7 @@ function EmojiText({ text, emojis }) {
|
|||
const emoji = emojis.find((e) => e.shortcode === word);
|
||||
if (emoji) {
|
||||
const { url, staticUrl } = emoji;
|
||||
return (
|
||||
<picture>
|
||||
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
|
||||
<img
|
||||
key={word}
|
||||
src={url}
|
||||
alt={word}
|
||||
class="shortcode-emoji emoji"
|
||||
width="16"
|
||||
height="16"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
return <CustomEmoji staticUrl={staticUrl} alt={word} url={url} />;
|
||||
}
|
||||
return word;
|
||||
});
|
||||
|
|
|
@ -1,4 +1,24 @@
|
|||
#generic-accounts-container {
|
||||
.post-preview {
|
||||
--max-height: 120px;
|
||||
max-height: var(--max-height);
|
||||
overflow: hidden;
|
||||
margin-block: 8px;
|
||||
border: 1px solid var(--outline-color);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
|
||||
.status {
|
||||
font-size: calc(var(--text-size) * 0.9);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black calc(var(--max-height) / 2),
|
||||
transparent calc(var(--max-height) - 8px)
|
||||
);
|
||||
filter: saturate(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.accounts-list {
|
||||
--list-gap: 16px;
|
||||
list-style: none;
|
||||
|
|
|
@ -12,10 +12,12 @@ import useLocationChange from '../utils/useLocationChange';
|
|||
import AccountBlock from './account-block';
|
||||
import Icon from './icon';
|
||||
import Loader from './loader';
|
||||
import Status from './status';
|
||||
|
||||
export default function GenericAccounts({
|
||||
instance,
|
||||
excludeRelationshipAttrs = [],
|
||||
postID,
|
||||
onClose = () => {},
|
||||
}) {
|
||||
const { masto, instance: currentInstance } = api();
|
||||
|
@ -129,6 +131,8 @@ export default function GenericAccounts({
|
|||
}
|
||||
}, [snapStates.reloadGenericAccounts.counter]);
|
||||
|
||||
const post = states.statuses[postID];
|
||||
|
||||
return (
|
||||
<div id="generic-accounts-container" class="sheet" tabindex="-1">
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
|
@ -138,6 +142,11 @@ export default function GenericAccounts({
|
|||
<h2>{heading || 'Accounts'}</h2>
|
||||
</header>
|
||||
<main>
|
||||
{post && (
|
||||
<div class="post-preview">
|
||||
<Status status={post} size="s" readOnly />
|
||||
</div>
|
||||
)}
|
||||
{accounts.length > 0 ? (
|
||||
<>
|
||||
<ul class="accounts-list">
|
||||
|
|
21
src/components/intl-segmenter-suspense.jsx
Normal file
21
src/components/intl-segmenter-suspense.jsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { shouldPolyfill } from '@formatjs/intl-segmenter/should-polyfill';
|
||||
import { Suspense } from 'preact/compat';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
const supportsIntlSegmenter = !shouldPolyfill();
|
||||
|
||||
export default function IntlSegmenterSuspense({ children }) {
|
||||
if (supportsIntlSegmenter) {
|
||||
return <Suspense>{children}</Suspense>;
|
||||
}
|
||||
|
||||
const [polyfillLoaded, setPolyfillLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await import('@formatjs/intl-segmenter/polyfill-force');
|
||||
setPolyfillLoaded(true);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return polyfillLoaded && <Suspense>{children}</Suspense>;
|
||||
}
|
46
src/components/lazy-shazam.jsx
Normal file
46
src/components/lazy-shazam.jsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
Rendered but hidden. Only show when visible
|
||||
*/
|
||||
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
export default function LazyShazam({ children }) {
|
||||
const containerRef = useRef();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [visibleStart, setVisibleStart] = useState(false);
|
||||
|
||||
const { ref } = useInView({
|
||||
root: null,
|
||||
trackVisibility: true,
|
||||
delay: 1000,
|
||||
onChange: (inView) => {
|
||||
if (inView) {
|
||||
setVisible(true);
|
||||
}
|
||||
},
|
||||
triggerOnce: true,
|
||||
skip: visibleStart || visible,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.bottom > 0) {
|
||||
setVisibleStart(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (visibleStart) return children;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="shazam-container no-animation"
|
||||
hidden={!visible}
|
||||
>
|
||||
<div ref={ref} class="shazam-container-inner">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import { addListStore, deleteListStore, updateListStore } from '../utils/lists';
|
||||
import supports from '../utils/supports';
|
||||
|
||||
import Icon from './icon';
|
||||
|
@ -75,6 +76,14 @@ function ListAddEdit({ list, onClose }) {
|
|||
state: 'success',
|
||||
list: listResult,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (editMode) {
|
||||
updateListStore(listResult);
|
||||
} else {
|
||||
addListStore(listResult);
|
||||
}
|
||||
}, 1);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setUIState('error');
|
||||
|
@ -146,6 +155,9 @@ function ListAddEdit({ list, onClose }) {
|
|||
onClose?.({
|
||||
state: 'deleted',
|
||||
});
|
||||
setTimeout(() => {
|
||||
deleteListStore(list.id);
|
||||
}, 1);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setUIState('error');
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { lazy } from 'preact/compat';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { subscribe, useSnapshot } from 'valtio';
|
||||
|
||||
|
@ -8,16 +9,19 @@ import showToast from '../utils/show-toast';
|
|||
import states from '../utils/states';
|
||||
|
||||
import AccountSheet from './account-sheet';
|
||||
import Compose from './compose';
|
||||
// import Compose from './compose';
|
||||
import Drafts from './drafts';
|
||||
import EmbedModal from './embed-modal';
|
||||
import GenericAccounts from './generic-accounts';
|
||||
import IntlSegmenterSuspense from './intl-segmenter-suspense';
|
||||
import MediaAltModal from './media-alt-modal';
|
||||
import MediaModal from './media-modal';
|
||||
import Modal from './modal';
|
||||
import ReportModal from './report-modal';
|
||||
import ShortcutsSettings from './shortcuts-settings';
|
||||
|
||||
const Compose = lazy(() => import('./compose'));
|
||||
|
||||
subscribe(states, (changes) => {
|
||||
for (const [action, path, value, prevValue] of changes) {
|
||||
// When closing modal, focus on deck
|
||||
|
@ -36,49 +40,51 @@ export default function Modals() {
|
|||
<>
|
||||
{!!snapStates.showCompose && (
|
||||
<Modal class="solid">
|
||||
<Compose
|
||||
replyToStatus={
|
||||
typeof snapStates.showCompose !== 'boolean'
|
||||
? snapStates.showCompose.replyToStatus
|
||||
: window.__COMPOSE__?.replyToStatus || null
|
||||
}
|
||||
editStatus={
|
||||
states.showCompose?.editStatus ||
|
||||
window.__COMPOSE__?.editStatus ||
|
||||
null
|
||||
}
|
||||
draftStatus={
|
||||
states.showCompose?.draftStatus ||
|
||||
window.__COMPOSE__?.draftStatus ||
|
||||
null
|
||||
}
|
||||
onClose={(results) => {
|
||||
const { newStatus, instance, type } = results || {};
|
||||
states.showCompose = false;
|
||||
window.__COMPOSE__ = null;
|
||||
if (newStatus) {
|
||||
states.reloadStatusPage++;
|
||||
showToast({
|
||||
text: {
|
||||
post: 'Post published. Check it out.',
|
||||
reply: 'Reply posted. Check it out.',
|
||||
edit: 'Post updated. Check it out.',
|
||||
}[type || 'post'],
|
||||
delay: 1000,
|
||||
duration: 10_000, // 10 seconds
|
||||
onClick: (toast) => {
|
||||
toast.hideToast();
|
||||
states.prevLocation = location;
|
||||
navigate(
|
||||
instance
|
||||
? `/${instance}/s/${newStatus.id}`
|
||||
: `/s/${newStatus.id}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
<IntlSegmenterSuspense>
|
||||
<Compose
|
||||
replyToStatus={
|
||||
typeof snapStates.showCompose !== 'boolean'
|
||||
? snapStates.showCompose.replyToStatus
|
||||
: window.__COMPOSE__?.replyToStatus || null
|
||||
}
|
||||
}}
|
||||
/>
|
||||
editStatus={
|
||||
states.showCompose?.editStatus ||
|
||||
window.__COMPOSE__?.editStatus ||
|
||||
null
|
||||
}
|
||||
draftStatus={
|
||||
states.showCompose?.draftStatus ||
|
||||
window.__COMPOSE__?.draftStatus ||
|
||||
null
|
||||
}
|
||||
onClose={(results) => {
|
||||
const { newStatus, instance, type } = results || {};
|
||||
states.showCompose = false;
|
||||
window.__COMPOSE__ = null;
|
||||
if (newStatus) {
|
||||
states.reloadStatusPage++;
|
||||
showToast({
|
||||
text: {
|
||||
post: 'Post published. Check it out.',
|
||||
reply: 'Reply posted. Check it out.',
|
||||
edit: 'Post updated. Check it out.',
|
||||
}[type || 'post'],
|
||||
delay: 1000,
|
||||
duration: 10_000, // 10 seconds
|
||||
onClick: (toast) => {
|
||||
toast.hideToast();
|
||||
states.prevLocation = location;
|
||||
navigate(
|
||||
instance
|
||||
? `/${instance}/s/${newStatus.id}`
|
||||
: `/s/${newStatus.id}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</IntlSegmenterSuspense>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showSettings && (
|
||||
|
@ -179,6 +185,7 @@ export default function Modals() {
|
|||
excludeRelationshipAttrs={
|
||||
snapStates.showGenericAccounts.excludeRelationshipAttrs
|
||||
}
|
||||
postID={snapStates.showGenericAccounts.postID}
|
||||
onClose={() => (states.showGenericAccounts = false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
|
|
@ -50,7 +50,11 @@ function NameText({
|
|||
class={`name-text ${showAcct ? 'show-acct' : ''} ${short ? 'short' : ''}`}
|
||||
href={url}
|
||||
target={external ? '_blank' : null}
|
||||
title={`${displayName ? `${displayName} ` : ''}@${acct}`}
|
||||
title={
|
||||
displayName
|
||||
? `${displayName} (${acct2 ? '' : '@'}${acct})`
|
||||
: `${acct2 ? '' : '@'}${acct}`
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (external) return;
|
||||
e.preventDefault();
|
||||
|
@ -88,8 +92,9 @@ function NameText({
|
|||
<>
|
||||
<br />
|
||||
<i>
|
||||
@{acct1}
|
||||
<span class="ib">{acct2}</span>
|
||||
{acct2 ? '' : '@'}
|
||||
{acct1}
|
||||
{!!acct2 && <span class="ib">{acct2}</span>}
|
||||
</i>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -7,11 +7,12 @@ import {
|
|||
SubMenu,
|
||||
} from '@szhsin/react-menu';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useLongPress } from 'use-long-press';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import { getLists } from '../utils/lists';
|
||||
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
|
||||
import states from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
|
@ -24,16 +25,12 @@ function NavMenu(props) {
|
|||
const snapStates = useSnapshot(states);
|
||||
const { masto, instance, authenticated } = api();
|
||||
|
||||
const [currentAccount, setCurrentAccount] = useState();
|
||||
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const [currentAccount, moreThanOneAccount] = useMemo(() => {
|
||||
const accounts = store.local.getJSON('accounts') || [];
|
||||
const acc = accounts.find(
|
||||
(account) => account.info.id === store.session.get('currentAccount'),
|
||||
);
|
||||
if (acc) setCurrentAccount(acc);
|
||||
setMoreThanOneAccount(accounts.length > 1);
|
||||
return [acc, accounts.length > 1];
|
||||
}, []);
|
||||
|
||||
// Home = Following
|
||||
|
@ -89,6 +86,13 @@ function NavMenu(props) {
|
|||
return results;
|
||||
}
|
||||
|
||||
const [lists, setLists] = useState([]);
|
||||
useEffect(() => {
|
||||
if (menuState === 'open') {
|
||||
getLists().then(setLists);
|
||||
}
|
||||
}, [menuState === 'open']);
|
||||
|
||||
const buttonClickTS = useRef();
|
||||
return (
|
||||
<>
|
||||
|
@ -97,7 +101,7 @@ function NavMenu(props) {
|
|||
type="button"
|
||||
class={`button plain nav-menu-button ${
|
||||
moreThanOneAccount ? 'with-avatar' : ''
|
||||
} ${open ? 'active' : ''}`}
|
||||
} ${menuState === 'open' ? 'active' : ''}`}
|
||||
style={{ position: 'relative' }}
|
||||
onClick={() => {
|
||||
buttonClickTS.current = Date.now();
|
||||
|
@ -203,9 +207,38 @@ function NavMenu(props) {
|
|||
<Icon icon="user" size="l" /> <span>Profile</span>
|
||||
</MenuLink>
|
||||
)}
|
||||
<MenuLink to="/l">
|
||||
<Icon icon="list" size="l" /> <span>Lists</span>
|
||||
</MenuLink>
|
||||
{lists?.length > 0 ? (
|
||||
<SubMenu
|
||||
overflow="auto"
|
||||
gap={-8}
|
||||
label={
|
||||
<>
|
||||
<Icon icon="list" size="l" />
|
||||
<span class="menu-grow">Lists</span>
|
||||
<Icon icon="chevron-right" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuLink to="/l">
|
||||
<span>All Lists</span>
|
||||
</MenuLink>
|
||||
{lists?.length > 0 && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
{lists.map((list) => (
|
||||
<MenuLink key={list.id} to={`/l/${list.id}`}>
|
||||
<span>{list.title}</span>
|
||||
</MenuLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SubMenu>
|
||||
) : (
|
||||
<MenuLink to="/l">
|
||||
<Icon icon="list" size="l" />
|
||||
<span>Lists</span>
|
||||
</MenuLink>
|
||||
)}
|
||||
<MenuLink to="/b">
|
||||
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
|
||||
</MenuLink>
|
||||
|
@ -223,11 +256,15 @@ function NavMenu(props) {
|
|||
<MenuLink to="/f">
|
||||
<Icon icon="heart" size="l" /> <span>Likes</span>
|
||||
</MenuLink>
|
||||
<MenuLink to="/ft">
|
||||
<MenuLink to="/fh">
|
||||
<Icon icon="hashtag" size="l" />{' '}
|
||||
<span>Followed Hashtags</span>
|
||||
</MenuLink>
|
||||
<MenuDivider />
|
||||
<MenuLink to="/ft">
|
||||
<Icon icon="filters" size="l" />
|
||||
Filters
|
||||
</MenuLink>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showGenericAccounts = {
|
||||
|
|
|
@ -7,6 +7,7 @@ import store from '../utils/store';
|
|||
import useTruncated from '../utils/useTruncated';
|
||||
|
||||
import Avatar from './avatar';
|
||||
import CustomEmoji from './custom-emoji';
|
||||
import FollowRequestButtons from './follow-request-buttons';
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
|
@ -25,6 +26,9 @@ const NOTIFICATION_ICONS = {
|
|||
update: 'pencil',
|
||||
'admin.signup': 'account-edit',
|
||||
'admin.report': 'account-warning',
|
||||
severed_relationships: 'heart-break',
|
||||
emoji_reaction: 'emoji2',
|
||||
'pleroma:emoji_reaction': 'emoji2',
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -42,6 +46,24 @@ admin.sign_up = Someone signed up (optionally sent to admins)
|
|||
admin.report = A new report has been filed
|
||||
*/
|
||||
|
||||
function emojiText(emoji, emoji_url) {
|
||||
let url;
|
||||
let staticUrl;
|
||||
if (typeof emoji_url === 'string') {
|
||||
url = emoji_url;
|
||||
} else {
|
||||
url = emoji_url?.url;
|
||||
staticUrl = emoji_url?.staticUrl;
|
||||
}
|
||||
return url ? (
|
||||
<>
|
||||
reacted to your post with{' '}
|
||||
<CustomEmoji url={url} staticUrl={staticUrl} alt={emoji} />
|
||||
</>
|
||||
) : (
|
||||
`reacted to your post with ${emoji}.`
|
||||
);
|
||||
}
|
||||
const contentText = {
|
||||
mention: 'mentioned you in their post.',
|
||||
status: 'published a post.',
|
||||
|
@ -63,6 +85,35 @@ const contentText = {
|
|||
'favourite+reblog_reply': 'boosted & liked your reply.',
|
||||
'admin.sign_up': 'signed up.',
|
||||
'admin.report': (targetAccount) => <>reported {targetAccount}</>,
|
||||
severed_relationships: (name) => (
|
||||
<>
|
||||
Lost connections with <i>{name}</i>.
|
||||
</>
|
||||
),
|
||||
emoji_reaction: emojiText,
|
||||
'pleroma:emoji_reaction': emojiText,
|
||||
};
|
||||
|
||||
// account_suspension, domain_block, user_domain_block
|
||||
const SEVERED_RELATIONSHIPS_TEXT = {
|
||||
account_suspension: ({ from, targetName }) => (
|
||||
<>
|
||||
An admin from <i>{from}</i> has suspended <i>{targetName}</i>, which means
|
||||
you can no longer receive updates from them or interact with them.
|
||||
</>
|
||||
),
|
||||
domain_block: ({ from, targetName, followersCount, followingCount }) => (
|
||||
<>
|
||||
An admin from <i>{from}</i> has blocked <i>{targetName}</i>. Affected
|
||||
followers: {followersCount}, followings: {followingCount}.
|
||||
</>
|
||||
),
|
||||
user_domain_block: ({ targetName, followersCount, followingCount }) => (
|
||||
<>
|
||||
You have blocked <i>{targetName}</i>. Removed followers: {followersCount},
|
||||
followings: {followingCount}.
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
const AVATARS_LIMIT = 50;
|
||||
|
@ -73,7 +124,8 @@ function Notification({
|
|||
isStatic,
|
||||
disableContextMenu,
|
||||
}) {
|
||||
const { id, status, account, report, _accounts, _statuses } = notification;
|
||||
const { id, status, account, report, event, _accounts, _statuses } =
|
||||
notification;
|
||||
let { type } = notification;
|
||||
|
||||
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
|
||||
|
@ -128,13 +180,30 @@ function Notification({
|
|||
|
||||
if (typeof text === 'function') {
|
||||
const count = _statuses?.length || _accounts?.length;
|
||||
if (count) {
|
||||
text = text(count);
|
||||
} else if (type === 'admin.report') {
|
||||
if (type === 'admin.report') {
|
||||
const targetAccount = report?.targetAccount;
|
||||
if (targetAccount) {
|
||||
text = text(<NameText account={targetAccount} showAvatar />);
|
||||
}
|
||||
} else if (type === 'severed_relationships') {
|
||||
const targetName = event?.targetName;
|
||||
if (targetName) {
|
||||
text = text(targetName);
|
||||
}
|
||||
} else if (
|
||||
(type === 'emoji_reaction' || type === 'pleroma:emoji_reaction') &&
|
||||
notification.emoji
|
||||
) {
|
||||
const emojiURL =
|
||||
notification.emoji_url || // This is string
|
||||
status?.emojis?.find?.(
|
||||
(emoji) =>
|
||||
emoji?.shortcode ===
|
||||
notification.emoji.replace(/^:/, '').replace(/:$/, ''),
|
||||
); // Emoji object instead of string
|
||||
text = text(notification.emoji, emojiURL);
|
||||
} else if (count) {
|
||||
text = text(count);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -203,9 +272,11 @@ function Notification({
|
|||
</b>{' '}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NameText account={account} showAvatar />{' '}
|
||||
</>
|
||||
account && (
|
||||
<>
|
||||
<NameText account={account} showAvatar />{' '}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
@ -224,6 +295,23 @@ function Notification({
|
|||
{type === 'follow_request' && (
|
||||
<FollowRequestButtons accountID={account.id} />
|
||||
)}
|
||||
{type === 'severed_relationships' && (
|
||||
<div>
|
||||
{SEVERED_RELATIONSHIPS_TEXT[event.type]({
|
||||
from: instance,
|
||||
...event,
|
||||
})}
|
||||
<br />
|
||||
<a
|
||||
href={`https://${instance}/severed_relationships`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more <Icon icon="external" size="s" />
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{_accounts?.length > 1 && (
|
||||
|
|
|
@ -14,6 +14,7 @@ import tabMenuBarUrl from '../assets/tab-menu-bar.svg';
|
|||
|
||||
import { api } from '../utils/api';
|
||||
import { fetchFollowedTags } from '../utils/followed-tags';
|
||||
import { getLists, getListTitle } from '../utils/lists';
|
||||
import pmem from '../utils/pmem';
|
||||
import showToast from '../utils/show-toast';
|
||||
import states from '../utils/states';
|
||||
|
@ -43,7 +44,7 @@ const TYPES = [
|
|||
const TYPE_TEXT = {
|
||||
following: 'Home / Following',
|
||||
notifications: 'Notifications',
|
||||
list: 'List',
|
||||
list: 'Lists',
|
||||
public: 'Public (Local / Federated)',
|
||||
search: 'Search',
|
||||
'account-statuses': 'Account',
|
||||
|
@ -58,6 +59,7 @@ const TYPE_PARAMS = {
|
|||
{
|
||||
text: 'List ID',
|
||||
name: 'id',
|
||||
notRequired: true,
|
||||
},
|
||||
],
|
||||
public: [
|
||||
|
@ -122,10 +124,6 @@ const TYPE_PARAMS = {
|
|||
},
|
||||
],
|
||||
};
|
||||
const fetchListTitle = pmem(async ({ id }) => {
|
||||
const list = await api().masto.v1.lists.$select(id).fetch();
|
||||
return list.title;
|
||||
});
|
||||
const fetchAccountTitle = pmem(async ({ id }) => {
|
||||
const account = await api().masto.v1.accounts.$select(id).fetch();
|
||||
return account.username || account.acct || account.displayName;
|
||||
|
@ -150,10 +148,11 @@ export const SHORTCUTS_META = {
|
|||
icon: 'notification',
|
||||
},
|
||||
list: {
|
||||
id: 'list',
|
||||
title: fetchListTitle,
|
||||
path: ({ id }) => `/l/${id}`,
|
||||
id: ({ id }) => (id ? 'list' : 'lists'),
|
||||
title: ({ id }) => (id ? getListTitle(id) : 'Lists'),
|
||||
path: ({ id }) => (id ? `/l/${id}` : '/l'),
|
||||
icon: 'list',
|
||||
excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []),
|
||||
},
|
||||
public: {
|
||||
id: 'public',
|
||||
|
@ -496,18 +495,8 @@ function ShortcutsSettings({ onClose }) {
|
|||
);
|
||||
}
|
||||
|
||||
const FETCH_MAX_AGE = 1000 * 60; // 1 minute
|
||||
const fetchLists = pmem(
|
||||
() => {
|
||||
const { masto } = api();
|
||||
return masto.v1.lists.list();
|
||||
},
|
||||
{
|
||||
maxAge: FETCH_MAX_AGE,
|
||||
},
|
||||
);
|
||||
|
||||
const FORM_NOTES = {
|
||||
list: `Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`,
|
||||
search: `For multi-column mode, search term is required, else the column will not be shown.`,
|
||||
hashtag: 'Multiple hashtags are supported. Space-separated.',
|
||||
};
|
||||
|
@ -532,8 +521,7 @@ function ShortcutForm({
|
|||
if (currentType !== 'list') return;
|
||||
try {
|
||||
setUIState('loading');
|
||||
const lists = await fetchLists();
|
||||
lists.sort((a, b) => a.title.localeCompare(b.title));
|
||||
const lists = await getLists();
|
||||
setLists(lists);
|
||||
setUIState('default');
|
||||
} catch (e) {
|
||||
|
@ -644,6 +632,7 @@ function ShortcutForm({
|
|||
disabled={disabled || uiState === 'loading'}
|
||||
defaultValue={editMode ? shortcut.id : undefined}
|
||||
>
|
||||
<option value=""></option>
|
||||
{lists.map((list) => (
|
||||
<option value={list.id}>{list.title}</option>
|
||||
))}
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import './shortcuts.css';
|
||||
|
||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||
import { MenuDivider, SubMenu } from '@szhsin/react-menu';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useMemo, useRef } from 'preact/hooks';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import { SHORTCUTS_META } from '../components/shortcuts-settings';
|
||||
import { api } from '../utils/api';
|
||||
import { getLists } from '../utils/lists';
|
||||
import states from '../utils/states';
|
||||
|
||||
import AsyncText from './AsyncText';
|
||||
|
@ -34,47 +35,48 @@ function Shortcuts() {
|
|||
|
||||
const menuRef = useRef();
|
||||
|
||||
const formattedShortcuts = useMemo(
|
||||
() =>
|
||||
shortcuts
|
||||
.map((pin, i) => {
|
||||
const { type, ...data } = pin;
|
||||
if (!SHORTCUTS_META[type]) return null;
|
||||
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
|
||||
const hasLists = useRef(false);
|
||||
const formattedShortcuts = shortcuts
|
||||
.map((pin, i) => {
|
||||
const { type, ...data } = pin;
|
||||
if (!SHORTCUTS_META[type]) return null;
|
||||
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
|
||||
|
||||
if (typeof id === 'function') {
|
||||
id = id(data, i);
|
||||
}
|
||||
if (typeof path === 'function') {
|
||||
path = path(
|
||||
{
|
||||
...data,
|
||||
instance: data.instance || instance,
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
if (typeof title === 'function') {
|
||||
title = title(data, i);
|
||||
}
|
||||
if (typeof subtitle === 'function') {
|
||||
subtitle = subtitle(data, i);
|
||||
}
|
||||
if (typeof icon === 'function') {
|
||||
icon = icon(data, i);
|
||||
}
|
||||
if (typeof id === 'function') {
|
||||
id = id(data, i);
|
||||
}
|
||||
if (typeof path === 'function') {
|
||||
path = path(
|
||||
{
|
||||
...data,
|
||||
instance: data.instance || instance,
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
if (typeof title === 'function') {
|
||||
title = title(data, i);
|
||||
}
|
||||
if (typeof subtitle === 'function') {
|
||||
subtitle = subtitle(data, i);
|
||||
}
|
||||
if (typeof icon === 'function') {
|
||||
icon = icon(data, i);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
path,
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
};
|
||||
})
|
||||
.filter(Boolean),
|
||||
[shortcuts],
|
||||
);
|
||||
if (id === 'lists') {
|
||||
hasLists.current = true;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
path,
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const navigate = useNavigate();
|
||||
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
|
||||
|
@ -88,6 +90,8 @@ function Shortcuts() {
|
|||
}
|
||||
});
|
||||
|
||||
const [lists, setLists] = useState([]);
|
||||
|
||||
return (
|
||||
<div id="shortcuts">
|
||||
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
|
||||
|
@ -147,6 +151,11 @@ function Shortcuts() {
|
|||
menuClassName="glass-menu shortcuts-menu"
|
||||
gap={8}
|
||||
position="anchor"
|
||||
onMenuChange={(e) => {
|
||||
if (e.open && hasLists.current) {
|
||||
getLists().then(setLists);
|
||||
}
|
||||
}}
|
||||
menuButton={
|
||||
<button
|
||||
type="button"
|
||||
|
@ -171,6 +180,35 @@ function Shortcuts() {
|
|||
}
|
||||
>
|
||||
{formattedShortcuts.map(({ id, path, title, subtitle, icon }, i) => {
|
||||
if (id === 'lists') {
|
||||
return (
|
||||
<SubMenu
|
||||
menuClassName="glass-menu"
|
||||
overflow="auto"
|
||||
gap={-8}
|
||||
label={
|
||||
<>
|
||||
<Icon icon={icon} size="l" />
|
||||
<span class="menu-grow">
|
||||
<AsyncText>{title}</AsyncText>
|
||||
</span>
|
||||
<Icon icon="chevron-right" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuLink to="/l">
|
||||
<span>All Lists</span>
|
||||
</MenuLink>
|
||||
<MenuDivider />
|
||||
{lists?.map((list) => (
|
||||
<MenuLink key={list.id} to={`/l/${list.id}`}>
|
||||
<span>{list.title}</span>
|
||||
</MenuLink>
|
||||
))}
|
||||
</SubMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuLink
|
||||
to={path}
|
||||
|
|
|
@ -1585,16 +1585,16 @@ a:focus-visible .card img {
|
|||
}
|
||||
.card .meta.domain {
|
||||
opacity: 1;
|
||||
color: var(--link-color);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
color: var(--text-insignificant-color);
|
||||
|
||||
.domain {
|
||||
color: var(--link-color);
|
||||
}
|
||||
}
|
||||
.card:visited .meta.domain {
|
||||
.card:visited .meta .domain {
|
||||
color: var(--link-visited-color);
|
||||
}
|
||||
.card .meta.domain * {
|
||||
.card .meta .domain * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
a.card {
|
||||
|
@ -1753,6 +1753,13 @@ a.card:is(:hover, :focus):visited {
|
|||
margin-left: calc(-50px - 16px);
|
||||
}
|
||||
|
||||
/* EMOJI REACTIONS */
|
||||
|
||||
.status.large .emoji-reactions {
|
||||
cursor: default;
|
||||
margin-left: calc(-50px - 16px);
|
||||
}
|
||||
|
||||
/* ACTIONS */
|
||||
|
||||
.status .actions {
|
||||
|
|
|
@ -24,8 +24,9 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
|||
import { useLongPress } from 'use-long-press';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import AccountBlock from '../components/account-block';
|
||||
import CustomEmoji from '../components/custom-emoji';
|
||||
import EmojiText from '../components/emoji-text';
|
||||
import LazyShazam from '../components/lazy-shazam';
|
||||
import Loader from '../components/loader';
|
||||
import Menu2 from '../components/menu2';
|
||||
import MenuConfirm from '../components/menu-confirm';
|
||||
|
@ -241,6 +242,8 @@ function Status({
|
|||
_deleted,
|
||||
_pinned,
|
||||
// _filtered,
|
||||
// Non-Mastodon
|
||||
emojiReactions,
|
||||
} = status;
|
||||
|
||||
const currentAccount = useMemo(() => {
|
||||
|
@ -724,25 +727,6 @@ function Status({
|
|||
const isPinnable = ['public', 'unlisted', 'private'].includes(visibility);
|
||||
const StatusMenuItems = (
|
||||
<>
|
||||
{isSizeLarge && (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showGenericAccounts = {
|
||||
heading: 'Boosted/Liked by…',
|
||||
fetchAccounts: fetchBoostedLikedByAccounts,
|
||||
instance,
|
||||
showReactions: true,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="react" />
|
||||
<span>
|
||||
Boosted/Liked by<span class="more-insignificant">…</span>
|
||||
</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{!isSizeLarge && sameInstance && (
|
||||
<>
|
||||
<div class="menu-control-group-horizontal status-menu">
|
||||
|
@ -840,6 +824,29 @@ function Status({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isSizeLarge && sameInstance && (isSizeLarge || showActionsBar) && (
|
||||
<MenuDivider />
|
||||
)}
|
||||
{(isSizeLarge || showActionsBar) && (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showGenericAccounts = {
|
||||
heading: 'Boosted/Liked by…',
|
||||
fetchAccounts: fetchBoostedLikedByAccounts,
|
||||
instance,
|
||||
showReactions: true,
|
||||
postID: sKey,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="react" />
|
||||
<span>
|
||||
Boosted/Liked by<span class="more-insignificant">…</span>
|
||||
</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{(enableTranslate || !language || differentLanguage) && <MenuDivider />}
|
||||
{enableTranslate ? (
|
||||
<div class={supportsTTS ? 'menu-horizontal' : ''}>
|
||||
|
@ -891,13 +898,12 @@ function Status({
|
|||
</div>
|
||||
)
|
||||
)}
|
||||
{!isSizeLarge ||
|
||||
((enableTranslate || !language || differentLanguage) && (
|
||||
<MenuDivider />
|
||||
))}
|
||||
{((!isSizeLarge && sameInstance) ||
|
||||
enableTranslate ||
|
||||
!language ||
|
||||
differentLanguage) && <MenuDivider />}
|
||||
{!isSizeLarge && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<MenuLink
|
||||
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
|
||||
onClick={(e) => {
|
||||
|
@ -1926,6 +1932,46 @@ function Status({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
{!!emojiReactions?.length && (
|
||||
<div class="emoji-reactions">
|
||||
{emojiReactions.map((emojiReaction) => {
|
||||
const { name, count, me } = emojiReaction;
|
||||
const isShortCode = /^:.+?:$/.test(name);
|
||||
if (isShortCode) {
|
||||
const emoji = emojis.find(
|
||||
(e) =>
|
||||
e.shortcode ===
|
||||
name.replace(/^:/, '').replace(/:$/, ''),
|
||||
);
|
||||
if (emoji) {
|
||||
return (
|
||||
<span
|
||||
class={`emoji-reaction tag ${
|
||||
me ? '' : 'insignificant'
|
||||
}`}
|
||||
>
|
||||
<CustomEmoji
|
||||
alt={name}
|
||||
url={emoji.url}
|
||||
staticUrl={emoji.staticUrl}
|
||||
/>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span
|
||||
class={`emoji-reaction tag ${
|
||||
me ? '' : 'insignificant'
|
||||
}`}
|
||||
>
|
||||
{name} {count}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div class={`actions ${_deleted ? 'disabled' : ''}`}>
|
||||
<div class="action has-count">
|
||||
<StatusButton
|
||||
|
@ -2229,8 +2275,14 @@ function Card({ card, selfReferential, instance }) {
|
|||
/>
|
||||
</div>
|
||||
<div class="meta-container">
|
||||
<p class="meta domain" dir="auto">
|
||||
{domain}
|
||||
<p class="meta domain">
|
||||
<span class="domain">{domain}</span>{' '}
|
||||
{!!publishedAt && <>· </>}
|
||||
{!!publishedAt && (
|
||||
<>
|
||||
<RelativeTime datetime={publishedAt} format="micro" />
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p class="title" dir="auto" title={title}>
|
||||
{title}
|
||||
|
@ -2302,10 +2354,22 @@ function Card({ card, selfReferential, instance }) {
|
|||
>
|
||||
<div class="meta-container">
|
||||
<p class="meta domain">
|
||||
<Icon icon="link" size="s" /> <span>{domain}</span>
|
||||
<span class="domain">
|
||||
<Icon icon="link" size="s" /> <span>{domain}</span>
|
||||
</span>{' '}
|
||||
{!!publishedAt && <>· </>}
|
||||
{!!publishedAt && (
|
||||
<>
|
||||
<RelativeTime datetime={publishedAt} format="micro" />
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p class="title" title={title}>
|
||||
{title}
|
||||
</p>
|
||||
<p class="meta" title={description || providerName || authorName}>
|
||||
{description || providerName || authorName}
|
||||
</p>
|
||||
<p class="title" title={title}>{title}</p>
|
||||
<p class="meta" title={description || providerName || authorName}>{description || providerName || authorName}</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
@ -3065,20 +3129,22 @@ const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
|
|||
|
||||
return uniqueQuotes.map((q) => {
|
||||
return (
|
||||
<Link
|
||||
key={q.instance + q.id}
|
||||
to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
|
||||
class="status-card-link"
|
||||
data-read-more="Read more →"
|
||||
>
|
||||
<Status
|
||||
statusID={q.id}
|
||||
instance={q.instance}
|
||||
size="s"
|
||||
quoted={level + 1}
|
||||
enableCommentHint
|
||||
/>
|
||||
</Link>
|
||||
<LazyShazam>
|
||||
<Link
|
||||
key={q.instance + q.id}
|
||||
to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
|
||||
class="status-card-link"
|
||||
data-read-more="Read more →"
|
||||
>
|
||||
<Status
|
||||
statusID={q.id}
|
||||
instance={q.instance}
|
||||
size="s"
|
||||
quoted={level + 1}
|
||||
enableCommentHint
|
||||
/>
|
||||
</Link>
|
||||
</LazyShazam>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -209,17 +209,13 @@ function Timeline({
|
|||
const showNewPostsIndicator =
|
||||
items.length > 0 && uiState !== 'loading' && showNew;
|
||||
const handleLoadNewPosts = useCallback(() => {
|
||||
loadItems(true);
|
||||
if (showNewPostsIndicator) loadItems(true);
|
||||
scrollableRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [loadItems]);
|
||||
const dotRef = useHotkeys('.', () => {
|
||||
if (showNewPostsIndicator) {
|
||||
handleLoadNewPosts();
|
||||
}
|
||||
});
|
||||
}, [loadItems, showNewPostsIndicator]);
|
||||
const dotRef = useHotkeys('.', handleLoadNewPosts);
|
||||
|
||||
// const {
|
||||
// scrollDirection,
|
||||
|
@ -365,6 +361,7 @@ function Timeline({
|
|||
jRef.current = node;
|
||||
kRef.current = node;
|
||||
oRef.current = node;
|
||||
dotRef.current = node;
|
||||
}}
|
||||
tabIndex="-1"
|
||||
>
|
||||
|
|
|
@ -3,11 +3,15 @@ import './index.css';
|
|||
import './app.css';
|
||||
|
||||
import { render } from 'preact';
|
||||
import { lazy } from 'preact/compat';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import Compose from './components/compose';
|
||||
import IntlSegmenterSuspense from './components/intl-segmenter-suspense';
|
||||
// import Compose from './components/compose';
|
||||
import useTitle from './utils/useTitle';
|
||||
|
||||
const Compose = lazy(() => import('./components/compose'));
|
||||
|
||||
if (window.opener) {
|
||||
console = window.opener.console;
|
||||
}
|
||||
|
@ -57,23 +61,25 @@ function App() {
|
|||
console.debug('OPEN COMPOSE');
|
||||
|
||||
return (
|
||||
<Compose
|
||||
editStatus={editStatus}
|
||||
replyToStatus={replyToStatus}
|
||||
draftStatus={draftStatus}
|
||||
standalone
|
||||
hasOpener={window.opener}
|
||||
onClose={(results) => {
|
||||
const { newStatus, fn = () => {} } = results || {};
|
||||
try {
|
||||
if (newStatus) {
|
||||
window.opener.__STATES__.reloadStatusPage++;
|
||||
}
|
||||
fn();
|
||||
setUIState('closed');
|
||||
} catch (e) {}
|
||||
}}
|
||||
/>
|
||||
<IntlSegmenterSuspense>
|
||||
<Compose
|
||||
editStatus={editStatus}
|
||||
replyToStatus={replyToStatus}
|
||||
draftStatus={draftStatus}
|
||||
standalone
|
||||
hasOpener={window.opener}
|
||||
onClose={(results) => {
|
||||
const { newStatus, fn = () => {} } = results || {};
|
||||
try {
|
||||
if (newStatus) {
|
||||
window.opener.__STATES__.reloadStatusPage++;
|
||||
}
|
||||
fn();
|
||||
setUIState('closed');
|
||||
} catch (e) {}
|
||||
}}
|
||||
/>
|
||||
</IntlSegmenterSuspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"@mastodon/edit-media-attributes": ">=4.1",
|
||||
"@mastodon/list-exclusive": ">=4.2"
|
||||
"@mastodon/list-exclusive": ">=4.2",
|
||||
"@mastodon/filtered-notifications": "~4.3 || >=4.3"
|
||||
}
|
||||
|
|
|
@ -24,6 +24,11 @@
|
|||
transparent
|
||||
);
|
||||
--red-color: orangered;
|
||||
--red-text-color: color-mix(
|
||||
in srgb-linear,
|
||||
var(--red-color) 60%,
|
||||
var(--text-color) 40%
|
||||
);
|
||||
--red-bg-color: color-mix(in lch, var(--red-color) 40%, transparent);
|
||||
--bg-color: #fff;
|
||||
--bg-faded-color: #f0f2f5;
|
||||
|
@ -227,7 +232,7 @@ button[hidden] {
|
|||
}
|
||||
:is(button, .button):not(:disabled, .disabled):is(:hover, :focus) {
|
||||
cursor: pointer;
|
||||
filter: brightness(1.2);
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
:is(button, .button):not(:disabled, .disabled):active {
|
||||
filter: brightness(0.8);
|
||||
|
|
|
@ -4,7 +4,7 @@ import './cloak-mode.css';
|
|||
|
||||
// Polyfill needed for Firefox < 122
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
|
||||
import '@formatjs/intl-segmenter/polyfill';
|
||||
// import '@formatjs/intl-segmenter/polyfill';
|
||||
import { render } from 'preact';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
|
||||
|
|
|
@ -206,8 +206,12 @@ function AccountStatuses() {
|
|||
const [featuredTags, setFeaturedTags] = useState([]);
|
||||
useTitle(
|
||||
account?.acct
|
||||
? `${account?.displayName ? account.displayName + ' ' : ''}@${
|
||||
account.acct
|
||||
? `${
|
||||
account?.displayName
|
||||
? `${account.displayName} (${/@/.test(account.acct) ? '' : '@'}${
|
||||
account.acct
|
||||
})`
|
||||
: `${/@/.test(account.acct) ? '' : '@'}${account.acct}`
|
||||
}${
|
||||
!excludeReplies
|
||||
? ' (+ Replies)'
|
||||
|
|
|
@ -813,6 +813,10 @@
|
|||
text-decoration: none;
|
||||
text-decoration-color: transparent;
|
||||
color: var(--link-text-color);
|
||||
|
||||
span {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -431,7 +431,7 @@ function Catchup() {
|
|||
|
||||
// Deduplicate boosts
|
||||
const boostedPosts = {};
|
||||
filteredPosts = filteredPosts.filter((post) => {
|
||||
filteredPosts.forEach((post) => {
|
||||
if (post.reblog) {
|
||||
if (boostedPosts[post.reblog.id]) {
|
||||
if (boostedPosts[post.reblog.id].__BOOSTERS) {
|
||||
|
@ -439,12 +439,11 @@ function Catchup() {
|
|||
} else {
|
||||
boostedPosts[post.reblog.id].__BOOSTERS = new Set([post.account]);
|
||||
}
|
||||
return false;
|
||||
post.__HIDDEN = true;
|
||||
} else {
|
||||
boostedPosts[post.reblog.id] = post;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (selectedAuthor && authorCountsMap.has(selectedAuthor)) {
|
||||
|
@ -479,39 +478,41 @@ function Catchup() {
|
|||
authorCountsList.forEach((authorID, index) => {
|
||||
authorIndices[authorID] = index;
|
||||
});
|
||||
return filteredPosts.sort((a, b) => {
|
||||
if (groupBy === 'account') {
|
||||
const aAccountID = a.account.id;
|
||||
const bAccountID = b.account.id;
|
||||
const aIndex = authorIndices[aAccountID];
|
||||
const bIndex = authorIndices[bAccountID];
|
||||
const order = aIndex - bIndex;
|
||||
if (order !== 0) {
|
||||
return order;
|
||||
return filteredPosts
|
||||
.filter((post) => !post.__HIDDEN)
|
||||
.sort((a, b) => {
|
||||
if (groupBy === 'account') {
|
||||
const aAccountID = a.account.id;
|
||||
const bAccountID = b.account.id;
|
||||
const aIndex = authorIndices[aAccountID];
|
||||
const bIndex = authorIndices[bAccountID];
|
||||
const order = aIndex - bIndex;
|
||||
if (order !== 0) {
|
||||
return order;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sortBy !== 'createdAt') {
|
||||
a = a.reblog || a;
|
||||
b = b.reblog || b;
|
||||
if (sortBy !== 'density' && a[sortBy] === b[sortBy]) {
|
||||
return a.createdAt > b.createdAt ? 1 : -1;
|
||||
if (sortBy !== 'createdAt') {
|
||||
a = a.reblog || a;
|
||||
b = b.reblog || b;
|
||||
if (sortBy !== 'density' && a[sortBy] === b[sortBy]) {
|
||||
return a.createdAt > b.createdAt ? 1 : -1;
|
||||
}
|
||||
}
|
||||
if (sortBy === 'density') {
|
||||
const aDensity = postDensity(a);
|
||||
const bDensity = postDensity(b);
|
||||
if (sortOrder === 'asc') {
|
||||
return aDensity > bDensity ? 1 : -1;
|
||||
} else {
|
||||
return bDensity > aDensity ? 1 : -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sortBy === 'density') {
|
||||
const aDensity = postDensity(a);
|
||||
const bDensity = postDensity(b);
|
||||
if (sortOrder === 'asc') {
|
||||
return aDensity > bDensity ? 1 : -1;
|
||||
return a[sortBy] > b[sortBy] ? 1 : -1;
|
||||
} else {
|
||||
return bDensity > aDensity ? 1 : -1;
|
||||
return b[sortBy] > a[sortBy] ? 1 : -1;
|
||||
}
|
||||
}
|
||||
if (sortOrder === 'asc') {
|
||||
return a[sortBy] > b[sortBy] ? 1 : -1;
|
||||
} else {
|
||||
return b[sortBy] > a[sortBy] ? 1 : -1;
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [filteredPosts, sortBy, sortOrder, groupBy, authorCountsList]);
|
||||
|
||||
const prevGroup = useRef(null);
|
||||
|
@ -955,10 +956,12 @@ function Catchup() {
|
|||
<Link to={`/catchup?id=${pc.id}`}>
|
||||
<Icon icon="history2" />{' '}
|
||||
<span>
|
||||
{formatRange(
|
||||
new Date(pc.startAt),
|
||||
new Date(pc.endAt),
|
||||
)}
|
||||
{pc.startAt
|
||||
? dtf.formatRange(
|
||||
new Date(pc.startAt),
|
||||
new Date(pc.endAt),
|
||||
)
|
||||
: `… – ${dtf.format(new Date(pc.endAt))}`}
|
||||
</span>
|
||||
</Link>{' '}
|
||||
<span>
|
||||
|
@ -1010,7 +1013,7 @@ function Catchup() {
|
|||
{posts.length > 0 && (
|
||||
<p>
|
||||
<b class="ib">
|
||||
{formatRange(
|
||||
{dtf.formatRange(
|
||||
new Date(posts[0].createdAt),
|
||||
new Date(posts[posts.length - 1].createdAt),
|
||||
)}
|
||||
|
@ -1131,7 +1134,12 @@ function Catchup() {
|
|||
)}
|
||||
</div>
|
||||
{!!title && (
|
||||
<h1 class="title" lang={language} dir="auto">
|
||||
<h1
|
||||
class="title"
|
||||
lang={language}
|
||||
dir="auto"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
|
@ -1141,6 +1149,7 @@ function Catchup() {
|
|||
class="description"
|
||||
lang={language}
|
||||
dir="auto"
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
|
@ -1835,9 +1844,6 @@ const dtf = new Intl.DateTimeFormat(locale, {
|
|||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
});
|
||||
function formatRange(startDate, endDate) {
|
||||
return dtf.formatRange(startDate, endDate);
|
||||
}
|
||||
|
||||
function binByTime(data, key, numBins) {
|
||||
// Extract dates from data objects
|
||||
|
|
149
src/pages/filters.css
Normal file
149
src/pages/filters.css
Normal file
|
@ -0,0 +1,149 @@
|
|||
#filters-page {
|
||||
.filters-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
padding: 8px 16px;
|
||||
border-bottom: var(--hairline-width) solid var(--outline-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#filters-add-edit-modal {
|
||||
.filter-form-row {
|
||||
margin-bottom: 16px;
|
||||
|
||||
+ .filter-form-row {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--outline-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
padding-top: 10px;
|
||||
line-height: 1.5;
|
||||
|
||||
p {
|
||||
margin-block: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-form-keywords {
|
||||
margin: 0 -16px 16px;
|
||||
}
|
||||
|
||||
.filter-form-cols {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.filter-form-col {
|
||||
flex-basis: 160px;
|
||||
flex-grow: 1;
|
||||
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-keywords {
|
||||
--gap: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap);
|
||||
padding: var(--gap);
|
||||
overflow-y: auto;
|
||||
min-height: 80px;
|
||||
max-height: 25vh;
|
||||
background-color: var(--bg-faded-blur-color);
|
||||
counter-reset: index;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
li {
|
||||
counter-increment: index;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&:not(:only-child):before {
|
||||
content: counter(index);
|
||||
font-size: 10px;
|
||||
color: var(--text-insignificant-color);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
flex-basis: 160px;
|
||||
flex-grow: 100;
|
||||
}
|
||||
|
||||
.filter-keyword-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
label {
|
||||
font-size: 0.8em;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-keywords-footer {
|
||||
padding: 8px 16px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-form-footer {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
> span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
padding-inline: 24px;
|
||||
}
|
||||
}
|
||||
}
|
580
src/pages/filters.jsx
Normal file
580
src/pages/filters.jsx
Normal file
|
@ -0,0 +1,580 @@
|
|||
import './filters.css';
|
||||
|
||||
import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import Icon from '../components/icon';
|
||||
import Link from '../components/link';
|
||||
import Loader from '../components/loader';
|
||||
import MenuConfirm from '../components/menu-confirm';
|
||||
import Modal from '../components/modal';
|
||||
import NavMenu from '../components/nav-menu';
|
||||
import RelativeTime from '../components/relative-time';
|
||||
import { api } from '../utils/api';
|
||||
import useInterval from '../utils/useInterval';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
const FILTER_CONTEXT = ['home', 'public', 'notifications', 'thread', 'account'];
|
||||
const FILTER_CONTEXT_UNIMPLEMENTED = ['notifications', 'thread', 'account'];
|
||||
const FILTER_CONTEXT_LABELS = {
|
||||
home: 'Home and lists',
|
||||
notifications: 'Notifications',
|
||||
public: 'Public timelines',
|
||||
thread: 'Conversations',
|
||||
account: 'Profiles',
|
||||
};
|
||||
|
||||
const EXPIRY_DURATIONS = [
|
||||
0, // forever
|
||||
30 * 60, // 30 minutes
|
||||
60 * 60, // 1 hour
|
||||
6 * 60 * 60, // 6 hours
|
||||
12 * 60 * 60, // 12 hours
|
||||
60 * 60 * 24, // 24 hours
|
||||
60 * 60 * 24 * 7, // 7 days
|
||||
60 * 60 * 24 * 30, // 30 days
|
||||
];
|
||||
const EXPIRY_DURATIONS_LABELS = {
|
||||
0: 'Never',
|
||||
1800: '30 minutes',
|
||||
3600: '1 hour',
|
||||
21600: '6 hours',
|
||||
43200: '12 hours',
|
||||
86_400: '24 hours',
|
||||
604_800: '7 days',
|
||||
2_592_000: '30 days',
|
||||
};
|
||||
|
||||
function Filters() {
|
||||
const { masto } = api();
|
||||
useTitle(`Filters`, `/ft`);
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [showFiltersAddEditModal, setShowFiltersAddEditModal] = useState(false);
|
||||
|
||||
const [reloadCount, reload] = useReducer((c) => c + 1, 0);
|
||||
const [filters, setFilters] = useState([]);
|
||||
useEffect(() => {
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const filters = await masto.v2.filters.list();
|
||||
filters.sort((a, b) => a.title.localeCompare(b.title));
|
||||
filters.forEach((filter) => {
|
||||
if (filter.keywords?.length) {
|
||||
filter.keywords.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
});
|
||||
console.log(filters);
|
||||
setFilters(filters);
|
||||
setUIState('default');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setUIState('error');
|
||||
}
|
||||
})();
|
||||
}, [reloadCount]);
|
||||
|
||||
return (
|
||||
<div id="filters-page" class="deck-container" tabIndex="-1">
|
||||
<div class="timeline-deck deck">
|
||||
<header>
|
||||
<div class="header-grid">
|
||||
<div class="header-side">
|
||||
<NavMenu />
|
||||
<Link to="/" class="button plain">
|
||||
<Icon icon="home" size="l" />
|
||||
</Link>
|
||||
</div>
|
||||
<h1>Filters</h1>
|
||||
<div class="header-side">
|
||||
<button
|
||||
type="button"
|
||||
class="plain"
|
||||
onClick={() => {
|
||||
setShowFiltersAddEditModal(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="plus" size="l" alt="New filter" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
{filters.length > 0 ? (
|
||||
<>
|
||||
<ul class="filters-list">
|
||||
{filters.map((filter) => {
|
||||
const { id, title, expiresAt, keywords } = filter;
|
||||
return (
|
||||
<li key={id}>
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{keywords?.length > 0 && (
|
||||
<div>
|
||||
{keywords.map((k) => (
|
||||
<>
|
||||
<span class="tag collapsed insignificant">
|
||||
{k.wholeWord ? `“${k.keyword}”` : k.keyword}
|
||||
</span>{' '}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<small class="insignificant">
|
||||
<ExpiryStatus expiresAt={expiresAt} />
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="plain"
|
||||
onClick={() => {
|
||||
setShowFiltersAddEditModal({
|
||||
filter,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="pencil" size="l" alt="Edit filter" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{filters.length > 1 && (
|
||||
<footer class="ui-state">
|
||||
<small class="insignificant">
|
||||
{filters.length} filter
|
||||
{filters.length === 1 ? '' : 's'}
|
||||
</small>
|
||||
</footer>
|
||||
)}
|
||||
</>
|
||||
) : uiState === 'loading' ? (
|
||||
<p class="ui-state">
|
||||
<Loader />
|
||||
</p>
|
||||
) : uiState === 'error' ? (
|
||||
<p class="ui-state">Unable to load filters.</p>
|
||||
) : (
|
||||
<p class="ui-state">No filters yet.</p>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
{!!showFiltersAddEditModal && (
|
||||
<Modal
|
||||
title="Add filter"
|
||||
onClose={() => {
|
||||
setShowFiltersAddEditModal(false);
|
||||
}}
|
||||
>
|
||||
<FiltersAddEdit
|
||||
filter={showFiltersAddEditModal?.filter}
|
||||
onClose={(result) => {
|
||||
if (result.state === 'success') {
|
||||
reload();
|
||||
}
|
||||
setShowFiltersAddEditModal(false);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FiltersAddEdit({ filter, onClose }) {
|
||||
const { masto } = api();
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const editMode = !!filter;
|
||||
const { context, expiresAt, id, keywords, title, filterAction } =
|
||||
filter || {};
|
||||
const hasExpiry = !!expiresAt;
|
||||
const expiresAtDate = hasExpiry && new Date(expiresAt);
|
||||
const [editKeywords, setEditKeywords] = useState(keywords || []);
|
||||
const keywordsRef = useRef();
|
||||
|
||||
// Hacky way of handling removed keywords for both existing and new ones
|
||||
const [removedKeywordIDs, setRemovedKeywordIDs] = useState([]);
|
||||
const [removedNewKeywordIndices, setRemovedNewKeywordIndices] = useState([]);
|
||||
|
||||
return (
|
||||
<div class="sheet" id="filters-add-edit-modal">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<h2>{editMode ? 'Edit filter' : 'New filter'}</h2>
|
||||
</header>
|
||||
<main>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const title = formData.get('title');
|
||||
const keywordIDs = formData.getAll('keyword_attributes[][id]');
|
||||
const keywordKeywords = formData.getAll(
|
||||
'keyword_attributes[][keyword]',
|
||||
);
|
||||
// const keywordWholeWords = formData.getAll(
|
||||
// 'keyword_attributes[][whole_word]',
|
||||
// );
|
||||
// Not using getAll because it skips the empty checkboxes
|
||||
const keywordWholeWords = [
|
||||
...keywordsRef.current.querySelectorAll(
|
||||
'input[name="keyword_attributes[][whole_word]"]',
|
||||
),
|
||||
].map((i) => i.checked);
|
||||
const keywordsAttributes = keywordKeywords.map((k, i) => ({
|
||||
id: keywordIDs[i] || undefined,
|
||||
keyword: k,
|
||||
wholeWord: keywordWholeWords[i],
|
||||
}));
|
||||
// if (editMode && keywords?.length) {
|
||||
// // Find which one got deleted and add to keywordsAttributes
|
||||
// keywords.forEach((k) => {
|
||||
// if (!keywordsAttributes.find((ka) => ka.id === k.id)) {
|
||||
// keywordsAttributes.push({
|
||||
// ...k,
|
||||
// _destroy: true,
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
if (editMode && removedKeywordIDs?.length) {
|
||||
removedKeywordIDs.forEach((id) => {
|
||||
keywordsAttributes.push({
|
||||
id,
|
||||
_destroy: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
const context = formData.getAll('context');
|
||||
let expiresIn = formData.get('expires_in');
|
||||
const filterAction = formData.get('filter_action');
|
||||
console.log({
|
||||
title,
|
||||
keywordIDs,
|
||||
keywords: keywordKeywords,
|
||||
wholeWords: keywordWholeWords,
|
||||
keywordsAttributes,
|
||||
context,
|
||||
expiresIn,
|
||||
filterAction,
|
||||
});
|
||||
|
||||
// Required fields
|
||||
if (!title || !context?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUIState('loading');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
let filterResult;
|
||||
|
||||
if (editMode) {
|
||||
if (expiresIn === '' || expiresIn === null) {
|
||||
// No value
|
||||
// Preserve existing expiry if not specified
|
||||
// Seconds from now to expiresAtDate
|
||||
// Other clients don't do this
|
||||
expiresIn = Math.floor((expiresAtDate - new Date()) / 1000);
|
||||
} else if (expiresIn === '0' || expiresIn === 0) {
|
||||
// 0 = Never
|
||||
expiresIn = null;
|
||||
} else {
|
||||
expiresIn = +expiresIn;
|
||||
}
|
||||
filterResult = await masto.v2.filters.$select(id).update({
|
||||
title,
|
||||
context,
|
||||
expiresIn,
|
||||
keywordsAttributes,
|
||||
filterAction,
|
||||
});
|
||||
} else {
|
||||
expiresIn = +expiresIn || null;
|
||||
filterResult = await masto.v2.filters.create({
|
||||
title,
|
||||
context,
|
||||
expiresIn,
|
||||
keywordsAttributes,
|
||||
filterAction,
|
||||
});
|
||||
}
|
||||
console.log({ filterResult });
|
||||
setUIState('default');
|
||||
onClose?.({
|
||||
state: 'success',
|
||||
filter: filterResult,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setUIState('error');
|
||||
alert(
|
||||
editMode
|
||||
? 'Unable to edit filter'
|
||||
: 'Unable to create filter',
|
||||
);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<div class="filter-form-row">
|
||||
<label>
|
||||
<b>Title</b>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
defaultValue={title}
|
||||
disabled={uiState === 'loading'}
|
||||
dir="auto"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-form-keywords" ref={keywordsRef}>
|
||||
{editKeywords.length ? (
|
||||
<ul class="filter-keywords">
|
||||
{editKeywords.map((k, index) => {
|
||||
const { id, keyword, wholeWord } = k;
|
||||
const removed =
|
||||
removedKeywordIDs.includes(id) ||
|
||||
removedNewKeywordIndices.includes(index);
|
||||
if (removed) return null;
|
||||
return (
|
||||
<li key={`${index}-${id}`}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="keyword_attributes[][id]"
|
||||
value={id}
|
||||
/>
|
||||
<input
|
||||
name="keyword_attributes[][keyword]"
|
||||
type="text"
|
||||
defaultValue={keyword}
|
||||
disabled={uiState === 'loading'}
|
||||
required
|
||||
/>
|
||||
<div class="filter-keyword-actions">
|
||||
<label>
|
||||
<input
|
||||
name="keyword_attributes[][whole_word]"
|
||||
type="checkbox"
|
||||
value={id} // Hacky way to map checkbox boolean to the keyword id
|
||||
defaultChecked={wholeWord}
|
||||
disabled={uiState === 'loading'}
|
||||
/>{' '}
|
||||
Whole word
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="light danger small"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
if (id) {
|
||||
removedKeywordIDs.push(id);
|
||||
setRemovedKeywordIDs([...removedKeywordIDs]);
|
||||
} else {
|
||||
// If no id, remove by index
|
||||
removedNewKeywordIndices.push(index);
|
||||
setRemovedNewKeywordIndices([
|
||||
...removedNewKeywordIndices,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div class="filter-keywords">
|
||||
<div class="insignificant">No keywords. Add one.</div>
|
||||
</div>
|
||||
)}
|
||||
<footer class="filter-keywords-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="light"
|
||||
onClick={() => {
|
||||
setEditKeywords([
|
||||
...editKeywords,
|
||||
{
|
||||
keyword: '',
|
||||
wholeWord: true,
|
||||
},
|
||||
]);
|
||||
setTimeout(() => {
|
||||
// Focus last input
|
||||
const fields =
|
||||
keywordsRef.current.querySelectorAll(
|
||||
'input[type="text"]',
|
||||
);
|
||||
fields[fields.length - 1]?.focus?.();
|
||||
}, 10);
|
||||
}}
|
||||
>
|
||||
Add keyword
|
||||
</button>{' '}
|
||||
{editKeywords?.length > 1 && (
|
||||
<small class="insignificant">
|
||||
{editKeywords.length} keyword
|
||||
{editKeywords.length === 1 ? '' : 's'}
|
||||
</small>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
<div class="filter-form-cols">
|
||||
<div class="filter-form-col">
|
||||
<div>
|
||||
<b>Filter from…</b>
|
||||
</div>
|
||||
{FILTER_CONTEXT.map((ctx) => (
|
||||
<div>
|
||||
<label
|
||||
class={
|
||||
FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx)
|
||||
? 'insignificant'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="context"
|
||||
value={ctx}
|
||||
defaultChecked={!!context ? context.includes(ctx) : true}
|
||||
disabled={uiState === 'loading'}
|
||||
/>{' '}
|
||||
{FILTER_CONTEXT_LABELS[ctx]}
|
||||
{FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx) ? '*' : ''}
|
||||
</label>{' '}
|
||||
</div>
|
||||
))}
|
||||
<p>
|
||||
<small class="insignificant">* Not implemented yet</small>
|
||||
</p>
|
||||
</div>
|
||||
<div class="filter-form-col">
|
||||
{editMode && (
|
||||
<>
|
||||
Status:{' '}
|
||||
<b>
|
||||
<ExpiryStatus expiresAt={expiresAt} showNeverExpires />
|
||||
</b>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label for="filters-expires_in">
|
||||
{editMode ? 'Change expiry' : 'Expiry'}
|
||||
</label>
|
||||
<select
|
||||
id="filters-expires_in"
|
||||
name="expires_in"
|
||||
disabled={uiState === 'loading'}
|
||||
defaultValue={editMode ? undefined : 0}
|
||||
>
|
||||
{editMode && <option></option>}
|
||||
{EXPIRY_DURATIONS.map((v) => (
|
||||
<option value={v}>{EXPIRY_DURATIONS_LABELS[v]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p>
|
||||
Filtered post will be…
|
||||
<br />
|
||||
<label class="ib">
|
||||
<input
|
||||
type="radio"
|
||||
name="filter_action"
|
||||
value="warn"
|
||||
defaultChecked={filterAction === 'warn' || !editMode}
|
||||
disabled={uiState === 'loading'}
|
||||
/>{' '}
|
||||
minimized
|
||||
</label>{' '}
|
||||
<label class="ib">
|
||||
<input
|
||||
type="radio"
|
||||
name="filter_action"
|
||||
value="hide"
|
||||
defaultChecked={filterAction === 'hide'}
|
||||
disabled={uiState === 'loading'}
|
||||
/>{' '}
|
||||
hidden
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="filter-form-footer">
|
||||
<span>
|
||||
<button type="submit" disabled={uiState === 'loading'}>
|
||||
{editMode ? 'Save' : 'Create'}
|
||||
</button>{' '}
|
||||
<Loader abrupt hidden={uiState !== 'loading'} />
|
||||
</span>
|
||||
{editMode && (
|
||||
<MenuConfirm
|
||||
disabled={uiState === 'loading'}
|
||||
align="end"
|
||||
menuItemClassName="danger"
|
||||
confirmLabel="Delete this filter?"
|
||||
onClick={() => {
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
await masto.v2.filters.$select(id).remove();
|
||||
setUIState('default');
|
||||
onClose?.({
|
||||
state: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setUIState('error');
|
||||
alert('Unable to delete filter.');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="light danger"
|
||||
onClick={() => {}}
|
||||
disabled={uiState === 'loading'}
|
||||
>
|
||||
Delete…
|
||||
</button>
|
||||
</MenuConfirm>
|
||||
)}
|
||||
</footer>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpiryStatus({ expiresAt, showNeverExpires }) {
|
||||
const hasExpiry = !!expiresAt;
|
||||
const expiresAtDate = hasExpiry && new Date(expiresAt);
|
||||
const expired = hasExpiry && expiresAtDate <= new Date();
|
||||
|
||||
// If less than a minute left, re-render interval every second, else every minute
|
||||
const [_, rerender] = useReducer((c) => c + 1, 0);
|
||||
useInterval(rerender, expired || 30_000);
|
||||
|
||||
return expired ? (
|
||||
'Expired'
|
||||
) : hasExpiry ? (
|
||||
<>
|
||||
Expiring <RelativeTime datetime={expiresAtDate} />
|
||||
</>
|
||||
) : (
|
||||
showNeverExpires && 'Never expires'
|
||||
);
|
||||
}
|
||||
|
||||
export default Filters;
|
|
@ -10,7 +10,7 @@ import useTitle from '../utils/useTitle';
|
|||
|
||||
function FollowedHashtags() {
|
||||
const { masto, instance } = api();
|
||||
useTitle(`Followed Hashtags`, `/ft`);
|
||||
useTitle(`Followed Hashtags`, `/fh`);
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
||||
const [followedHashtags, setFollowedHashtags] = useState([]);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import './lists.css';
|
||||
|
||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||
import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
@ -12,10 +12,12 @@ import Link from '../components/link';
|
|||
import ListAddEdit from '../components/list-add-edit';
|
||||
import Menu2 from '../components/menu2';
|
||||
import MenuConfirm from '../components/menu-confirm';
|
||||
import MenuLink from '../components/menu-link';
|
||||
import Modal from '../components/modal';
|
||||
import Timeline from '../components/timeline';
|
||||
import { api } from '../utils/api';
|
||||
import { filteredItems } from '../utils/filters';
|
||||
import { getList, getLists } from '../utils/lists';
|
||||
import states, { saveStatus } from '../utils/states';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
|
@ -71,13 +73,18 @@ function List(props) {
|
|||
}
|
||||
}
|
||||
|
||||
const [lists, setLists] = useState([]);
|
||||
useEffect(() => {
|
||||
getLists().then(setLists);
|
||||
}, []);
|
||||
|
||||
const [list, setList] = useState({ title: 'List' });
|
||||
// const [title, setTitle] = useState(`List`);
|
||||
useTitle(list.title, `/l/:id`);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const list = await masto.v1.lists.$select(id).fetch();
|
||||
const list = await getList(id);
|
||||
setList(list);
|
||||
// setTitle(list.title);
|
||||
} catch (e) {
|
||||
|
@ -107,9 +114,32 @@ function List(props) {
|
|||
showReplyParent
|
||||
// refresh={reloadCount}
|
||||
headerStart={
|
||||
<Link to="/l" class="button plain">
|
||||
<Icon icon="list" size="l" />
|
||||
</Link>
|
||||
// <Link to="/l" class="button plain">
|
||||
// <Icon icon="list" size="l" />
|
||||
// </Link>
|
||||
<Menu2
|
||||
overflow="auto"
|
||||
menuButton={
|
||||
<button type="button" class="plain">
|
||||
<Icon icon="list" size="l" alt="Lists" />
|
||||
<Icon icon="chevron-down" size="s" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<MenuLink to="/l">
|
||||
<span>All Lists</span>
|
||||
</MenuLink>
|
||||
{lists?.length > 0 && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
{lists.map((list) => (
|
||||
<MenuLink key={list.id} to={`/l/${list.id}`}>
|
||||
<span>{list.title}</span>
|
||||
</MenuLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Menu2>
|
||||
}
|
||||
headerEnd={
|
||||
<Menu2
|
||||
|
|
|
@ -8,11 +8,10 @@ import ListAddEdit from '../components/list-add-edit';
|
|||
import Loader from '../components/loader';
|
||||
import Modal from '../components/modal';
|
||||
import NavMenu from '../components/nav-menu';
|
||||
import { api } from '../utils/api';
|
||||
import { fetchLists } from '../utils/lists';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
function Lists() {
|
||||
const { masto } = api();
|
||||
useTitle(`Lists`, `/l`);
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
||||
|
@ -22,8 +21,7 @@ function Lists() {
|
|||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const lists = await masto.v1.lists.list();
|
||||
lists.sort((a, b) => a.title.localeCompare(b.title));
|
||||
const lists = await fetchLists();
|
||||
console.log(lists);
|
||||
setLists(lists);
|
||||
setUIState('default');
|
||||
|
|
|
@ -421,3 +421,145 @@
|
|||
color: var(--text-color);
|
||||
background-color: var(--link-faded-color);
|
||||
}
|
||||
|
||||
/* FILTERED NOTIFICATIONS */
|
||||
|
||||
.filtered-notifications {
|
||||
padding-block-end: 16px;
|
||||
|
||||
summary {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
margin: 16px 0 0;
|
||||
color: var(--text-insignificant-color);
|
||||
|
||||
&::marker,
|
||||
&::-webkit-details-marker {
|
||||
color: var(--text-insignificant-color);
|
||||
}
|
||||
}
|
||||
details[open] summary {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
summary + ul {
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 50vh;
|
||||
max-height: 50dvh;
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--outline-color);
|
||||
border-bottom: 1px solid var(--outline-color);
|
||||
background-color: var(--bg-faded-color);
|
||||
|
||||
@media (min-width: 40em) {
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 16px;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
row-gap: 8px;
|
||||
column-gap: 16px;
|
||||
border-bottom: 1px solid var(--outline-color);
|
||||
}
|
||||
li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.request-notifcations {
|
||||
min-width: 0;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.last-post {
|
||||
max-width: 100%;
|
||||
|
||||
> .status-link {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
--max-height: 160px;
|
||||
max-height: var(--max-height);
|
||||
border: 1px solid var(--outline-color);
|
||||
|
||||
&:is(:hover, :focus-visible) {
|
||||
border-color: var(--outline-hover-color);
|
||||
}
|
||||
|
||||
.status {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black calc(var(--max-height) / 2),
|
||||
transparent calc(var(--max-height) - 8px)
|
||||
);
|
||||
font-size: calc(var(--text-size) * 0.9);
|
||||
|
||||
.content-container {
|
||||
pointer-events: none;
|
||||
filter: saturate(0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.request-notifications-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-request-buttons {
|
||||
grid-area: buttons;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
button {
|
||||
max-width: 30vw;
|
||||
}
|
||||
|
||||
.notification-request-states {
|
||||
min-height: 32px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
|
||||
.icon {
|
||||
margin-inline: 8px;
|
||||
|
||||
&.notification-accepted {
|
||||
color: var(--green-color);
|
||||
}
|
||||
|
||||
&.notification-dismissed {
|
||||
color: var(--red-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#notifications-settings {
|
||||
label {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
input[type='checkbox'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,8 +14,10 @@ import FollowRequestButtons from '../components/follow-request-buttons';
|
|||
import Icon from '../components/icon';
|
||||
import Link from '../components/link';
|
||||
import Loader from '../components/loader';
|
||||
import Modal from '../components/modal';
|
||||
import NavMenu from '../components/nav-menu';
|
||||
import Notification from '../components/notification';
|
||||
import Status from '../components/status';
|
||||
import { api } from '../utils/api';
|
||||
import enhanceContent from '../utils/enhance-content';
|
||||
import groupNotifications from '../utils/group-notifications';
|
||||
|
@ -23,8 +25,10 @@ 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 showToast from '../utils/show-toast';
|
||||
import states, { saveStatus } from '../utils/states';
|
||||
import { getCurrentInstance } from '../utils/store-utils';
|
||||
import supports from '../utils/supports';
|
||||
import usePageVisibility from '../utils/usePageVisibility';
|
||||
import useScroll from '../utils/useScroll';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
@ -136,6 +140,28 @@ function Notifications({ columnMode }) {
|
|||
}
|
||||
}
|
||||
|
||||
const supportsFilteredNotifications = supports(
|
||||
'@mastodon/filtered-notifications',
|
||||
);
|
||||
const [showNotificationsSettings, setShowNotificationsSettings] =
|
||||
useState(false);
|
||||
const [notificationsPolicy, setNotificationsPolicy] = useState({});
|
||||
function fetchNotificationsPolicy() {
|
||||
return masto.v1.notifications.policy.fetch().catch(() => {});
|
||||
}
|
||||
function loadNotificationsPolicy() {
|
||||
fetchNotificationsPolicy()
|
||||
.then((policy) => {
|
||||
console.log('✨ Notifications policy', policy);
|
||||
setNotificationsPolicy(policy);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
const [notificationsRequests, setNotificationsRequests] = useState(null);
|
||||
function fetchNotificationsRequest() {
|
||||
return masto.v1.notifications.requests.list();
|
||||
}
|
||||
|
||||
const loadNotifications = (firstLoad) => {
|
||||
setShowNew(false);
|
||||
setUIState('loading');
|
||||
|
@ -161,6 +187,10 @@ function Notifications({ columnMode }) {
|
|||
setFollowRequests(requests);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
if (supportsFilteredNotifications) {
|
||||
loadNotificationsPolicy();
|
||||
}
|
||||
}
|
||||
|
||||
const { done } = await fetchNotificationsPromise;
|
||||
|
@ -168,6 +198,7 @@ function Notifications({ columnMode }) {
|
|||
|
||||
setUIState('default');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setUIState('error');
|
||||
}
|
||||
})();
|
||||
|
@ -384,7 +415,17 @@ function Notifications({ columnMode }) {
|
|||
</div>
|
||||
<h1>Notifications</h1>
|
||||
<div class="header-side">
|
||||
{/* <Loader hidden={uiState !== 'loading'} /> */}
|
||||
{supportsFilteredNotifications && (
|
||||
<button
|
||||
type="button"
|
||||
class="button plain"
|
||||
onClick={() => {
|
||||
setShowNotificationsSettings(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="settings" size="l" alt="Notifications settings" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showNew && uiState !== 'loading' && (
|
||||
|
@ -489,6 +530,70 @@ function Notifications({ columnMode }) {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{supportsFilteredNotifications &&
|
||||
notificationsPolicy?.summary?.pendingRequestsCount > 0 && (
|
||||
<div class="filtered-notifications">
|
||||
<details
|
||||
onToggle={async (e) => {
|
||||
const { open } = e.target;
|
||||
if (open) {
|
||||
const requests = await fetchNotificationsRequest();
|
||||
setNotificationsRequests(requests);
|
||||
console.log({ open, requests });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<summary>
|
||||
Filtered notifications from{' '}
|
||||
{notificationsPolicy.summary.pendingRequestsCount} people
|
||||
</summary>
|
||||
{!notificationsRequests ? (
|
||||
<p class="ui-state">
|
||||
<Loader abrupt />
|
||||
</p>
|
||||
) : (
|
||||
notificationsRequests?.length > 0 && (
|
||||
<ul>
|
||||
{notificationsRequests.map((request) => (
|
||||
<li key={request.id}>
|
||||
<div class="request-notifcations">
|
||||
{!request.lastStatus?.id && (
|
||||
<AccountBlock
|
||||
useAvatarStatic
|
||||
showStats
|
||||
account={request.account}
|
||||
/>
|
||||
)}
|
||||
{request.lastStatus?.id && (
|
||||
<div class="last-post">
|
||||
<Link
|
||||
class="status-link"
|
||||
to={`/${instance}/s/${request.lastStatus.id}`}
|
||||
>
|
||||
<Status
|
||||
status={request.lastStatus}
|
||||
size="s"
|
||||
readOnly
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<NotificationRequestModalButton request={request} />
|
||||
</div>
|
||||
<NotificationRequestButtons
|
||||
request={request}
|
||||
onChange={() => {
|
||||
loadNotifications(true);
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
<div id="mentions-option">
|
||||
<label>
|
||||
<input
|
||||
|
@ -597,6 +702,109 @@ function Notifications({ columnMode }) {
|
|||
</InView>
|
||||
)}
|
||||
</div>
|
||||
{supportsFilteredNotifications && showNotificationsSettings && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowNotificationsSettings(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="sheet" id="notifications-settings" tabIndex="-1">
|
||||
<button
|
||||
type="button"
|
||||
class="sheet-close"
|
||||
onClick={() => setShowNotificationsSettings(false)}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
<header>
|
||||
<h2>Notifications settings</h2>
|
||||
</header>
|
||||
<main>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const {
|
||||
filterNotFollowing,
|
||||
filterNotFollowers,
|
||||
filterNewAccounts,
|
||||
filterPrivateMentions,
|
||||
} = e.target;
|
||||
const allFilters = {
|
||||
filterNotFollowing: filterNotFollowing.checked,
|
||||
filterNotFollowers: filterNotFollowers.checked,
|
||||
filterNewAccounts: filterNewAccounts.checked,
|
||||
filterPrivateMentions: filterPrivateMentions.checked,
|
||||
};
|
||||
setNotificationsPolicy({
|
||||
...notificationsPolicy,
|
||||
...allFilters,
|
||||
});
|
||||
setShowNotificationsSettings(false);
|
||||
(async () => {
|
||||
try {
|
||||
await masto.v1.notifications.policy.update(allFilters);
|
||||
showToast('Notifications settings updated');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<p>Filter out notifications from people:</p>
|
||||
<p>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
switch
|
||||
defaultChecked={notificationsPolicy.filterNotFollowing}
|
||||
name="filterNotFollowing"
|
||||
/>{' '}
|
||||
You don't follow
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
switch
|
||||
defaultChecked={notificationsPolicy.filterNotFollowers}
|
||||
name="filterNotFollowers"
|
||||
/>{' '}
|
||||
Who don't follow you
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
switch
|
||||
defaultChecked={notificationsPolicy.filterNewAccounts}
|
||||
name="filterNewAccounts"
|
||||
/>{' '}
|
||||
With a new account
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
switch
|
||||
defaultChecked={notificationsPolicy.filterPrivateMentions}
|
||||
name="filterPrivateMentions"
|
||||
/>{' '}
|
||||
Who unsolicitedly private mention you
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<button type="submit">Save</button>
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -679,4 +887,186 @@ function AnnouncementBlock({ announcement }) {
|
|||
);
|
||||
}
|
||||
|
||||
function fetchNotficationsByAccount(accountID) {
|
||||
const { masto } = api();
|
||||
return masto.v1.notifications.list({
|
||||
accountID,
|
||||
});
|
||||
}
|
||||
function NotificationRequestModalButton({ request }) {
|
||||
const { instance } = api();
|
||||
const [uiState, setUIState] = useState('loading');
|
||||
const { account, lastStatus } = request;
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
|
||||
function onClose() {
|
||||
setShowModal(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!request?.account?.id) return;
|
||||
if (!showModal) return;
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
const notifs = await fetchNotficationsByAccount(request.account.id);
|
||||
setNotifications(notifs || []);
|
||||
setUIState('default');
|
||||
})();
|
||||
}, [showModal, request?.account?.id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class="plain4 request-notifications-account"
|
||||
onClick={() => {
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="notification" class="more-insignificant" />{' '}
|
||||
<small>View notifications from @{account.username}</small>{' '}
|
||||
<Icon icon="chevron-down" />
|
||||
</button>
|
||||
{showModal && (
|
||||
<Modal
|
||||
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>Notifications from @{account.username}</b>
|
||||
</header>
|
||||
<main>
|
||||
{uiState === 'loading' ? (
|
||||
<p class="ui-state">
|
||||
<Loader abrupt />
|
||||
</p>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
class="notification-peek"
|
||||
onClick={(e) => {
|
||||
const { target } = e;
|
||||
// If button or links
|
||||
if (
|
||||
e.target.tagName === 'BUTTON' ||
|
||||
e.target.tagName === 'A'
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Notification
|
||||
instance={instance}
|
||||
notification={notification}
|
||||
isStatic
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationRequestButtons({ request, onChange }) {
|
||||
const { masto } = api();
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [requestState, setRequestState] = useState(null); // accept, dismiss
|
||||
const hasRequestState = requestState !== null;
|
||||
|
||||
return (
|
||||
<p class="notification-request-buttons">
|
||||
<button
|
||||
type="button"
|
||||
disabled={uiState === 'loading' || hasRequestState}
|
||||
onClick={() => {
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
await masto.v1.notifications.requests
|
||||
.$select(request.id)
|
||||
.accept();
|
||||
setRequestState('accept');
|
||||
setUIState('default');
|
||||
onChange({
|
||||
request,
|
||||
state: 'accept',
|
||||
});
|
||||
showToast(
|
||||
`Notifications from @${request.account.username} will not be filtered from now on.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setUIState('error');
|
||||
console.error(error);
|
||||
showToast(`Unable to accept notification request`);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Allow
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
disabled={uiState === 'loading' || hasRequestState}
|
||||
class="light danger"
|
||||
onClick={() => {
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
await masto.v1.notifications.requests
|
||||
.$select(request.id)
|
||||
.dismiss();
|
||||
setRequestState('dismiss');
|
||||
setUIState('default');
|
||||
onChange({
|
||||
request,
|
||||
state: 'dismiss',
|
||||
});
|
||||
showToast(
|
||||
`Notifications from @${request.account.username} will not show up in Filtered notifications from now on.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setUIState('error');
|
||||
console.error(error);
|
||||
showToast(`Unable to dismiss notification request`);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<span class="notification-request-states">
|
||||
{uiState === 'loading' ? (
|
||||
<Loader abrupt />
|
||||
) : requestState === 'accept' ? (
|
||||
<Icon
|
||||
icon="check-circle"
|
||||
alt="Accepted"
|
||||
class="notification-accepted"
|
||||
/>
|
||||
) : (
|
||||
requestState === 'dismiss' && (
|
||||
<Icon
|
||||
icon="x-circle"
|
||||
alt="Dismissed"
|
||||
class="notification-dismissed"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Notifications);
|
||||
|
|
|
@ -690,9 +690,10 @@ function PushNotificationsSection({ onClose }) {
|
|||
) {
|
||||
setAllowNotifications(true);
|
||||
const { alerts, policy } = backendSubscription;
|
||||
console.log('backendSubscription', backendSubscription);
|
||||
previousPolicyRef.current = policy;
|
||||
const { elements } = pushFormRef.current;
|
||||
const policyEl = elements.namedItem(policy);
|
||||
const policyEl = elements.namedItem('policy');
|
||||
if (policyEl) policyEl.value = policy;
|
||||
// alerts is {}, iterate it
|
||||
Object.keys(alerts).forEach((alert) => {
|
||||
|
@ -721,65 +722,68 @@ function PushNotificationsSection({ onClose }) {
|
|||
<form
|
||||
ref={pushFormRef}
|
||||
onChange={() => {
|
||||
const values = Object.fromEntries(new FormData(pushFormRef.current));
|
||||
const allowNotifications = !!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,
|
||||
setTimeout(() => {
|
||||
const values = Object.fromEntries(new FormData(pushFormRef.current));
|
||||
const allowNotifications = !!values['policy-allow'];
|
||||
const params = {
|
||||
data: {
|
||||
policy: values.policy,
|
||||
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;
|
||||
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.data.policy;
|
||||
|
||||
console.log('PN Form', {
|
||||
values,
|
||||
allowNotifications: allowNotifications,
|
||||
params,
|
||||
});
|
||||
console.log('PN Form', {
|
||||
values,
|
||||
allowNotifications: allowNotifications,
|
||||
params,
|
||||
});
|
||||
|
||||
if (allowNotifications && alertsCount > 0) {
|
||||
if (policyChanged) {
|
||||
console.debug('Policy changed.');
|
||||
removeSubscription()
|
||||
.then(() => {
|
||||
updateSubscription(params);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (allowNotifications && 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 {
|
||||
updateSubscription(params).catch((err) => {
|
||||
removeSubscription().catch((err) => {
|
||||
console.warn(err);
|
||||
alert('Failed to update subscription. Please try again.');
|
||||
alert('Failed to remove subscription. Please try again.');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
removeSubscription().catch((err) => {
|
||||
console.warn(err);
|
||||
alert('Failed to remove subscription. Please try again.');
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
<h3>Push Notifications (beta)</h3>
|
||||
|
|
|
@ -217,13 +217,23 @@ function Trending({ columnMode, ...props }) {
|
|||
)}
|
||||
</div>
|
||||
{!!title && (
|
||||
<h1 class="title" lang={language} dir="auto">
|
||||
<h1
|
||||
class="title"
|
||||
lang={language}
|
||||
dir="auto"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
</header>
|
||||
{!!description && (
|
||||
<p class="description" lang={language} dir="auto">
|
||||
<p
|
||||
class="description"
|
||||
lang={language}
|
||||
dir="auto"
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
@ -63,11 +63,11 @@ function groupNotifications(notifications) {
|
|||
mappedNotification.id += `-${id}`;
|
||||
}
|
||||
} else {
|
||||
account._types = [type];
|
||||
if (account) account._types = [type];
|
||||
let n = (notificationsMap[key] = {
|
||||
...notification,
|
||||
type: virtualType,
|
||||
_accounts: [account],
|
||||
_accounts: account ? [account] : [],
|
||||
});
|
||||
cleanNotifications[j++] = n;
|
||||
}
|
||||
|
|
114
src/utils/lists.js
Normal file
114
src/utils/lists.js
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { api } from './api';
|
||||
import pmem from './pmem';
|
||||
import store from './store';
|
||||
|
||||
const FETCH_MAX_AGE = 1000 * 60; // 1 minute
|
||||
const MAX_AGE = 24 * 60 * 60 * 1000; // 1 day
|
||||
|
||||
export const fetchLists = pmem(
|
||||
async () => {
|
||||
const { masto } = api();
|
||||
const lists = await masto.v1.lists.list();
|
||||
lists.sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
if (lists.length) {
|
||||
setTimeout(() => {
|
||||
// Save to local storage, with saved timestamp
|
||||
store.account.set('lists', {
|
||||
lists,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}, 1);
|
||||
}
|
||||
|
||||
return lists;
|
||||
},
|
||||
{
|
||||
maxAge: FETCH_MAX_AGE,
|
||||
},
|
||||
);
|
||||
|
||||
export async function getLists() {
|
||||
try {
|
||||
const { lists, updatedAt } = store.account.get('lists') || {};
|
||||
if (!lists?.length) return await fetchLists();
|
||||
if (Date.now() - updatedAt > MAX_AGE) {
|
||||
// Stale-while-revalidate
|
||||
fetchLists();
|
||||
return lists;
|
||||
}
|
||||
return lists;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchList = pmem(
|
||||
(id) => {
|
||||
const { masto } = api();
|
||||
return masto.v1.lists.$select(id).fetch();
|
||||
},
|
||||
{
|
||||
maxAge: FETCH_MAX_AGE,
|
||||
},
|
||||
);
|
||||
|
||||
export async function getList(id) {
|
||||
const { lists } = store.account.get('lists') || {};
|
||||
console.log({ lists });
|
||||
if (lists?.length) {
|
||||
const theList = lists.find((l) => l.id === id);
|
||||
if (theList) return theList;
|
||||
}
|
||||
try {
|
||||
return fetchList(id);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getListTitle(id) {
|
||||
const list = await getList(id);
|
||||
return list?.title || '';
|
||||
}
|
||||
|
||||
export function addListStore(list) {
|
||||
const { lists } = store.account.get('lists') || {};
|
||||
if (lists?.length) {
|
||||
lists.push(list);
|
||||
lists.sort((a, b) => a.title.localeCompare(b.title));
|
||||
store.account.set('lists', {
|
||||
lists,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function updateListStore(list) {
|
||||
const { lists } = store.account.get('lists') || {};
|
||||
if (lists?.length) {
|
||||
const index = lists.findIndex((l) => l.id === list.id);
|
||||
if (index !== -1) {
|
||||
lists[index] = list;
|
||||
lists.sort((a, b) => a.title.localeCompare(b.title));
|
||||
store.account.set('lists', {
|
||||
lists,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteListStore(listID) {
|
||||
const { lists } = store.account.get('lists') || {};
|
||||
if (lists?.length) {
|
||||
const index = lists.findIndex((l) => l.id === listID);
|
||||
if (index !== -1) {
|
||||
lists.splice(index, 1);
|
||||
store.account.set('lists', {
|
||||
lists,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -110,6 +110,7 @@ export default defineConfig({
|
|||
],
|
||||
build: {
|
||||
sourcemap: true,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
treeshake: false,
|
||||
input: {
|
||||
|
@ -117,9 +118,9 @@ export default defineConfig({
|
|||
compose: resolve(__dirname, 'compose/index.html'),
|
||||
},
|
||||
output: {
|
||||
manualChunks: {
|
||||
'intl-segmenter-polyfill': ['@formatjs/intl-segmenter/polyfill'],
|
||||
},
|
||||
// manualChunks: {
|
||||
// 'intl-segmenter-polyfill': ['@formatjs/intl-segmenter/polyfill'],
|
||||
// },
|
||||
chunkFileNames: (chunkInfo) => {
|
||||
const { facadeModuleId } = chunkInfo;
|
||||
if (facadeModuleId && facadeModuleId.includes('icon')) {
|
||||
|
|
Loading…
Reference in a new issue