Merge pull request #20 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2022-12-24 23:06:13 +08:00 committed by GitHub
commit 13de3d9263
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1053 additions and 240 deletions

View file

@ -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
View file

@ -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",

View file

@ -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",

View file

@ -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
View 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}`);
},
);

View file

@ -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 {

View file

@ -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 () => {

View file

@ -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);

View file

@ -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>

View file

@ -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;

View file

@ -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,28 +87,45 @@ 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([
replyToStatus.account.acct,
...replyToStatus.mentions.map((m) => m.acct),
]);
const allMentions = [...mentions].filter(
(m) => m !== currentAccountInfo.acct,
);
if (allMentions.length > 0) {
textareaRef.current.value = `${allMentions
.map((m) => `@${m}`)
.join(' ')} `;
textareaRef.current.dispatchEvent(new Event('input'));
}
textareaRef.current.focus();
} }
const mentions = new Set([
replyToStatus.account.acct,
...replyToStatus.mentions.map((m) => m.acct),
]);
const allMentions = [...mentions].filter(
(m) => m !== currentAccountInfo.acct,
);
if (allMentions.length > 0) {
textareaRef.current.value = `${allMentions
.map((m) => `@${m}`)
.join(' ')} `;
oninputTextarea();
}
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="${encodeHTML(
<img src="${avatarStatic}" width="16" height="16" alt="" loading="lazy" /> avatarStatic,
</span> )}" width="16" height="16" alt="" loading="lazy" />
<span> </span>
<b>${displayNameWithEmoji || username}</b> <span>
<br>@${acct} <b>${encodeHTML(displayNameWithEmoji || username)}</b>
</span> <br>@${encodeHTML(acct)}
</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;

View file

@ -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>

View file

@ -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; }
}

View file

@ -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;
} }

View file

@ -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;

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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;

View file

@ -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 (

View file

@ -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 {

View file

@ -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
View 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;
}

View file

@ -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); const cachedStatuses = store.session.getJSON('statuses-' + id);
if (!containsStatus) { if (cachedStatuses) {
// Case 1: On first load, or when navigating to a status that's not cached at all // Case 1: It's cached, let's restore them to make it snappy
setStatuses([{ id }]); const reallyCachedStatuses = cachedStatuses.filter(
(s) => states.statuses.has(s.id),
// Some are not cached in the global state, so we need to filter them out
);
setStatuses(reallyCachedStatuses);
} else { } else {
const cachedStatuses = store.session.getJSON('statuses-' + id); const heroIndex = statuses.findIndex((s) => s.id === id);
if (cachedStatuses) { if (heroIndex !== -1) {
// Case 2: Looks like we've cached this status before, let's restore them to make it snappy // Case 2: It's in current statuses. Slice off all descendant statuses after the hero status to be safe
const reallyCachedStatuses = cachedStatuses.filter(
(s) => snapStates.statuses.has(s.id),
// Some are not cached in the global state, so we need to filter them out
);
setStatuses(reallyCachedStatuses);
} 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 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,12 +165,18 @@ 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
heroStatusRef.current?.scrollIntoView({ console.log('Case 2.1');
behavior: 'smooth', heroStatusRef.current?.scrollIntoView();
block: 'start', } else {
}); // Case 2.2: User initiated, while statuses are loading, SMOOTH-SCROLL to hero status
console.log('Case 2.2');
heroStatusRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
} }
} else { } else {
const scrollPosition = states.scrollPositions.get(id); const scrollPosition = states.scrollPositions.get(id);
@ -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">
&bull;{' '}
<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 ? (
<Status statusID={statusID} withinContext size="l" /> <InView threshold={0.5} onChange={onView}>
<Status statusID={statusID} withinContext size="l" />
</InView>
) : ( ) : (
<Link <Link
class=" class="
@ -286,33 +345,27 @@ function StatusPage({ id }) {
withinContext withinContext
size={thread || ancestor ? 'm' : 's'} size={thread || ancestor ? 'm' : 's'}
/> />
{replies?.length > LIMIT && (
<div class="replies-link">
<Icon icon="comment" />{' '}
<span title={replies.length}>
{shortenNumber(replies.length)}
</span>
</div>
)}
</Link> </Link>
)} )}
{descendant && replies?.length > 0 && ( {descendant &&
<details class="replies" open={!hasManyStatuses}> replies?.length > 0 &&
<summary hidden={!hasManyStatuses}> replies?.length <= LIMIT && (
<span title={replies.length}> <SubComments
{shortenNumber(replies.length)} hasManyStatuses={hasManyStatuses}
</span>{' '} replies={replies}
repl{replies.length === 1 ? 'y' : 'ies'} onStatusLinkClick={() => {
</summary> userInitiated.current = true;
<ul> }}
{replies.map((replyID) => ( />
<li key={replyID}> )}
<Link
class="status-link"
href={`#/s/${replyID}`}
onClick={() => {
userInitiated.current = true;
}}
>
<Status statusID={replyID} withinContext size="s" />
</Link>
</li>
))}
</ul>
</details>
)}
{uiState === 'loading' && {uiState === 'loading' &&
isHero && isHero &&
!!heroStatus?.repliesCount && !!heroStatus?.repliesCount &&
@ -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&hellip;{' '} Show more&hellip;{' '}
<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;

View file

@ -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, '&lt;').replace(/>/g, '&gt;');
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, '&lt;').replace(/>/g, '&gt;');
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, '&lt;').replace(/>/g, '&gt;');
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;

View file

@ -1,6 +1,6 @@
export default { export default {
public: 'earth', public: 'earth',
unlisted: 'unlock', unlisted: 'group',
private: 'lock', private: 'lock',
direct: 'message', direct: 'message',
}; };

View file

@ -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();