commit
13de3d9263
57
index.html
57
index.html
|
@ -33,5 +33,62 @@
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="position: absolute; width: 0; height: 0"
|
||||||
|
>
|
||||||
|
<filter id="spoiler" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feColorMatrix
|
||||||
|
type="matrix"
|
||||||
|
values="0.01 0 0 0 0
|
||||||
|
0.01 0 0 0 0
|
||||||
|
0.01 0 0 0 0
|
||||||
|
0 0 0 .5 0"
|
||||||
|
in="SourceGraphic"
|
||||||
|
result="colormatrix"
|
||||||
|
/>
|
||||||
|
<feTurbulence
|
||||||
|
type="turbulence"
|
||||||
|
baseFrequency=".5 .5"
|
||||||
|
numOctaves="10"
|
||||||
|
seed="1"
|
||||||
|
result="turbulence"
|
||||||
|
/>
|
||||||
|
<feDisplacementMap
|
||||||
|
in="colormatrix"
|
||||||
|
in2="turbulence"
|
||||||
|
scale="20"
|
||||||
|
xChannelSelector="R"
|
||||||
|
yChannelSelector="B"
|
||||||
|
result="displacementMap"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
<filter id="spoiler-dark" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feColorMatrix
|
||||||
|
type="matrix"
|
||||||
|
values="1 0 0 .8 0
|
||||||
|
1 0 0 .8 0
|
||||||
|
1 0 0 .8 0
|
||||||
|
0 0 0 .5 0"
|
||||||
|
in="SourceGraphic"
|
||||||
|
result="colormatrix"
|
||||||
|
/>
|
||||||
|
<feTurbulence
|
||||||
|
type="turbulence"
|
||||||
|
baseFrequency=".5 .5"
|
||||||
|
numOctaves="10"
|
||||||
|
seed="1"
|
||||||
|
result="turbulence"
|
||||||
|
/>
|
||||||
|
<feDisplacementMap
|
||||||
|
in="colormatrix"
|
||||||
|
in2="turbulence"
|
||||||
|
scale="20"
|
||||||
|
xChannelSelector="R"
|
||||||
|
yChannelSelector="B"
|
||||||
|
result="displacementMap"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
</svg>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
95
package-lock.json
generated
95
package-lock.json
generated
|
@ -13,8 +13,8 @@
|
||||||
"fast-blurhash": "~1.1.2",
|
"fast-blurhash": "~1.1.2",
|
||||||
"history": "~5.3.0",
|
"history": "~5.3.0",
|
||||||
"iconify-icon": "~1.0.2",
|
"iconify-icon": "~1.0.2",
|
||||||
"just-debounce-it": "^3.2.0",
|
"just-debounce-it": "~3.2.0",
|
||||||
"masto": "~4.10.1",
|
"masto": "~4.11.1",
|
||||||
"mem": "~9.0.2",
|
"mem": "~9.0.2",
|
||||||
"preact": "~10.11.3",
|
"preact": "~10.11.3",
|
||||||
"preact-router": "~4.1.0",
|
"preact-router": "~4.1.0",
|
||||||
|
@ -29,7 +29,8 @@
|
||||||
"autoprefixer": "~10.4.13",
|
"autoprefixer": "~10.4.13",
|
||||||
"postcss": "~8.4.20",
|
"postcss": "~8.4.20",
|
||||||
"postcss-dark-theme-class": "~0.7.3",
|
"postcss-dark-theme-class": "~0.7.3",
|
||||||
"vite": "~4.0.2",
|
"twitter-text": "~3.1.0",
|
||||||
|
"vite": "~4.0.3",
|
||||||
"vite-plugin-pwa": "~0.14.0",
|
"vite-plugin-pwa": "~0.14.0",
|
||||||
"workbox-cacheable-response": "~6.5.4",
|
"workbox-cacheable-response": "~6.5.4",
|
||||||
"workbox-expiration": "~6.5.4",
|
"workbox-expiration": "~6.5.4",
|
||||||
|
@ -2930,6 +2931,14 @@
|
||||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "2.6.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||||
|
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
|
||||||
|
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true
|
||||||
|
},
|
||||||
"node_modules/core-js-compat": {
|
"node_modules/core-js-compat": {
|
||||||
"version": "3.26.1",
|
"version": "3.26.1",
|
||||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.26.1.tgz",
|
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.26.1.tgz",
|
||||||
|
@ -4137,9 +4146,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/masto": {
|
"node_modules/masto": {
|
||||||
"version": "4.10.1",
|
"version": "4.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/masto/-/masto-4.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/masto/-/masto-4.11.1.tgz",
|
||||||
"integrity": "sha512-zEcQff0MkXTMDT9yXSyJw8+9oBkbcaSnYntm4x57CSReD1OBM7Z3FOQjEwydTetHwsX+etE0EvQHFB8mNggl6g==",
|
"integrity": "sha512-siTQNhfLV1JjOERCGgjagMvD6q0K0hLuhOXrbXNcYzHAwpbPeSeAM6CSpIRrZ8zFDepOR62Djs/GtJdTR21Rfw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "1.1.3",
|
"axios": "1.1.3",
|
||||||
"change-case": "^4.1.2",
|
"change-case": "^4.1.2",
|
||||||
|
@ -5075,6 +5084,30 @@
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
|
||||||
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA=="
|
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/twemoji-parser": {
|
||||||
|
"version": "11.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-11.0.2.tgz",
|
||||||
|
"integrity": "sha512-5kO2XCcpAql6zjdLwRwJjYvAZyDy3+Uj7v1ipBzLthQmDL7Ce19bEqHr3ImSNeoSW2OA8u02XmARbXHaNO8GhA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/twitter-text": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/twitter-text/-/twitter-text-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-nulfUi3FN6z0LUjYipJid+eiwXvOLb8Ass7Jy/6zsXmZK3URte043m8fL3FyDzrK+WLpyqhHuR/TcARTN/iuGQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.3.1",
|
||||||
|
"core-js": "^2.5.0",
|
||||||
|
"punycode": "1.4.1",
|
||||||
|
"twemoji-parser": "^11.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/twitter-text/node_modules/punycode": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/type-fest": {
|
"node_modules/type-fest": {
|
||||||
"version": "0.16.0",
|
"version": "0.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
||||||
|
@ -5285,9 +5318,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.3.tgz",
|
||||||
"integrity": "sha512-QJaY3R+tFlTagH0exVqbgkkw45B+/bXVBzF2ZD1KB5Z8RiAoiKo60vSUf6/r4c2Vh9jfGBKM4oBI9b4/1ZJYng==",
|
"integrity": "sha512-HvuNv1RdE7deIfQb8mPk51UKjqptO/4RXZ5yXSAvurd5xOckwS/gg8h9Tky3uSbnjYTgUm0hVCet1cyhKd73ZA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.16.3",
|
"esbuild": "^0.16.3",
|
||||||
|
@ -7752,6 +7785,12 @@
|
||||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"core-js": {
|
||||||
|
"version": "2.6.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||||
|
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"core-js-compat": {
|
"core-js-compat": {
|
||||||
"version": "3.26.1",
|
"version": "3.26.1",
|
||||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.26.1.tgz",
|
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.26.1.tgz",
|
||||||
|
@ -8646,9 +8685,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"masto": {
|
"masto": {
|
||||||
"version": "4.10.1",
|
"version": "4.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/masto/-/masto-4.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/masto/-/masto-4.11.1.tgz",
|
||||||
"integrity": "sha512-zEcQff0MkXTMDT9yXSyJw8+9oBkbcaSnYntm4x57CSReD1OBM7Z3FOQjEwydTetHwsX+etE0EvQHFB8mNggl6g==",
|
"integrity": "sha512-siTQNhfLV1JjOERCGgjagMvD6q0K0hLuhOXrbXNcYzHAwpbPeSeAM6CSpIRrZ8zFDepOR62Djs/GtJdTR21Rfw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"axios": "1.1.3",
|
"axios": "1.1.3",
|
||||||
"change-case": "^4.1.2",
|
"change-case": "^4.1.2",
|
||||||
|
@ -9316,6 +9355,32 @@
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
|
||||||
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA=="
|
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA=="
|
||||||
},
|
},
|
||||||
|
"twemoji-parser": {
|
||||||
|
"version": "11.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-11.0.2.tgz",
|
||||||
|
"integrity": "sha512-5kO2XCcpAql6zjdLwRwJjYvAZyDy3+Uj7v1ipBzLthQmDL7Ce19bEqHr3ImSNeoSW2OA8u02XmARbXHaNO8GhA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"twitter-text": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/twitter-text/-/twitter-text-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-nulfUi3FN6z0LUjYipJid+eiwXvOLb8Ass7Jy/6zsXmZK3URte043m8fL3FyDzrK+WLpyqhHuR/TcARTN/iuGQ==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.3.1",
|
||||||
|
"core-js": "^2.5.0",
|
||||||
|
"punycode": "1.4.1",
|
||||||
|
"twemoji-parser": "^11.0.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
|
||||||
|
"dev": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"type-fest": {
|
"type-fest": {
|
||||||
"version": "0.16.0",
|
"version": "0.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
||||||
|
@ -9442,9 +9507,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vite": {
|
"vite": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.3.tgz",
|
||||||
"integrity": "sha512-QJaY3R+tFlTagH0exVqbgkkw45B+/bXVBzF2ZD1KB5Z8RiAoiKo60vSUf6/r4c2Vh9jfGBKM4oBI9b4/1ZJYng==",
|
"integrity": "sha512-HvuNv1RdE7deIfQb8mPk51UKjqptO/4RXZ5yXSAvurd5xOckwS/gg8h9Tky3uSbnjYTgUm0hVCet1cyhKd73ZA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"esbuild": "^0.16.3",
|
"esbuild": "^0.16.3",
|
||||||
|
|
|
@ -15,8 +15,8 @@
|
||||||
"fast-blurhash": "~1.1.2",
|
"fast-blurhash": "~1.1.2",
|
||||||
"history": "~5.3.0",
|
"history": "~5.3.0",
|
||||||
"iconify-icon": "~1.0.2",
|
"iconify-icon": "~1.0.2",
|
||||||
"just-debounce-it": "^3.2.0",
|
"just-debounce-it": "~3.2.0",
|
||||||
"masto": "~4.10.1",
|
"masto": "~4.11.1",
|
||||||
"mem": "~9.0.2",
|
"mem": "~9.0.2",
|
||||||
"preact": "~10.11.3",
|
"preact": "~10.11.3",
|
||||||
"preact-router": "~4.1.0",
|
"preact-router": "~4.1.0",
|
||||||
|
@ -31,7 +31,8 @@
|
||||||
"autoprefixer": "~10.4.13",
|
"autoprefixer": "~10.4.13",
|
||||||
"postcss": "~8.4.20",
|
"postcss": "~8.4.20",
|
||||||
"postcss-dark-theme-class": "~0.7.3",
|
"postcss-dark-theme-class": "~0.7.3",
|
||||||
"vite": "~4.0.2",
|
"twitter-text": "~3.1.0",
|
||||||
|
"vite": "~4.0.3",
|
||||||
"vite-plugin-pwa": "~0.14.0",
|
"vite-plugin-pwa": "~0.14.0",
|
||||||
"workbox-cacheable-response": "~6.5.4",
|
"workbox-cacheable-response": "~6.5.4",
|
||||||
"workbox-expiration": "~6.5.4",
|
"workbox-expiration": "~6.5.4",
|
||||||
|
|
|
@ -29,7 +29,7 @@ registerRoute(imageRoute);
|
||||||
|
|
||||||
// Cache /instance because masto.js has to keep calling it while initializing
|
// Cache /instance because masto.js has to keep calling it while initializing
|
||||||
const apiExtendedRoute = new RegExpRoute(
|
const apiExtendedRoute = new RegExpRoute(
|
||||||
/^https?:\/\/[^\/]+\/api\/v\d+\/instance/,
|
/^https?:\/\/[^\/]+\/api\/v\d+\/(instance|custom_emojis)/,
|
||||||
new StaleWhileRevalidate({
|
new StaleWhileRevalidate({
|
||||||
cacheName: 'api-extended',
|
cacheName: 'api-extended',
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
48
scripts/extract-url.js
Normal file
48
scripts/extract-url.js
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import regexSupplant from 'twitter-text/dist/lib/regexSupplant.js';
|
||||||
|
import validDomain from 'twitter-text/dist/regexp/validDomain.js';
|
||||||
|
import validPortNumber from 'twitter-text/dist/regexp/validPortNumber.js';
|
||||||
|
import validUrlPath from 'twitter-text/dist/regexp/validUrlPath.js';
|
||||||
|
import validUrlPrecedingChars from 'twitter-text/dist/regexp/validUrlPrecedingChars.js';
|
||||||
|
import validUrlQueryChars from 'twitter-text/dist/regexp/validUrlQueryChars.js';
|
||||||
|
import validUrlQueryEndingChars from 'twitter-text/dist/regexp/validUrlQueryEndingChars.js';
|
||||||
|
|
||||||
|
// The difference with twitter-text's extractURL is that the protocol isn't
|
||||||
|
// optional.
|
||||||
|
|
||||||
|
const urlRegex = regexSupplant(
|
||||||
|
'(' + // $1 total match
|
||||||
|
'(#{validUrlPrecedingChars})' + // $2 Preceeding chracter
|
||||||
|
'(' + // $3 URL
|
||||||
|
'(https?:\\/\\/)' + // $4 Protocol (optional) <-- THIS IS THE DIFFERENCE, MISSING '?' AFTER PROTOCOL
|
||||||
|
'(#{validDomain})' + // $5 Domain(s)
|
||||||
|
'(?::(#{validPortNumber}))?' + // $6 Port number (optional)
|
||||||
|
'(\\/#{validUrlPath}*)?' + // $7 URL Path
|
||||||
|
'(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?' + // $8 Query String
|
||||||
|
')' +
|
||||||
|
')',
|
||||||
|
{
|
||||||
|
validUrlPrecedingChars,
|
||||||
|
validDomain,
|
||||||
|
validPortNumber,
|
||||||
|
validUrlPath,
|
||||||
|
validUrlQueryChars,
|
||||||
|
validUrlQueryEndingChars,
|
||||||
|
},
|
||||||
|
'gi',
|
||||||
|
);
|
||||||
|
|
||||||
|
const filePath = 'src/data/url-regex.json';
|
||||||
|
fs.writeFile(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify({
|
||||||
|
source: urlRegex.source,
|
||||||
|
flags: urlRegex.flags,
|
||||||
|
}),
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
console.log(`Wrote ${filePath}`);
|
||||||
|
},
|
||||||
|
);
|
97
src/app.css
97
src/app.css
|
@ -23,6 +23,8 @@ a.mention {
|
||||||
a.mention span {
|
a.mention span {
|
||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
text-decoration-color: inherit;
|
text-decoration-color: inherit;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
/* a.mention:has(span).hashtag {
|
/* a.mention:has(span).hashtag {
|
||||||
color: var(--link-light-color);
|
color: var(--link-light-color);
|
||||||
|
@ -38,6 +40,7 @@ a.mention span {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
transition: opacity 0.1s ease-in-out;
|
transition: opacity 0.1s ease-in-out;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
.deck-container[hidden] {
|
.deck-container[hidden] {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -68,6 +71,7 @@ a.mention span {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deck header {
|
.deck header {
|
||||||
|
@ -75,7 +79,7 @@ a.mention span {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background-color: var(--bg-blur-color);
|
background-color: var(--bg-blur-color);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: saturate(180%) blur(20px);
|
||||||
border-bottom: 1px solid var(--divider-color);
|
border-bottom: 1px solid var(--divider-color);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
@ -83,6 +87,7 @@ a.mention span {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
.deck header > .header-side:last-of-type {
|
.deck header > .header-side:last-of-type {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
@ -159,14 +164,14 @@ a.mention span {
|
||||||
> .status-link
|
> .status-link
|
||||||
+ .replies
|
+ .replies
|
||||||
> summary {
|
> summary {
|
||||||
margin-left: calc(50px + 16px + 16px);
|
margin-left: calc(50px + 16px + 12px);
|
||||||
}
|
}
|
||||||
.timeline.contextual
|
.timeline.contextual
|
||||||
> li.descendant.thread
|
> li.descendant.thread
|
||||||
> .status-link
|
> .status-link
|
||||||
+ .replies
|
+ .replies
|
||||||
.status-link {
|
.status-link {
|
||||||
padding-left: calc(50px + 16px + 16px);
|
padding-left: calc(50px + 16px + 12px);
|
||||||
}
|
}
|
||||||
.timeline.contextual
|
.timeline.contextual
|
||||||
> li.descendant:not(.thread)
|
> li.descendant:not(.thread)
|
||||||
|
@ -197,6 +202,19 @@ a.mention span {
|
||||||
border-color: transparent transparent var(--comment-line-color) transparent;
|
border-color: transparent transparent var(--comment-line-color) transparent;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
.timeline.contextual > li .replies-link {
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
margin-left: 16px;
|
||||||
|
margin-top: -12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
.timeline.contextual > li.thread > .status-link .replies-link {
|
||||||
|
margin-left: calc(50px + 16px + 12px);
|
||||||
|
}
|
||||||
|
.timeline.contextual > li .replies-link * {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
.timeline.contextual > li .replies {
|
.timeline.contextual > li .replies {
|
||||||
margin-top: -12px;
|
margin-top: -12px;
|
||||||
}
|
}
|
||||||
|
@ -238,9 +256,9 @@ a.mention span {
|
||||||
.timeline.contextual > li .replies li {
|
.timeline.contextual > li .replies li {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies li .status {
|
.timeline.contextual > li .replies li {
|
||||||
--width: 3px;
|
--width: 3px;
|
||||||
--left: 0px;
|
--left: calc(40px + 16px);
|
||||||
--right: calc(var(--left) + var(--width));
|
--right: calc(var(--left) + var(--width));
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
|
@ -253,7 +271,10 @@ a.mention span {
|
||||||
);
|
);
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies li:last-child .status {
|
.timeline.contextual > li.thread .replies li {
|
||||||
|
--left: calc(50px + 16px + 12px);
|
||||||
|
}
|
||||||
|
.timeline.contextual > li .replies li:last-child {
|
||||||
background-size: 100% 20px;
|
background-size: 100% 20px;
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies li:before {
|
.timeline.contextual > li .replies li:before {
|
||||||
|
@ -272,7 +293,7 @@ a.mention span {
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
.timeline.contextual > li.thread .replies li:before {
|
.timeline.contextual > li.thread .replies li:before {
|
||||||
left: calc(50px + 16px + 16px);
|
left: calc(50px + 16px + 12px);
|
||||||
}
|
}
|
||||||
.timeline.contextual.loading > li:not(.hero) {
|
.timeline.contextual.loading > li:not(.hero) {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
@ -317,6 +338,7 @@ a.mention span {
|
||||||
text-decoration-line: none;
|
text-decoration-line: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: background-color 0.2s ease-out;
|
transition: background-color 0.2s ease-out;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
.status-link:hover {
|
.status-link:hover {
|
||||||
background-color: var(--link-bg-hover-color);
|
background-color: var(--link-bg-hover-color);
|
||||||
|
@ -432,6 +454,7 @@ a.mention span {
|
||||||
}
|
}
|
||||||
.carousel > * {
|
.carousel > * {
|
||||||
scroll-snap-align: center;
|
scroll-snap-align: center;
|
||||||
|
scroll-snap-stop: always;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
@ -576,6 +599,7 @@ button.carousel-dot[disabled].active {
|
||||||
box-shadow: 0 -1px 32px var(--divider-color);
|
box-shadow: 0 -1px 32px var(--divider-color);
|
||||||
animation: slide-up 0.2s var(--timing-function);
|
animation: slide-up 0.2s var(--timing-function);
|
||||||
border: 1px solid var(--outline-color);
|
border: 1px solid var(--outline-color);
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TAG */
|
/* TAG */
|
||||||
|
@ -643,6 +667,65 @@ button.carousel-dot[disabled].active {
|
||||||
background-color: var(--link-color);
|
background-color: var(--link-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DONUT METER */
|
||||||
|
|
||||||
|
meter.donut {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
meter.donut:is(
|
||||||
|
::-webkit-progress-inner-element,
|
||||||
|
::-webkit-progress-bar,
|
||||||
|
::-webkit-progress-value,
|
||||||
|
::-webkit-meter-bar,
|
||||||
|
::-webkit-meter-optimum-value,
|
||||||
|
::-webkit-meter-suboptimum-value,
|
||||||
|
::-webkit-meter-even-less-good-value
|
||||||
|
) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
meter.donut:is(::-moz-progress-bar, ::-moz-meter-bar) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
meter.donut {
|
||||||
|
position: relative;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
--fill: calc(var(--percentage) * 1%);
|
||||||
|
--color: var(--link-color);
|
||||||
|
--middle-circle: radial-gradient(
|
||||||
|
circle at 50% 50%,
|
||||||
|
var(--bg-faded-color) 10px,
|
||||||
|
transparent 10px
|
||||||
|
);
|
||||||
|
background-image: var(--middle-circle),
|
||||||
|
conic-gradient(var(--color) var(--fill), var(--bg-faded-blur-color) 0);
|
||||||
|
}
|
||||||
|
meter.donut.warning {
|
||||||
|
--color: var(--orange-color);
|
||||||
|
}
|
||||||
|
meter.donut.danger {
|
||||||
|
--color: var(--red-color);
|
||||||
|
}
|
||||||
|
meter.donut.explode {
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 40em) {
|
@media (min-width: 40em) {
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
|
|
98
src/app.jsx
98
src/app.jsx
|
@ -56,7 +56,9 @@ async function startStream() {
|
||||||
});
|
});
|
||||||
stream.on('delete', (statusID) => {
|
stream.on('delete', (statusID) => {
|
||||||
console.log('DELETE', statusID);
|
console.log('DELETE', statusID);
|
||||||
states.statuses.delete(statusID);
|
// states.statuses.delete(statusID);
|
||||||
|
const s = states.statuses.get(statusID);
|
||||||
|
if (s) s._deleted = true;
|
||||||
});
|
});
|
||||||
stream.on('notification', (notification) => {
|
stream.on('notification', (notification) => {
|
||||||
console.log('NOTIFICATION', notification);
|
console.log('NOTIFICATION', notification);
|
||||||
|
@ -101,6 +103,99 @@ async function startStream() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startVisibility() {
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
store.session.set('lastHidden', timestamp);
|
||||||
|
} else {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const lastHidden = store.session.get('lastHidden');
|
||||||
|
const diff = timestamp - lastHidden;
|
||||||
|
const diffMins = Math.round(diff / 1000 / 60);
|
||||||
|
if (diffMins > 1) {
|
||||||
|
console.log('visible', { lastHidden, diffMins });
|
||||||
|
setTimeout(() => {
|
||||||
|
// Buffer for WS reconnect
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const fetchHome = masto.timelines.fetchHome({
|
||||||
|
limit: 2,
|
||||||
|
// Need 2 because "new posts" only appear when there are 2 or more
|
||||||
|
});
|
||||||
|
const fetchNotifications = masto.notifications
|
||||||
|
.iterate({
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
.next();
|
||||||
|
|
||||||
|
const newStatuses = await fetchHome;
|
||||||
|
if (
|
||||||
|
newStatuses.value.length &&
|
||||||
|
newStatuses.value[0].id !== states.home[0].id
|
||||||
|
) {
|
||||||
|
states.homeNew = newStatuses.value.map((status) => {
|
||||||
|
states.statuses.set(status.id, status);
|
||||||
|
if (status.reblog) {
|
||||||
|
states.statuses.set(status.reblog.id, status.reblog);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: status.id,
|
||||||
|
reblog: status.reblog?.id,
|
||||||
|
reply: !!status.inReplyToAccountId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newNotifications = await fetchNotifications;
|
||||||
|
if (newNotifications.value.length) {
|
||||||
|
const notification = newNotifications.value[0];
|
||||||
|
const inNotificationsNew = states.notificationsNew.find(
|
||||||
|
(n) => n.id === notification.id,
|
||||||
|
);
|
||||||
|
const inNotifications = states.notifications.find(
|
||||||
|
(n) => n.id === notification.id,
|
||||||
|
);
|
||||||
|
if (!inNotificationsNew && !inNotifications) {
|
||||||
|
states.notificationsNew.unshift(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
notification.status &&
|
||||||
|
!states.statuses.has(notification.status.id)
|
||||||
|
) {
|
||||||
|
states.statuses.set(
|
||||||
|
notification.status.id,
|
||||||
|
notification.status,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
notification.status.reblog &&
|
||||||
|
!states.statuses.has(notification.status.reblog.id)
|
||||||
|
) {
|
||||||
|
states.statuses.set(
|
||||||
|
notification.status.reblog.id,
|
||||||
|
notification.status.reblog,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
return {
|
||||||
|
stop: () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
@ -208,6 +303,7 @@ export function App() {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
startStream();
|
startStream();
|
||||||
|
startVisibility();
|
||||||
|
|
||||||
// Collect instance info
|
// Collect instance info
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
#account-container .note {
|
#account-container .note {
|
||||||
font-size: 95%;
|
font-size: 95%;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
#account-container .note:not(:has(p)) {
|
#account-container .note:not(:has(p)) {
|
||||||
/* Some notes don't have <p> tags, so we need to add some padding */
|
/* Some notes don't have <p> tags, so we need to add some padding */
|
||||||
|
@ -52,6 +52,13 @@
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#account-container :is(.note, .profile-field) .invisible {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#account-container :is(.note, .profile-field) .ellipsis::after {
|
||||||
|
content: '…';
|
||||||
|
}
|
||||||
|
|
||||||
#account-container .profile-field b {
|
#account-container .profile-field b {
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
color: var(--text-insignificant-color);
|
color: var(--text-insignificant-color);
|
||||||
|
|
|
@ -184,7 +184,7 @@ function Account({ account }) {
|
||||||
{relationshipUIState !== 'loading' && relationship && (
|
{relationshipUIState !== 'loading' && relationship && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={following ? 'light' : ''}
|
class={`${following ? 'light danger' : ''}`}
|
||||||
disabled={relationshipUIState === 'loading'}
|
disabled={relationshipUIState === 'loading'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRelationshipUIState('loading');
|
setRelationshipUIState('loading');
|
||||||
|
@ -210,7 +210,7 @@ function Account({ account }) {
|
||||||
})();
|
})();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{following ? 'Unfollow' : 'Follow'}
|
{following ? 'Unfollow…' : 'Follow'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -26,11 +26,20 @@
|
||||||
#compose-container textarea {
|
#compose-container textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 3em;
|
height: 4em;
|
||||||
min-height: 3em;
|
min-height: 4em;
|
||||||
max-height: 10em;
|
max-height: 10em;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 40em) {
|
||||||
|
#compose-container textarea {
|
||||||
|
font-size: 150%;
|
||||||
|
font-size: calc(100% + 50% / var(--text-weight));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes appear-up {
|
@keyframes appear-up {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
|
@ -4,6 +4,7 @@ import '@github/text-expander-element';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import stringLength from 'string-length';
|
import stringLength from 'string-length';
|
||||||
|
|
||||||
|
import urlRegex from '../data/url-regex';
|
||||||
import emojifyText from '../utils/emojify-text';
|
import emojifyText from '../utils/emojify-text';
|
||||||
import openCompose from '../utils/open-compose';
|
import openCompose from '../utils/open-compose';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
|
@ -36,6 +37,10 @@ const expiresInFromExpiresAt = (expiresAt) => {
|
||||||
return expirySeconds.find((s) => s >= delta) || oneDay;
|
return expirySeconds.find((s) => s >= delta) || oneDay;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const menu = document.createElement('ul');
|
||||||
|
menu.role = 'listbox';
|
||||||
|
menu.className = 'text-expander-menu';
|
||||||
|
|
||||||
function Compose({
|
function Compose({
|
||||||
onClose,
|
onClose,
|
||||||
replyToStatus,
|
replyToStatus,
|
||||||
|
@ -82,13 +87,31 @@ function Compose({
|
||||||
const [mediaAttachments, setMediaAttachments] = useState([]);
|
const [mediaAttachments, setMediaAttachments] = useState([]);
|
||||||
const [poll, setPoll] = useState(null);
|
const [poll, setPoll] = useState(null);
|
||||||
|
|
||||||
|
const customEmojis = useRef();
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const emojis = await masto.customEmojis.fetchAll();
|
||||||
|
console.log({ emojis });
|
||||||
|
customEmojis.current = emojis;
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const oninputTextarea = () => {
|
||||||
|
if (!textareaRef.current) return;
|
||||||
|
textareaRef.current.dispatchEvent(new Event('input'));
|
||||||
|
};
|
||||||
|
const focusTextarea = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (replyToStatus) {
|
if (replyToStatus) {
|
||||||
const { spoilerText, visibility, sensitive } = replyToStatus;
|
const { spoilerText, visibility, sensitive } = replyToStatus;
|
||||||
if (spoilerText && spoilerTextRef.current) {
|
if (spoilerText && spoilerTextRef.current) {
|
||||||
spoilerTextRef.current.value = spoilerText;
|
spoilerTextRef.current.value = spoilerText;
|
||||||
spoilerTextRef.current.focus();
|
}
|
||||||
} else {
|
|
||||||
const mentions = new Set([
|
const mentions = new Set([
|
||||||
replyToStatus.account.acct,
|
replyToStatus.account.acct,
|
||||||
...replyToStatus.mentions.map((m) => m.acct),
|
...replyToStatus.mentions.map((m) => m.acct),
|
||||||
|
@ -100,10 +123,9 @@ function Compose({
|
||||||
textareaRef.current.value = `${allMentions
|
textareaRef.current.value = `${allMentions
|
||||||
.map((m) => `@${m}`)
|
.map((m) => `@${m}`)
|
||||||
.join(' ')} `;
|
.join(' ')} `;
|
||||||
textareaRef.current.dispatchEvent(new Event('input'));
|
oninputTextarea();
|
||||||
}
|
|
||||||
textareaRef.current.focus();
|
|
||||||
}
|
}
|
||||||
|
focusTextarea();
|
||||||
setVisibility(visibility);
|
setVisibility(visibility);
|
||||||
setSensitive(sensitive);
|
setSensitive(sensitive);
|
||||||
}
|
}
|
||||||
|
@ -122,7 +144,8 @@ function Compose({
|
||||||
expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt),
|
expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt),
|
||||||
};
|
};
|
||||||
textareaRef.current.value = status;
|
textareaRef.current.value = status;
|
||||||
textareaRef.current.dispatchEvent(new Event('input'));
|
oninputTextarea();
|
||||||
|
focusTextarea();
|
||||||
spoilerTextRef.current.value = spoilerText;
|
spoilerTextRef.current.value = spoilerText;
|
||||||
setVisibility(visibility);
|
setVisibility(visibility);
|
||||||
setSensitive(sensitive);
|
setSensitive(sensitive);
|
||||||
|
@ -142,8 +165,9 @@ function Compose({
|
||||||
console.log({ statusSource });
|
console.log({ statusSource });
|
||||||
const { text, spoilerText } = statusSource;
|
const { text, spoilerText } = statusSource;
|
||||||
textareaRef.current.value = text;
|
textareaRef.current.value = text;
|
||||||
textareaRef.current.dispatchEvent(new Event('input'));
|
|
||||||
textareaRef.current.dataset.source = text;
|
textareaRef.current.dataset.source = text;
|
||||||
|
oninputTextarea();
|
||||||
|
focusTextarea();
|
||||||
spoilerTextRef.current.value = spoilerText;
|
spoilerTextRef.current.value = spoilerText;
|
||||||
setVisibility(visibility);
|
setVisibility(visibility);
|
||||||
setSensitive(sensitive);
|
setSensitive(sensitive);
|
||||||
|
@ -156,6 +180,8 @@ function Compose({
|
||||||
setUIState('error');
|
setUIState('error');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
} else {
|
||||||
|
focusTextarea();
|
||||||
}
|
}
|
||||||
}, [draftStatus, editStatus, replyToStatus]);
|
}, [draftStatus, editStatus, replyToStatus]);
|
||||||
|
|
||||||
|
@ -167,6 +193,7 @@ function Compose({
|
||||||
// console.log('text-expander-change', e);
|
// console.log('text-expander-change', e);
|
||||||
const { key, provide, text } = e.detail;
|
const { key, provide, text } = e.detail;
|
||||||
textExpanderTextRef.current = text;
|
textExpanderTextRef.current = text;
|
||||||
|
|
||||||
if (text === '') {
|
if (text === '') {
|
||||||
provide(
|
provide(
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
|
@ -175,6 +202,34 @@ function Compose({
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === ':') {
|
||||||
|
// const emojis = customEmojis.current.filter((emoji) =>
|
||||||
|
// emoji.shortcode.startsWith(text),
|
||||||
|
// );
|
||||||
|
const emojis = filterShortcodes(customEmojis.current, text);
|
||||||
|
let html = '';
|
||||||
|
emojis.forEach((emoji) => {
|
||||||
|
const { shortcode, url } = emoji;
|
||||||
|
html += `
|
||||||
|
<li role="option" data-value="${encodeHTML(shortcode)}">
|
||||||
|
<img src="${encodeHTML(
|
||||||
|
url,
|
||||||
|
)}" width="16" height="16" alt="" loading="lazy" />
|
||||||
|
:${encodeHTML(shortcode)}:
|
||||||
|
</li>`;
|
||||||
|
});
|
||||||
|
// console.log({ emojis, html });
|
||||||
|
menu.innerHTML = html;
|
||||||
|
provide(
|
||||||
|
Promise.resolve({
|
||||||
|
matched: emojis.length > 0,
|
||||||
|
fragment: menu,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const type = {
|
const type = {
|
||||||
'@': 'accounts',
|
'@': 'accounts',
|
||||||
'#': 'hashtags',
|
'#': 'hashtags',
|
||||||
|
@ -192,9 +247,7 @@ function Compose({
|
||||||
}
|
}
|
||||||
const results = value[type];
|
const results = value[type];
|
||||||
console.log('RESULTS', value, results);
|
console.log('RESULTS', value, results);
|
||||||
const menu = document.createElement('ul');
|
let html = '';
|
||||||
menu.role = 'listbox';
|
|
||||||
menu.className = 'text-expander-menu';
|
|
||||||
results.forEach((result) => {
|
results.forEach((result) => {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
|
@ -205,27 +258,29 @@ function Compose({
|
||||||
emojis,
|
emojis,
|
||||||
} = result;
|
} = result;
|
||||||
const displayNameWithEmoji = emojifyText(displayName, emojis);
|
const displayNameWithEmoji = emojifyText(displayName, emojis);
|
||||||
const item = document.createElement('li');
|
// const item = menuItem.cloneNode();
|
||||||
item.setAttribute('role', 'option');
|
|
||||||
if (acct) {
|
if (acct) {
|
||||||
item.dataset.value = acct;
|
html += `
|
||||||
// Want to use <Avatar /> here, but will need to render to string 😅
|
<li role="option" data-value="${encodeHTML(acct)}">
|
||||||
item.innerHTML = `
|
|
||||||
<span class="avatar">
|
<span class="avatar">
|
||||||
<img src="${avatarStatic}" width="16" height="16" alt="" loading="lazy" />
|
<img src="${encodeHTML(
|
||||||
|
avatarStatic,
|
||||||
|
)}" width="16" height="16" alt="" loading="lazy" />
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<b>${displayNameWithEmoji || username}</b>
|
<b>${encodeHTML(displayNameWithEmoji || username)}</b>
|
||||||
<br>@${acct}
|
<br>@${encodeHTML(acct)}
|
||||||
</span>
|
</span>
|
||||||
|
</li>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
item.dataset.value = name;
|
html += `
|
||||||
item.innerHTML = `
|
<li role="option" data-value="${encodeHTML(name)}">
|
||||||
<span>#<b>${name}</b></span>
|
<span>#<b>${encodeHTML(name)}</b></span>
|
||||||
|
</li>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
menu.appendChild(item);
|
menu.innerHTML = html;
|
||||||
});
|
});
|
||||||
console.log('MENU', results, menu);
|
console.log('MENU', results, menu);
|
||||||
resolve({
|
resolve({
|
||||||
|
@ -244,7 +299,11 @@ function Compose({
|
||||||
|
|
||||||
textExpanderRef.current.addEventListener('text-expander-value', (e) => {
|
textExpanderRef.current.addEventListener('text-expander-value', (e) => {
|
||||||
const { key, item } = e.detail;
|
const { key, item } = e.detail;
|
||||||
e.detail.value = key + item.dataset.value;
|
if (key === ':') {
|
||||||
|
e.detail.value = `:${item.dataset.value}:`;
|
||||||
|
} else {
|
||||||
|
e.detail.value = `${key}${item.dataset.value}`;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -334,6 +393,20 @@ function Compose({
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [charCount, setCharCount] = useState(
|
||||||
|
textareaRef.current?.value?.length +
|
||||||
|
spoilerTextRef.current?.value?.length || 0,
|
||||||
|
);
|
||||||
|
const leftChars = maxCharacters - charCount;
|
||||||
|
const getCharCount = () => {
|
||||||
|
const { value } = textareaRef.current;
|
||||||
|
const { value: spoilerText } = spoilerTextRef.current;
|
||||||
|
return stringLength(countableText(value)) + stringLength(spoilerText);
|
||||||
|
};
|
||||||
|
const updateCharCount = () => {
|
||||||
|
setCharCount(getCharCount());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="compose-container" class={standalone ? 'standalone' : ''}>
|
<div id="compose-container" class={standalone ? 'standalone' : ''}>
|
||||||
<div class="compose-top">
|
<div class="compose-top">
|
||||||
|
@ -497,6 +570,7 @@ function Compose({
|
||||||
sensitive = sensitive === 'on'; // checkboxes return "on" if checked
|
sensitive = sensitive === 'on'; // checkboxes return "on" if checked
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
|
/* Let the backend validate this
|
||||||
if (stringLength(status) > maxCharacters) {
|
if (stringLength(status) > maxCharacters) {
|
||||||
alert(`Status is too long! Max characters: ${maxCharacters}`);
|
alert(`Status is too long! Max characters: ${maxCharacters}`);
|
||||||
return;
|
return;
|
||||||
|
@ -510,6 +584,7 @@ function Compose({
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
if (poll) {
|
if (poll) {
|
||||||
if (poll.options.length < 2) {
|
if (poll.options.length < 2) {
|
||||||
alert('Poll must have at least 2 options');
|
alert('Poll must have at least 2 options');
|
||||||
|
@ -618,6 +693,9 @@ function Compose({
|
||||||
opacity: sensitive ? 1 : 0,
|
opacity: sensitive ? 1 : 0,
|
||||||
pointerEvents: sensitive ? 'auto' : 'none',
|
pointerEvents: sensitive ? 'auto' : 'none',
|
||||||
}}
|
}}
|
||||||
|
onInput={() => {
|
||||||
|
updateCharCount();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
class="toolbar-button"
|
class="toolbar-button"
|
||||||
|
@ -664,9 +742,8 @@ function Compose({
|
||||||
</select>
|
</select>
|
||||||
</label>{' '}
|
</label>{' '}
|
||||||
</div>
|
</div>
|
||||||
<text-expander ref={textExpanderRef} keys="@ #">
|
<text-expander ref={textExpanderRef} keys="@ # :">
|
||||||
<textarea
|
<textarea
|
||||||
class="large"
|
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
placeholder={
|
placeholder={
|
||||||
replyToStatus
|
replyToStatus
|
||||||
|
@ -692,9 +769,11 @@ function Compose({
|
||||||
e.target.style.height = value
|
e.target.style.height = value
|
||||||
? scrollHeight + offset + 'px'
|
? scrollHeight + offset + 'px'
|
||||||
: null;
|
: null;
|
||||||
|
updateCharCount();
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
maxHeight: `${maxCharacters / 50}em`,
|
maxHeight: `${maxCharacters / 50}em`,
|
||||||
|
'--text-weight': (1 + charCount / 140).toFixed(1) || 1,
|
||||||
}}
|
}}
|
||||||
></textarea>
|
></textarea>
|
||||||
</text-expander>
|
</text-expander>
|
||||||
|
@ -802,6 +881,27 @@ function Compose({
|
||||||
</button>{' '}
|
</button>{' '}
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
{uiState === 'loading' && <Loader abrupt />}{' '}
|
{uiState === 'loading' && <Loader abrupt />}{' '}
|
||||||
|
{uiState !== 'loading' && charCount > maxCharacters / 2 && (
|
||||||
|
<>
|
||||||
|
<meter
|
||||||
|
class={`donut ${
|
||||||
|
leftChars <= -10
|
||||||
|
? 'explode'
|
||||||
|
: leftChars <= 0
|
||||||
|
? 'danger'
|
||||||
|
: leftChars <= 20
|
||||||
|
? 'warning'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
value={charCount}
|
||||||
|
max={maxCharacters}
|
||||||
|
data-left={leftChars}
|
||||||
|
style={{
|
||||||
|
'--percentage': (charCount / maxCharacters) * 100,
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button type="submit" class="large" disabled={uiState === 'loading'}>
|
<button type="submit" class="large" disabled={uiState === 'loading'}>
|
||||||
{replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post'}
|
{replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post'}
|
||||||
</button>
|
</button>
|
||||||
|
@ -980,4 +1080,53 @@ function Poll({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterShortcodes(emojis, searchTerm) {
|
||||||
|
searchTerm = searchTerm.toLowerCase();
|
||||||
|
|
||||||
|
// Return an array of shortcodes that start with or contain the search term, sorted by relevance and limited to the first 5
|
||||||
|
return emojis
|
||||||
|
.sort((a, b) => {
|
||||||
|
let aLower = a.shortcode.toLowerCase();
|
||||||
|
let bLower = b.shortcode.toLowerCase();
|
||||||
|
|
||||||
|
let aStartsWith = aLower.startsWith(searchTerm);
|
||||||
|
let bStartsWith = bLower.startsWith(searchTerm);
|
||||||
|
let aContains = aLower.includes(searchTerm);
|
||||||
|
let bContains = bLower.includes(searchTerm);
|
||||||
|
let bothStartWith = aStartsWith && bStartsWith;
|
||||||
|
let bothContain = aContains && bContains;
|
||||||
|
|
||||||
|
return bothStartWith
|
||||||
|
? a.length - b.length
|
||||||
|
: aStartsWith
|
||||||
|
? -1
|
||||||
|
: bStartsWith
|
||||||
|
? 1
|
||||||
|
: bothContain
|
||||||
|
? a.length - b.length
|
||||||
|
: aContains
|
||||||
|
? -1
|
||||||
|
: bContains
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
})
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeHTML(str) {
|
||||||
|
return str.replace(/[&<>"']/g, function (char) {
|
||||||
|
return '&#' + char.charCodeAt(0) + ';';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js
|
||||||
|
const urlRegexObj = new RegExp(urlRegex.source, urlRegex.flags);
|
||||||
|
const usernameRegex = /(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/gi;
|
||||||
|
const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx';
|
||||||
|
function countableText(inputText) {
|
||||||
|
return inputText
|
||||||
|
.replace(urlRegexObj, urlPlaceholder)
|
||||||
|
.replace(usernameRegex, '$1@$3');
|
||||||
|
}
|
||||||
|
|
||||||
export default Compose;
|
export default Compose;
|
||||||
|
|
|
@ -42,6 +42,9 @@ const ICONS = {
|
||||||
popin: ['mingcute:external-link-line', '180deg'],
|
popin: ['mingcute:external-link-line', '180deg'],
|
||||||
plus: 'mingcute:add-circle-line',
|
plus: 'mingcute:add-circle-line',
|
||||||
'chevron-left': 'mingcute:left-line',
|
'chevron-left': 'mingcute:left-line',
|
||||||
|
reply: ['mingcute:share-forward-line', '180deg', 'horizontal'],
|
||||||
|
thread: 'mingcute:route-line',
|
||||||
|
group: 'mingcute:group-line',
|
||||||
};
|
};
|
||||||
|
|
||||||
function Icon({ icon, size = 'm', alt, title, class: className = '' }) {
|
function Icon({ icon, size = 'm', alt, title, class: className = '' }) {
|
||||||
|
@ -49,9 +52,9 @@ function Icon({ icon, size = 'm', alt, title, class: className = '' }) {
|
||||||
|
|
||||||
const iconSize = SIZES[size];
|
const iconSize = SIZES[size];
|
||||||
let iconName = ICONS[icon];
|
let iconName = ICONS[icon];
|
||||||
let rotate;
|
let rotate, flip;
|
||||||
if (Array.isArray(iconName)) {
|
if (Array.isArray(iconName)) {
|
||||||
[iconName, rotate] = iconName;
|
[iconName, rotate, flip] = iconName;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -70,6 +73,7 @@ function Icon({ icon, size = 'm', alt, title, class: className = '' }) {
|
||||||
height={iconSize}
|
height={iconSize}
|
||||||
icon={iconName}
|
icon={iconName}
|
||||||
rotate={rotate}
|
rotate={rotate}
|
||||||
|
flip={flip}
|
||||||
>
|
>
|
||||||
{alt}
|
{alt}
|
||||||
</iconify-icon>
|
</iconify-icon>
|
||||||
|
|
|
@ -15,5 +15,4 @@ a.name-text.short:hover i {
|
||||||
|
|
||||||
.name-text .avatar {
|
.name-text .avatar {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-right: 4px;
|
|
||||||
}
|
}
|
|
@ -13,7 +13,11 @@ function NameText({ account, showAvatar, showAcct, short, external }) {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!short &&
|
!short &&
|
||||||
username.toLowerCase().trim() === (displayName || '').toLowerCase().trim()
|
username.toLowerCase().trim() ===
|
||||||
|
(displayName || '')
|
||||||
|
.replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
) {
|
) {
|
||||||
username = null;
|
username = null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@
|
||||||
/* STATUS PRE META */
|
/* STATUS PRE META */
|
||||||
|
|
||||||
.status-pre-meta {
|
.status-pre-meta {
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
padding: 8px 16px 0;
|
padding: 8px 16px 0;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
font-size: smaller;
|
font-size: smaller;
|
||||||
|
@ -45,6 +45,7 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
margin-bottom: -8px;
|
||||||
}
|
}
|
||||||
.status-pre-meta .name-text {
|
.status-pre-meta .name-text {
|
||||||
display: inline;
|
display: inline;
|
||||||
|
@ -60,7 +61,7 @@
|
||||||
.status {
|
.status {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 16px 16px 20px;
|
padding: 16px 16px 20px;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
@ -78,12 +79,6 @@
|
||||||
.status.large.visibility-direct {
|
.status.large.visibility-direct {
|
||||||
background-image: var(--fade-in-out-bg), var(--yellow-stripes);
|
background-image: var(--fade-in-out-bg), var(--yellow-stripes);
|
||||||
}
|
}
|
||||||
.status-pre-meta + .status {
|
|
||||||
padding-top: 8px;
|
|
||||||
}
|
|
||||||
.status.small {
|
|
||||||
/* font-size: 95%; */
|
|
||||||
}
|
|
||||||
.status.skeleton {
|
.status.skeleton {
|
||||||
color: var(--outline-color);
|
color: var(--outline-color);
|
||||||
}
|
}
|
||||||
|
@ -97,13 +92,19 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.status:not(.small) .container {
|
.status:not(.small) .container {
|
||||||
padding-left: 16px;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status > .container > .meta {
|
.status > .container > .meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status > .container > .meta > * {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.status.large > .container > .meta {
|
.status.large > .container > .meta {
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
|
@ -129,6 +130,39 @@
|
||||||
font-size: smaller;
|
font-size: smaller;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-reply-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-left: 4px;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.status-reply-badge .icon {
|
||||||
|
color: var(--reply-to-color);
|
||||||
|
}
|
||||||
|
.status-thread-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--reply-to-color);
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--reply-to-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.75;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
-70deg,
|
||||||
|
transparent,
|
||||||
|
transparent 3px,
|
||||||
|
var(--reply-to-faded-color) 3px,
|
||||||
|
var(--reply-to-faded-color) 4px
|
||||||
|
);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.status.large .content-container {
|
.status.large .content-container {
|
||||||
margin-left: calc(-50px - 16px);
|
margin-left: calc(-50px - 16px);
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
|
@ -136,7 +170,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.status .content-container.has-spoiler .spoiler {
|
.status .content-container.has-spoiler .spoiler {
|
||||||
margin: 8px 0;
|
margin: 4px 0;
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
border: 1px dashed var(--button-bg-color);
|
border: 1px dashed var(--button-bg-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -144,10 +178,17 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.status .content-container.has-spoiler .spoiler ~ * {
|
.status .content-container.has-spoiler .spoiler ~ * {
|
||||||
filter: blur(6px) invert(0.5);
|
/* filter: blur(6px) invert(0.5); */
|
||||||
|
filter: url(#spoiler);
|
||||||
|
transform: translate3d(-5px, -5px, 0);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: filter 0.5s;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
contain: layout;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.status .content-container.has-spoiler .spoiler ~ * {
|
||||||
|
filter: url(#spoiler-dark);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.status .content-container.has-spoiler .spoiler ~ .content ~ * {
|
.status .content-container.has-spoiler .spoiler ~ .content ~ * {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
@ -156,7 +197,8 @@
|
||||||
border-style: dotted;
|
border-style: dotted;
|
||||||
}
|
}
|
||||||
.status .content-container.show-spoiler .spoiler ~ * {
|
.status .content-container.show-spoiler .spoiler ~ * {
|
||||||
filter: none;
|
filter: none !important;
|
||||||
|
transform: none;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
user-select: auto;
|
user-select: auto;
|
||||||
}
|
}
|
||||||
|
@ -165,7 +207,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.status .content {
|
.status .content {
|
||||||
margin-top: 8px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
.timeline-deck .status .content {
|
.timeline-deck .status .content {
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
|
@ -238,6 +280,10 @@
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
.status:not(.large) .media-container.media-gt4 {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
.status .media {
|
.status .media {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
flex-basis: calc(50% - 8px);
|
flex-basis: calc(50% - 8px);
|
||||||
|
@ -247,6 +293,12 @@
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
border: 1px solid var(--outline-color);
|
border: 1px solid var(--outline-color);
|
||||||
}
|
}
|
||||||
|
.status .media-container.media-gt2 .media {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
.status:not(.large) .media-container.media-gt4 .media {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
.status .media:hover {
|
.status .media:hover {
|
||||||
border-color: var(--outline-hover-color);
|
border-color: var(--outline-hover-color);
|
||||||
}
|
}
|
||||||
|
@ -275,7 +327,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.status .media img:hover {
|
.status .media img:hover {
|
||||||
animation: position-object 5s ease-in-out 1s infinite;
|
animation: position-object 5s ease-in-out 1s 5;
|
||||||
}
|
}
|
||||||
.status .media video {
|
.status .media video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -354,7 +406,7 @@
|
||||||
border-inline-end: 1px solid var(--outline-color);
|
border-inline-end: 1px solid var(--outline-color);
|
||||||
}
|
}
|
||||||
.card:hover .image {
|
.card:hover .image {
|
||||||
animation: position-object 5s ease-in-out 1s infinite;
|
animation: position-object 5s ease-in-out 1s 5;
|
||||||
}
|
}
|
||||||
.card p {
|
.card p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
@ -99,6 +99,7 @@ function Status({
|
||||||
reblog,
|
reblog,
|
||||||
uri,
|
uri,
|
||||||
emojis,
|
emojis,
|
||||||
|
_deleted,
|
||||||
} = status;
|
} = status;
|
||||||
|
|
||||||
const createdAtDate = new Date(createdAt);
|
const createdAtDate = new Date(createdAt);
|
||||||
|
@ -220,13 +221,13 @@ function Status({
|
||||||
)}
|
)}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span>
|
{/* <span> */}
|
||||||
<NameText
|
<NameText
|
||||||
account={status.account}
|
account={status.account}
|
||||||
showAvatar={size === 's'}
|
showAvatar={size === 's'}
|
||||||
showAcct={size === 'l'}
|
showAcct={size === 'l'}
|
||||||
/>
|
/>
|
||||||
{inReplyToAccount && !withinContext && size !== 's' && (
|
{/* {inReplyToAccount && !withinContext && size !== 's' && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
<span class="ib">
|
<span class="ib">
|
||||||
|
@ -234,8 +235,8 @@ function Status({
|
||||||
<NameText account={inReplyToAccount} short />
|
<NameText account={inReplyToAccount} short />
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)} */}
|
||||||
</span>{' '}
|
{/* </span> */}{' '}
|
||||||
{size !== 'l' &&
|
{size !== 'l' &&
|
||||||
(uri ? (
|
(uri ? (
|
||||||
<a href={uri} target="_blank" class="time">
|
<a href={uri} target="_blank" class="time">
|
||||||
|
@ -271,6 +272,26 @@ function Status({
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{inReplyToAccountId && !withinContext && size !== 's' && (
|
||||||
|
<>
|
||||||
|
{inReplyToAccountId === status.account.id ? (
|
||||||
|
<div class="status-thread-badge">
|
||||||
|
<Icon icon="thread" size="s" />
|
||||||
|
Thread
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!!inReplyToAccount &&
|
||||||
|
!mentions.find((mention) => {
|
||||||
|
return mention.id === inReplyToAccountId;
|
||||||
|
}) && (
|
||||||
|
<div class="status-reply-badge">
|
||||||
|
<Icon icon="reply" />{' '}
|
||||||
|
<NameText account={inReplyToAccount} short />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
class={`content-container ${
|
class={`content-container ${
|
||||||
sensitive || spoilerText ? 'has-spoiler' : ''
|
sensitive || spoilerText ? 'has-spoiler' : ''
|
||||||
|
@ -333,9 +354,12 @@ function Status({
|
||||||
).innerText
|
).innerText
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/^@/, '');
|
.replace(/^@/, '');
|
||||||
|
const url = target.getAttribute('href');
|
||||||
const mention = mentions.find(
|
const mention = mentions.find(
|
||||||
(mention) =>
|
(mention) =>
|
||||||
mention.username === username || mention.acct === username,
|
mention.username === username ||
|
||||||
|
mention.acct === username ||
|
||||||
|
mention.url === url,
|
||||||
);
|
);
|
||||||
if (mention) {
|
if (mention) {
|
||||||
states.showAccount = mention.acct;
|
states.showAccount = mention.acct;
|
||||||
|
@ -387,7 +411,11 @@ function Status({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!!mediaAttachments.length && (
|
{!!mediaAttachments.length && (
|
||||||
<div class="media-container">
|
<div
|
||||||
|
class={`media-container ${
|
||||||
|
mediaAttachments.length > 2 ? 'media-gt2' : ''
|
||||||
|
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
|
||||||
|
>
|
||||||
{mediaAttachments.map((media, i) => (
|
{mediaAttachments.map((media, i) => (
|
||||||
<Media
|
<Media
|
||||||
media={media}
|
media={media}
|
||||||
|
@ -704,7 +732,9 @@ function Media({ media, showOriginal, onClick = () => {} }) {
|
||||||
);
|
);
|
||||||
} else if (type === 'gifv' || type === 'video') {
|
} else if (type === 'gifv' || type === 'video') {
|
||||||
// 20 seconds, treat as a gif
|
// 20 seconds, treat as a gif
|
||||||
const isGIF = type === 'gifv' && original.duration <= 20;
|
const shortDuration = original.duration <= 20;
|
||||||
|
const isGIF = type === 'gifv' || shortDuration;
|
||||||
|
const loopable = original.duration <= 60;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`media media-${isGIF ? 'gif' : 'video'}`}
|
class={`media media-${isGIF ? 'gif' : 'video'}`}
|
||||||
|
@ -713,30 +743,35 @@ function Media({ media, showOriginal, onClick = () => {} }) {
|
||||||
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (isGIF) {
|
if (showOriginal && isGIF) {
|
||||||
try {
|
try {
|
||||||
videoRef.current?.pause();
|
if (videoRef.current.paused) {
|
||||||
|
videoRef.current.play();
|
||||||
|
} else {
|
||||||
|
videoRef.current.pause();
|
||||||
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
onClick(e);
|
onClick(e);
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (isGIF) {
|
if (!showOriginal && isGIF) {
|
||||||
try {
|
try {
|
||||||
videoRef.current?.play();
|
videoRef.current.play();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
if (isGIF) {
|
if (!showOriginal && isGIF) {
|
||||||
try {
|
try {
|
||||||
videoRef.current?.pause();
|
videoRef.current.pause();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showOriginal ? (
|
{showOriginal ? (
|
||||||
<video
|
<video
|
||||||
|
ref={videoRef}
|
||||||
src={url}
|
src={url}
|
||||||
poster={previewUrl}
|
poster={previewUrl}
|
||||||
width={width}
|
width={width}
|
||||||
|
@ -746,14 +781,7 @@ function Media({ media, showOriginal, onClick = () => {} }) {
|
||||||
muted={isGIF}
|
muted={isGIF}
|
||||||
controls={!isGIF}
|
controls={!isGIF}
|
||||||
playsinline
|
playsinline
|
||||||
loop
|
loop={loopable}
|
||||||
onClick={() => {
|
|
||||||
if (isGIF) {
|
|
||||||
try {
|
|
||||||
videoRef.current?.play();
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></video>
|
></video>
|
||||||
) : isGIF ? (
|
) : isGIF ? (
|
||||||
<video
|
<video
|
||||||
|
@ -898,6 +926,41 @@ function Poll({ poll, readOnly, onUpdate = () => {} }) {
|
||||||
|
|
||||||
const expiresAtDate = !!expiresAt && new Date(expiresAt);
|
const expiresAtDate = !!expiresAt && new Date(expiresAt);
|
||||||
|
|
||||||
|
// Update poll at point of expiry
|
||||||
|
useEffect(() => {
|
||||||
|
let timeout;
|
||||||
|
if (!expired && expiresAtDate) {
|
||||||
|
const ms = expiresAtDate.getTime() - Date.now() + 1; // +1 to give it a little buffer
|
||||||
|
if (ms > 0) {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const pollResponse = await masto.poll.fetch(id);
|
||||||
|
onUpdate(pollResponse);
|
||||||
|
} catch (e) {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
setUIState('default');
|
||||||
|
})();
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [expired, expiresAtDate]);
|
||||||
|
|
||||||
|
const pollVotesCount = votersCount || votesCount;
|
||||||
|
let roundPrecision = 0;
|
||||||
|
if (pollVotesCount <= 1000) {
|
||||||
|
roundPrecision = 0;
|
||||||
|
} else if (pollVotesCount <= 10000) {
|
||||||
|
roundPrecision = 1;
|
||||||
|
} else if (pollVotesCount <= 100000) {
|
||||||
|
roundPrecision = 2;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`poll ${readOnly ? 'read-only' : ''} ${
|
class={`poll ${readOnly ? 'read-only' : ''} ${
|
||||||
|
@ -907,9 +970,10 @@ function Poll({ poll, readOnly, onUpdate = () => {} }) {
|
||||||
{voted || expired ? (
|
{voted || expired ? (
|
||||||
options.map((option, i) => {
|
options.map((option, i) => {
|
||||||
const { title, votesCount: optionVotesCount } = option;
|
const { title, votesCount: optionVotesCount } = option;
|
||||||
const pollVotesCount = votersCount || votesCount;
|
|
||||||
const percentage =
|
const percentage =
|
||||||
Math.round((optionVotesCount / pollVotesCount) * 100) || 0;
|
((optionVotesCount / pollVotesCount) * 100).toFixed(
|
||||||
|
roundPrecision,
|
||||||
|
) || 0;
|
||||||
// check if current poll choice is the leading one
|
// check if current poll choice is the leading one
|
||||||
const isLeading =
|
const isLeading =
|
||||||
optionVotesCount > 0 &&
|
optionVotesCount > 0 &&
|
||||||
|
|
1
src/data/url-regex.json
Normal file
1
src/data/url-regex.json
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,8 +1,10 @@
|
||||||
:root {
|
:root {
|
||||||
|
text-size-adjust: none;
|
||||||
|
|
||||||
--blue-color: royalblue;
|
--blue-color: royalblue;
|
||||||
--purple-color: blueviolet;
|
--purple-color: blueviolet;
|
||||||
--green-color: green;
|
--green-color: green;
|
||||||
--orange-color: orange;
|
--orange-color: darkorange;
|
||||||
--red-color: orangered;
|
--red-color: orangered;
|
||||||
--bg-color: #fff;
|
--bg-color: #fff;
|
||||||
--bg-faded-color: #f0f2f5;
|
--bg-faded-color: #f0f2f5;
|
||||||
|
@ -39,6 +41,7 @@
|
||||||
--blue-color: CornflowerBlue;
|
--blue-color: CornflowerBlue;
|
||||||
--purple-color: mediumpurple;
|
--purple-color: mediumpurple;
|
||||||
--green-color: limegreen;
|
--green-color: limegreen;
|
||||||
|
--orange-color: orange;
|
||||||
--bg-color: #242526;
|
--bg-color: #242526;
|
||||||
--bg-faded-color: #18191a;
|
--bg-faded-color: #18191a;
|
||||||
--bg-blur-color: #0009;
|
--bg-blur-color: #0009;
|
||||||
|
@ -150,6 +153,9 @@ button,
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border: 1px solid var(--outline-color);
|
border: 1px solid var(--outline-color);
|
||||||
}
|
}
|
||||||
|
:is(button, .button).light.danger:not(:disabled, .disabled) {
|
||||||
|
color: var(--red-color);
|
||||||
|
}
|
||||||
|
|
||||||
:is(button, .button).block {
|
:is(button, .button).block {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
@ -7,7 +7,6 @@ import Icon from '../components/icon';
|
||||||
import Loader from '../components/loader';
|
import Loader from '../components/loader';
|
||||||
import Status from '../components/status';
|
import Status from '../components/status';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
import store from '../utils/store';
|
|
||||||
|
|
||||||
const LIMIT = 20;
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
@ -70,46 +69,6 @@ function Home({ hidden }) {
|
||||||
loadStatuses(true);
|
loadStatuses(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (document.hidden) {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
store.session.set('lastHidden', timestamp);
|
|
||||||
} else {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const lastHidden = store.session.get('lastHidden');
|
|
||||||
const diff = timestamp - lastHidden;
|
|
||||||
const diffMins = Math.round(diff / 1000 / 60);
|
|
||||||
if (diffMins > 1) {
|
|
||||||
console.log('visible', { lastHidden, diffMins });
|
|
||||||
setUIState('loading');
|
|
||||||
setTimeout(() => {
|
|
||||||
(async () => {
|
|
||||||
const newStatuses = await masto.timelines.fetchHome({
|
|
||||||
limit: 2,
|
|
||||||
// Need 2 because "new posts" only appear when there are 2 or more
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
newStatuses.value.length &&
|
|
||||||
newStatuses.value[0].id !== states.home[0].id
|
|
||||||
) {
|
|
||||||
states.homeNew = newStatuses.value;
|
|
||||||
}
|
|
||||||
setUIState('default');
|
|
||||||
})();
|
|
||||||
// loadStatuses(true);
|
|
||||||
// states.homeNew = [];
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
setUIState('default');
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scrollableRef = useRef();
|
const scrollableRef = useRef();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
.notification {
|
.notification {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 16px !important;
|
padding: 16px !important;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.only-mentions .notification:not(.mention),
|
.only-mentions .notification:not(.mention),
|
||||||
.only-mentions .timeline-empty {
|
.only-mentions .timeline-empty {
|
||||||
|
@ -58,9 +58,11 @@
|
||||||
|
|
||||||
.notification-content {
|
.notification-content {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.notification-content p:first-child {
|
.notification-content p:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#mentions-option {
|
#mentions-option {
|
||||||
|
|
|
@ -292,7 +292,11 @@ function Notifications() {
|
||||||
return (
|
return (
|
||||||
<div class="deck-container" ref={scrollableRef}>
|
<div class="deck-container" ref={scrollableRef}>
|
||||||
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
|
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
|
||||||
<header>
|
<header
|
||||||
|
onClick={() => {
|
||||||
|
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="header-side">
|
<div class="header-side">
|
||||||
<a href="#" class="button plain">
|
<a href="#" class="button plain">
|
||||||
<Icon icon="home" size="l" />
|
<Icon icon="home" size="l" />
|
||||||
|
|
29
src/pages/status.css
Normal file
29
src/pages/status.css
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
.status-deck header h1 {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
}
|
||||||
|
.status-deck header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status-deck header h1 {
|
||||||
|
min-width: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-deck header.inview h1 {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-heading {
|
||||||
|
font-size: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
.hero-heading .insignificant {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
|
import './status.css';
|
||||||
|
|
||||||
import debounce from 'just-debounce-it';
|
import debounce from 'just-debounce-it';
|
||||||
import { Link } from 'preact-router/match';
|
import { Link } from 'preact-router/match';
|
||||||
import {
|
import {
|
||||||
|
@ -7,16 +9,22 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
|
import { InView } from 'react-intersection-observer';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import Loader from '../components/loader';
|
import Loader from '../components/loader';
|
||||||
|
import NameText from '../components/name-text';
|
||||||
import Status from '../components/status';
|
import Status from '../components/status';
|
||||||
|
import htmlContentLength from '../utils/html-content-length';
|
||||||
import shortenNumber from '../utils/shorten-number';
|
import shortenNumber from '../utils/shorten-number';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
|
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
const LIMIT = 40;
|
||||||
|
|
||||||
function StatusPage({ id }) {
|
function StatusPage({ id }) {
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const [statuses, setStatuses] = useState([]);
|
const [statuses, setStatuses] = useState([]);
|
||||||
|
@ -43,32 +51,34 @@ function StatusPage({ id }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
|
|
||||||
const containsStatus = statuses.find((s) => s.id === id);
|
|
||||||
if (!containsStatus) {
|
|
||||||
// Case 1: On first load, or when navigating to a status that's not cached at all
|
|
||||||
setStatuses([{ id }]);
|
|
||||||
} else {
|
|
||||||
const cachedStatuses = store.session.getJSON('statuses-' + id);
|
const cachedStatuses = store.session.getJSON('statuses-' + id);
|
||||||
if (cachedStatuses) {
|
if (cachedStatuses) {
|
||||||
// Case 2: Looks like we've cached this status before, let's restore them to make it snappy
|
// Case 1: It's cached, let's restore them to make it snappy
|
||||||
const reallyCachedStatuses = cachedStatuses.filter(
|
const reallyCachedStatuses = cachedStatuses.filter(
|
||||||
(s) => snapStates.statuses.has(s.id),
|
(s) => states.statuses.has(s.id),
|
||||||
// Some are not cached in the global state, so we need to filter them out
|
// Some are not cached in the global state, so we need to filter them out
|
||||||
);
|
);
|
||||||
setStatuses(reallyCachedStatuses);
|
setStatuses(reallyCachedStatuses);
|
||||||
} else {
|
} else {
|
||||||
// Case 3: Unknown state, could be a sub-comment. Let's slice off all descendant statuses after the hero status to be safe because they are custom-rendered with sub-comments etc
|
|
||||||
const heroIndex = statuses.findIndex((s) => s.id === id);
|
const heroIndex = statuses.findIndex((s) => s.id === id);
|
||||||
|
if (heroIndex !== -1) {
|
||||||
|
// Case 2: It's in current statuses. Slice off all descendant statuses after the hero status to be safe
|
||||||
const slicedStatuses = statuses.slice(0, heroIndex + 1);
|
const slicedStatuses = statuses.slice(0, heroIndex + 1);
|
||||||
setStatuses(slicedStatuses);
|
setStatuses(slicedStatuses);
|
||||||
|
} else {
|
||||||
|
// Case 3: Not cached and not in statuses, let's start from scratch
|
||||||
|
setStatuses([{ id }]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
const heroFetch = masto.statuses.fetch(id);
|
||||||
|
const contextFetch = masto.statuses.fetchContext(id);
|
||||||
|
|
||||||
const hasStatus = snapStates.statuses.has(id);
|
const hasStatus = snapStates.statuses.has(id);
|
||||||
let heroStatus = snapStates.statuses.get(id);
|
let heroStatus = snapStates.statuses.get(id);
|
||||||
try {
|
try {
|
||||||
heroStatus = await masto.statuses.fetch(id);
|
heroStatus = await heroFetch;
|
||||||
states.statuses.set(id, heroStatus);
|
states.statuses.set(id, heroStatus);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent fail if status is cached
|
// Silent fail if status is cached
|
||||||
|
@ -80,7 +90,7 @@ function StatusPage({ id }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const context = await masto.statuses.fetchContext(id);
|
const context = await contextFetch;
|
||||||
const { ancestors, descendants } = context;
|
const { ancestors, descendants } = context;
|
||||||
|
|
||||||
ancestors.forEach((status) => {
|
ancestors.forEach((status) => {
|
||||||
|
@ -124,7 +134,11 @@ function StatusPage({ id }) {
|
||||||
accountID: s.account.id,
|
accountID: s.account.id,
|
||||||
descendant: true,
|
descendant: true,
|
||||||
thread: s.account.id === heroStatus.account.id,
|
thread: s.account.id === heroStatus.account.id,
|
||||||
replies: s.__replies?.map((r) => r.id),
|
replies: s.__replies?.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
repliesCount: r.repliesCount,
|
||||||
|
content: r.content,
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -139,6 +153,8 @@ function StatusPage({ id }) {
|
||||||
})();
|
})();
|
||||||
}, [id, snapStates.reloadStatusPage]);
|
}, [id, snapStates.reloadStatusPage]);
|
||||||
|
|
||||||
|
const firstLoad = useRef(true);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!statuses.length) return;
|
if (!statuses.length) return;
|
||||||
const isLoading = uiState === 'loading';
|
const isLoading = uiState === 'loading';
|
||||||
|
@ -149,13 +165,19 @@ function StatusPage({ id }) {
|
||||||
console.log('Case 1');
|
console.log('Case 1');
|
||||||
heroStatusRef.current?.scrollIntoView();
|
heroStatusRef.current?.scrollIntoView();
|
||||||
} else if (isLoading && statuses.length > 1) {
|
} else if (isLoading && statuses.length > 1) {
|
||||||
// Case 2: User initiated, while statuses are loading, SMOOTH-SCROLL to hero status
|
if (firstLoad.current) {
|
||||||
console.log('Case 2');
|
// Case 2.1: User initiated, first load, don't smooth scroll anything
|
||||||
|
console.log('Case 2.1');
|
||||||
|
heroStatusRef.current?.scrollIntoView();
|
||||||
|
} else {
|
||||||
|
// Case 2.2: User initiated, while statuses are loading, SMOOTH-SCROLL to hero status
|
||||||
|
console.log('Case 2.2');
|
||||||
heroStatusRef.current?.scrollIntoView({
|
heroStatusRef.current?.scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
block: 'start',
|
block: 'start',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const scrollPosition = states.scrollPositions.get(id);
|
const scrollPosition = states.scrollPositions.get(id);
|
||||||
if (scrollPosition && scrollableRef.current) {
|
if (scrollPosition && scrollableRef.current) {
|
||||||
|
@ -168,12 +190,14 @@ function StatusPage({ id }) {
|
||||||
isLoading,
|
isLoading,
|
||||||
userInitiated: userInitiated.current,
|
userInitiated: userInitiated.current,
|
||||||
statusesLength: statuses.length,
|
statusesLength: statuses.length,
|
||||||
|
firstLoad: firstLoad.current,
|
||||||
// scrollPosition,
|
// scrollPosition,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
// Reset user initiated flag after statuses are loaded
|
// Reset user initiated flag after statuses are loaded
|
||||||
userInitiated.current = false;
|
userInitiated.current = false;
|
||||||
|
firstLoad.current = false;
|
||||||
}
|
}
|
||||||
}, [statuses, uiState]);
|
}, [statuses, uiState]);
|
||||||
|
|
||||||
|
@ -215,15 +239,18 @@ function StatusPage({ id }) {
|
||||||
});
|
});
|
||||||
const closeLink = `#${prevRoute || '/'}`;
|
const closeLink = `#${prevRoute || '/'}`;
|
||||||
|
|
||||||
const [limit, setLimit] = useState(40);
|
const [limit, setLimit] = useState(LIMIT);
|
||||||
const showMore = useMemo(() => {
|
const showMore = useMemo(() => {
|
||||||
// return number of statuses to show
|
// return number of statuses to show
|
||||||
return statuses.length - limit;
|
return statuses.length - limit;
|
||||||
}, [statuses.length, limit]);
|
}, [statuses.length, limit]);
|
||||||
|
|
||||||
const hasManyStatuses = statuses.length > 40;
|
const hasManyStatuses = statuses.length > LIMIT;
|
||||||
const hasDescendants = statuses.some((s) => s.descendant);
|
const hasDescendants = statuses.some((s) => s.descendant);
|
||||||
|
|
||||||
|
const [heroInView, setHeroInView] = useState(true);
|
||||||
|
const onView = useDebouncedCallback(setHeroInView, 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="deck-backdrop">
|
<div class="deck-backdrop">
|
||||||
<Link href={closeLink}></Link>
|
<Link href={closeLink}></Link>
|
||||||
|
@ -233,13 +260,43 @@ function StatusPage({ id }) {
|
||||||
statuses.length > 1 ? 'padded-bottom' : ''
|
statuses.length > 1 ? 'padded-bottom' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<header>
|
<header
|
||||||
|
class={`${heroInView ? 'inview' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (
|
||||||
|
!/^(a|button)$/i.test(e.target.tagName) &&
|
||||||
|
heroStatusRef.current
|
||||||
|
) {
|
||||||
|
heroStatusRef.current.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* <div>
|
{/* <div>
|
||||||
<Link class="button plain deck-close" href={closeLink}>
|
<Link class="button plain deck-close" href={closeLink}>
|
||||||
<Icon icon="chevron-left" size="xl" />
|
<Icon icon="chevron-left" size="xl" />
|
||||||
</Link>
|
</Link>
|
||||||
</div> */}
|
</div> */}
|
||||||
<h1>Status</h1>
|
<h1>
|
||||||
|
{!heroInView && heroStatus ? (
|
||||||
|
<span class="hero-heading">
|
||||||
|
<NameText showAvatar account={heroStatus.account} short />{' '}
|
||||||
|
<span class="insignificant">
|
||||||
|
•{' '}
|
||||||
|
<relative-time
|
||||||
|
datetime={heroStatus.createdAt}
|
||||||
|
format="micro"
|
||||||
|
threshold="P1D"
|
||||||
|
prefix=""
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Status'
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
<div class="header-side">
|
<div class="header-side">
|
||||||
<Loader hidden={uiState !== 'loading'} />
|
<Loader hidden={uiState !== 'loading'} />
|
||||||
<Link class="button plain deck-close" href={closeLink}>
|
<Link class="button plain deck-close" href={closeLink}>
|
||||||
|
@ -270,7 +327,9 @@ function StatusPage({ id }) {
|
||||||
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
|
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
|
||||||
>
|
>
|
||||||
{isHero ? (
|
{isHero ? (
|
||||||
|
<InView threshold={0.5} onChange={onView}>
|
||||||
<Status statusID={statusID} withinContext size="l" />
|
<Status statusID={statusID} withinContext size="l" />
|
||||||
|
</InView>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
class="
|
class="
|
||||||
|
@ -286,32 +345,26 @@ function StatusPage({ id }) {
|
||||||
withinContext
|
withinContext
|
||||||
size={thread || ancestor ? 'm' : 's'}
|
size={thread || ancestor ? 'm' : 's'}
|
||||||
/>
|
/>
|
||||||
</Link>
|
{replies?.length > LIMIT && (
|
||||||
)}
|
<div class="replies-link">
|
||||||
{descendant && replies?.length > 0 && (
|
<Icon icon="comment" />{' '}
|
||||||
<details class="replies" open={!hasManyStatuses}>
|
|
||||||
<summary hidden={!hasManyStatuses}>
|
|
||||||
<span title={replies.length}>
|
<span title={replies.length}>
|
||||||
{shortenNumber(replies.length)}
|
{shortenNumber(replies.length)}
|
||||||
</span>{' '}
|
</span>
|
||||||
repl{replies.length === 1 ? 'y' : 'ies'}
|
</div>
|
||||||
</summary>
|
)}
|
||||||
<ul>
|
</Link>
|
||||||
{replies.map((replyID) => (
|
)}
|
||||||
<li key={replyID}>
|
{descendant &&
|
||||||
<Link
|
replies?.length > 0 &&
|
||||||
class="status-link"
|
replies?.length <= LIMIT && (
|
||||||
href={`#/s/${replyID}`}
|
<SubComments
|
||||||
onClick={() => {
|
hasManyStatuses={hasManyStatuses}
|
||||||
|
replies={replies}
|
||||||
|
onStatusLinkClick={() => {
|
||||||
userInitiated.current = true;
|
userInitiated.current = true;
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Status statusID={replyID} withinContext size="s" />
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
)}
|
)}
|
||||||
{uiState === 'loading' &&
|
{uiState === 'loading' &&
|
||||||
isHero &&
|
isHero &&
|
||||||
|
@ -330,11 +383,13 @@ function StatusPage({ id }) {
|
||||||
type="button"
|
type="button"
|
||||||
class="plain block"
|
class="plain block"
|
||||||
disabled={uiState === 'loading'}
|
disabled={uiState === 'loading'}
|
||||||
onClick={() => setLimit((l) => l + 40)}
|
onClick={() => setLimit((l) => l + LIMIT)}
|
||||||
style={{ marginBlockEnd: '6em' }}
|
style={{ marginBlockEnd: '6em' }}
|
||||||
>
|
>
|
||||||
Show more…{' '}
|
Show more…{' '}
|
||||||
<span class="tag">{showMore > 40 ? '40+' : showMore}</span>
|
<span class="tag">
|
||||||
|
{showMore > LIMIT ? `${LIMIT}+` : showMore}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
@ -344,4 +399,53 @@ function StatusPage({ id }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SubComments({
|
||||||
|
hasManyStatuses,
|
||||||
|
replies,
|
||||||
|
onStatusLinkClick = () => {},
|
||||||
|
}) {
|
||||||
|
// If less than or 2 replies and total number of characters of content from replies is less than 500
|
||||||
|
let isBrief = false;
|
||||||
|
if (replies.length <= 2) {
|
||||||
|
let totalLength = replies.reduce((acc, reply) => {
|
||||||
|
const { content } = reply;
|
||||||
|
const length = htmlContentLength(content);
|
||||||
|
return acc + length;
|
||||||
|
}, 0);
|
||||||
|
isBrief = totalLength < 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = isBrief || !hasManyStatuses;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details class="replies" open={open}>
|
||||||
|
<summary hidden={open}>
|
||||||
|
<span title={replies.length}>{shortenNumber(replies.length)}</span> repl
|
||||||
|
{replies.length === 1 ? 'y' : 'ies'}
|
||||||
|
</summary>
|
||||||
|
<ul>
|
||||||
|
{replies.map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<Link
|
||||||
|
class="status-link"
|
||||||
|
href={`#/s/${r.id}`}
|
||||||
|
onClick={onStatusLinkClick}
|
||||||
|
>
|
||||||
|
<Status statusID={r.id} withinContext size="s" />
|
||||||
|
{r.repliesCount > 0 && (
|
||||||
|
<div class="replies-link">
|
||||||
|
<Icon icon="comment" />{' '}
|
||||||
|
<span title={r.repliesCount}>
|
||||||
|
{shortenNumber(r.repliesCount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default StatusPage;
|
export default StatusPage;
|
||||||
|
|
|
@ -29,20 +29,6 @@ function enhanceContent(content, opts = {}) {
|
||||||
node.replaceWith(...nodes);
|
node.replaceWith(...nodes);
|
||||||
});
|
});
|
||||||
|
|
||||||
// INLINE CODE
|
|
||||||
// ===========
|
|
||||||
// Convert `code` to <code>code</code>
|
|
||||||
textNodes = extractTextNodes(dom);
|
|
||||||
textNodes.forEach((node) => {
|
|
||||||
let html = node.nodeValue.replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
if (/`[^`]+`/g.test(html)) {
|
|
||||||
html = html.replaceAll(/(`[^]+?`)/g, '<code>$1</code>');
|
|
||||||
}
|
|
||||||
fauxDiv.innerHTML = html;
|
|
||||||
const nodes = Array.from(fauxDiv.childNodes);
|
|
||||||
node.replaceWith(...nodes);
|
|
||||||
});
|
|
||||||
|
|
||||||
// CODE BLOCKS
|
// CODE BLOCKS
|
||||||
// ===========
|
// ===========
|
||||||
// Convert ```code``` to <pre><code>code</code></pre>
|
// Convert ```code``` to <pre><code>code</code></pre>
|
||||||
|
@ -57,6 +43,39 @@ function enhanceContent(content, opts = {}) {
|
||||||
block.replaceWith(pre);
|
block.replaceWith(pre);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// INLINE CODE
|
||||||
|
// ===========
|
||||||
|
// Convert `code` to <code>code</code>
|
||||||
|
textNodes = extractTextNodes(dom);
|
||||||
|
textNodes.forEach((node) => {
|
||||||
|
let html = node.nodeValue.replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
if (/`[^`]+`/g.test(html)) {
|
||||||
|
html = html.replaceAll(/(`[^]+?`)/g, '<code>$1</code>');
|
||||||
|
}
|
||||||
|
fauxDiv.innerHTML = html;
|
||||||
|
const nodes = Array.from(fauxDiv.childNodes);
|
||||||
|
node.replaceWith(...nodes);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TWITTER USERNAMES
|
||||||
|
// =================
|
||||||
|
// Convert @username@twitter.com to <a href="https://twitter.com/username">@username@twitter.com</a>
|
||||||
|
textNodes = extractTextNodes(dom, {
|
||||||
|
rejectFilter: ['A'],
|
||||||
|
});
|
||||||
|
textNodes.forEach((node) => {
|
||||||
|
let html = node.nodeValue.replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
if (/@[a-zA-Z0-9_]+@twitter\.com/g.test(html)) {
|
||||||
|
html = html.replaceAll(
|
||||||
|
/(@([a-zA-Z0-9_]+)@twitter\.com)/g,
|
||||||
|
'<a href="https://twitter.com/$2" rel="nofollow noopener noreferrer" target="_blank">$1</a>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fauxDiv.innerHTML = html;
|
||||||
|
const nodes = Array.from(fauxDiv.childNodes);
|
||||||
|
node.replaceWith(...nodes);
|
||||||
|
});
|
||||||
|
|
||||||
if (postEnhanceDOM) {
|
if (postEnhanceDOM) {
|
||||||
postEnhanceDOM(dom); // mutate dom
|
postEnhanceDOM(dom); // mutate dom
|
||||||
}
|
}
|
||||||
|
@ -66,12 +85,60 @@ function enhanceContent(content, opts = {}) {
|
||||||
return enhancedContent;
|
return enhancedContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTextNodes(dom) {
|
const defaultRejectFilter = [
|
||||||
|
// Document metadata
|
||||||
|
'STYLE',
|
||||||
|
// Image and multimedia
|
||||||
|
'IMG',
|
||||||
|
'VIDEO',
|
||||||
|
'AUDIO',
|
||||||
|
'AREA',
|
||||||
|
'MAP',
|
||||||
|
'TRACK',
|
||||||
|
// Embedded content
|
||||||
|
'EMBED',
|
||||||
|
'IFRAME',
|
||||||
|
'OBJECT',
|
||||||
|
'PICTURE',
|
||||||
|
'PORTAL',
|
||||||
|
'SOURCE',
|
||||||
|
// SVG and MathML
|
||||||
|
'SVG',
|
||||||
|
'MATH',
|
||||||
|
// Scripting
|
||||||
|
'CANVAS',
|
||||||
|
'NOSCRIPT',
|
||||||
|
'SCRIPT',
|
||||||
|
// Forms
|
||||||
|
'INPUT',
|
||||||
|
'OPTION',
|
||||||
|
'TEXTAREA',
|
||||||
|
// Web Components
|
||||||
|
'SLOT',
|
||||||
|
'TEMPLATE',
|
||||||
|
];
|
||||||
|
const defaultRejectFilterMap = Object.fromEntries(
|
||||||
|
defaultRejectFilter.map((nodeName) => [nodeName, true]),
|
||||||
|
);
|
||||||
|
function extractTextNodes(dom, opts = {}) {
|
||||||
const textNodes = [];
|
const textNodes = [];
|
||||||
const walk = document.createTreeWalker(
|
const walk = document.createTreeWalker(
|
||||||
dom,
|
dom,
|
||||||
NodeFilter.SHOW_TEXT,
|
NodeFilter.SHOW_TEXT,
|
||||||
null,
|
{
|
||||||
|
acceptNode(node) {
|
||||||
|
if (defaultRejectFilterMap[node.parentNode.nodeName]) {
|
||||||
|
return NodeFilter.FILTER_REJECT;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
opts.rejectFilter &&
|
||||||
|
opts.rejectFilter.includes(node.parentNode.nodeName)
|
||||||
|
) {
|
||||||
|
return NodeFilter.FILTER_REJECT;
|
||||||
|
}
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
},
|
||||||
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
let node;
|
let node;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
export default {
|
export default {
|
||||||
public: 'earth',
|
public: 'earth',
|
||||||
unlisted: 'unlock',
|
unlisted: 'group',
|
||||||
private: 'lock',
|
private: 'lock',
|
||||||
direct: 'message',
|
direct: 'message',
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
import preact from '@preact/preset-vite';
|
import preact from '@preact/preset-vite';
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { defineConfig, splitVendorChunkPlugin } from 'vite';
|
import { defineConfig, loadEnv, splitVendorChunkPlugin } from 'vite';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
const { VITE_CLIENT_NAME: CLIENT_NAME, NODE_ENV } = process.env;
|
const { VITE_CLIENT_NAME: CLIENT_NAME, NODE_ENV } = loadEnv(
|
||||||
|
'production',
|
||||||
|
process.cwd(),
|
||||||
|
);
|
||||||
|
|
||||||
const commitHash = execSync('git rev-parse --short HEAD').toString().trim();
|
const commitHash = execSync('git rev-parse --short HEAD').toString().trim();
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue