commit
13de3d9263
57
index.html
57
index.html
|
@ -33,5 +33,62 @@
|
|||
<div id="app"></div>
|
||||
<div id="modal-container"></div>
|
||||
<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>
|
||||
</html>
|
||||
|
|
95
package-lock.json
generated
95
package-lock.json
generated
|
@ -13,8 +13,8 @@
|
|||
"fast-blurhash": "~1.1.2",
|
||||
"history": "~5.3.0",
|
||||
"iconify-icon": "~1.0.2",
|
||||
"just-debounce-it": "^3.2.0",
|
||||
"masto": "~4.10.1",
|
||||
"just-debounce-it": "~3.2.0",
|
||||
"masto": "~4.11.1",
|
||||
"mem": "~9.0.2",
|
||||
"preact": "~10.11.3",
|
||||
"preact-router": "~4.1.0",
|
||||
|
@ -29,7 +29,8 @@
|
|||
"autoprefixer": "~10.4.13",
|
||||
"postcss": "~8.4.20",
|
||||
"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",
|
||||
"workbox-cacheable-response": "~6.5.4",
|
||||
"workbox-expiration": "~6.5.4",
|
||||
|
@ -2930,6 +2931,14 @@
|
|||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"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": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.26.1.tgz",
|
||||
|
@ -4137,9 +4146,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/masto": {
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/masto/-/masto-4.10.1.tgz",
|
||||
"integrity": "sha512-zEcQff0MkXTMDT9yXSyJw8+9oBkbcaSnYntm4x57CSReD1OBM7Z3FOQjEwydTetHwsX+etE0EvQHFB8mNggl6g==",
|
||||
"version": "4.11.1",
|
||||
"resolved": "https://registry.npmjs.org/masto/-/masto-4.11.1.tgz",
|
||||
"integrity": "sha512-siTQNhfLV1JjOERCGgjagMvD6q0K0hLuhOXrbXNcYzHAwpbPeSeAM6CSpIRrZ8zFDepOR62Djs/GtJdTR21Rfw==",
|
||||
"dependencies": {
|
||||
"axios": "1.1.3",
|
||||
"change-case": "^4.1.2",
|
||||
|
@ -5075,6 +5084,30 @@
|
|||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
|
||||
"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": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
||||
|
@ -5285,9 +5318,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.2.tgz",
|
||||
"integrity": "sha512-QJaY3R+tFlTagH0exVqbgkkw45B+/bXVBzF2ZD1KB5Z8RiAoiKo60vSUf6/r4c2Vh9jfGBKM4oBI9b4/1ZJYng==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.3.tgz",
|
||||
"integrity": "sha512-HvuNv1RdE7deIfQb8mPk51UKjqptO/4RXZ5yXSAvurd5xOckwS/gg8h9Tky3uSbnjYTgUm0hVCet1cyhKd73ZA==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.16.3",
|
||||
|
@ -7752,6 +7785,12 @@
|
|||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"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": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.26.1.tgz",
|
||||
|
@ -8646,9 +8685,9 @@
|
|||
}
|
||||
},
|
||||
"masto": {
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/masto/-/masto-4.10.1.tgz",
|
||||
"integrity": "sha512-zEcQff0MkXTMDT9yXSyJw8+9oBkbcaSnYntm4x57CSReD1OBM7Z3FOQjEwydTetHwsX+etE0EvQHFB8mNggl6g==",
|
||||
"version": "4.11.1",
|
||||
"resolved": "https://registry.npmjs.org/masto/-/masto-4.11.1.tgz",
|
||||
"integrity": "sha512-siTQNhfLV1JjOERCGgjagMvD6q0K0hLuhOXrbXNcYzHAwpbPeSeAM6CSpIRrZ8zFDepOR62Djs/GtJdTR21Rfw==",
|
||||
"requires": {
|
||||
"axios": "1.1.3",
|
||||
"change-case": "^4.1.2",
|
||||
|
@ -9316,6 +9355,32 @@
|
|||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
|
||||
"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": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
||||
|
@ -9442,9 +9507,9 @@
|
|||
}
|
||||
},
|
||||
"vite": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.2.tgz",
|
||||
"integrity": "sha512-QJaY3R+tFlTagH0exVqbgkkw45B+/bXVBzF2ZD1KB5Z8RiAoiKo60vSUf6/r4c2Vh9jfGBKM4oBI9b4/1ZJYng==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.3.tgz",
|
||||
"integrity": "sha512-HvuNv1RdE7deIfQb8mPk51UKjqptO/4RXZ5yXSAvurd5xOckwS/gg8h9Tky3uSbnjYTgUm0hVCet1cyhKd73ZA==",
|
||||
"devOptional": true,
|
||||
"requires": {
|
||||
"esbuild": "^0.16.3",
|
||||
|
|
|
@ -15,8 +15,8 @@
|
|||
"fast-blurhash": "~1.1.2",
|
||||
"history": "~5.3.0",
|
||||
"iconify-icon": "~1.0.2",
|
||||
"just-debounce-it": "^3.2.0",
|
||||
"masto": "~4.10.1",
|
||||
"just-debounce-it": "~3.2.0",
|
||||
"masto": "~4.11.1",
|
||||
"mem": "~9.0.2",
|
||||
"preact": "~10.11.3",
|
||||
"preact-router": "~4.1.0",
|
||||
|
@ -31,7 +31,8 @@
|
|||
"autoprefixer": "~10.4.13",
|
||||
"postcss": "~8.4.20",
|
||||
"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",
|
||||
"workbox-cacheable-response": "~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
|
||||
const apiExtendedRoute = new RegExpRoute(
|
||||
/^https?:\/\/[^\/]+\/api\/v\d+\/instance/,
|
||||
/^https?:\/\/[^\/]+\/api\/v\d+\/(instance|custom_emojis)/,
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'api-extended',
|
||||
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 {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: inherit;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
/* a.mention:has(span).hashtag {
|
||||
color: var(--link-light-color);
|
||||
|
@ -38,6 +40,7 @@ a.mention span {
|
|||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.deck-container[hidden] {
|
||||
display: block;
|
||||
|
@ -68,6 +71,7 @@ a.mention span {
|
|||
overflow-x: hidden;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.deck header {
|
||||
|
@ -75,7 +79,7 @@ a.mention span {
|
|||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--bg-blur-color);
|
||||
backdrop-filter: blur(12px);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
z-index: 1;
|
||||
cursor: default;
|
||||
|
@ -83,6 +87,7 @@ a.mention span {
|
|||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
.deck header > .header-side:last-of-type {
|
||||
text-align: right;
|
||||
|
@ -159,14 +164,14 @@ a.mention span {
|
|||
> .status-link
|
||||
+ .replies
|
||||
> summary {
|
||||
margin-left: calc(50px + 16px + 16px);
|
||||
margin-left: calc(50px + 16px + 12px);
|
||||
}
|
||||
.timeline.contextual
|
||||
> li.descendant.thread
|
||||
> .status-link
|
||||
+ .replies
|
||||
.status-link {
|
||||
padding-left: calc(50px + 16px + 16px);
|
||||
padding-left: calc(50px + 16px + 12px);
|
||||
}
|
||||
.timeline.contextual
|
||||
> li.descendant:not(.thread)
|
||||
|
@ -197,6 +202,19 @@ a.mention span {
|
|||
border-color: transparent transparent var(--comment-line-color) transparent;
|
||||
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 {
|
||||
margin-top: -12px;
|
||||
}
|
||||
|
@ -238,9 +256,9 @@ a.mention span {
|
|||
.timeline.contextual > li .replies li {
|
||||
position: relative;
|
||||
}
|
||||
.timeline.contextual > li .replies li .status {
|
||||
.timeline.contextual > li .replies li {
|
||||
--width: 3px;
|
||||
--left: 0px;
|
||||
--left: calc(40px + 16px);
|
||||
--right: calc(var(--left) + var(--width));
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
|
@ -253,7 +271,10 @@ a.mention span {
|
|||
);
|
||||
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;
|
||||
}
|
||||
.timeline.contextual > li .replies li:before {
|
||||
|
@ -272,7 +293,7 @@ a.mention span {
|
|||
transform: rotate(45deg);
|
||||
}
|
||||
.timeline.contextual > li.thread .replies li:before {
|
||||
left: calc(50px + 16px + 16px);
|
||||
left: calc(50px + 16px + 12px);
|
||||
}
|
||||
.timeline.contextual.loading > li:not(.hero) {
|
||||
opacity: 0.5;
|
||||
|
@ -317,6 +338,7 @@ a.mention span {
|
|||
text-decoration-line: none;
|
||||
color: inherit;
|
||||
transition: background-color 0.2s ease-out;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.status-link:hover {
|
||||
background-color: var(--link-bg-hover-color);
|
||||
|
@ -432,6 +454,7 @@ a.mention span {
|
|||
}
|
||||
.carousel > * {
|
||||
scroll-snap-align: center;
|
||||
scroll-snap-stop: always;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
@ -576,6 +599,7 @@ button.carousel-dot[disabled].active {
|
|||
box-shadow: 0 -1px 32px var(--divider-color);
|
||||
animation: slide-up 0.2s var(--timing-function);
|
||||
border: 1px solid var(--outline-color);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* TAG */
|
||||
|
@ -643,6 +667,65 @@ button.carousel-dot[disabled].active {
|
|||
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) {
|
||||
html,
|
||||
body {
|
||||
|
|
98
src/app.jsx
98
src/app.jsx
|
@ -56,7 +56,9 @@ async function startStream() {
|
|||
});
|
||||
stream.on('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) => {
|
||||
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() {
|
||||
const snapStates = useSnapshot(states);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
|
@ -208,6 +303,7 @@ export function App() {
|
|||
if (isLoggedIn) {
|
||||
requestAnimationFrame(() => {
|
||||
startStream();
|
||||
startVisibility();
|
||||
|
||||
// Collect instance info
|
||||
(async () => {
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
#account-container .note {
|
||||
font-size: 95%;
|
||||
line-height: 1.5;
|
||||
line-height: 1.4;
|
||||
}
|
||||
#account-container .note:not(:has(p)) {
|
||||
/* Some notes don't have <p> tags, so we need to add some padding */
|
||||
|
@ -52,6 +52,13 @@
|
|||
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 {
|
||||
font-size: 90%;
|
||||
color: var(--text-insignificant-color);
|
||||
|
|
|
@ -184,7 +184,7 @@ function Account({ account }) {
|
|||
{relationshipUIState !== 'loading' && relationship && (
|
||||
<button
|
||||
type="button"
|
||||
class={following ? 'light' : ''}
|
||||
class={`${following ? 'light danger' : ''}`}
|
||||
disabled={relationshipUIState === 'loading'}
|
||||
onClick={() => {
|
||||
setRelationshipUIState('loading');
|
||||
|
@ -210,7 +210,7 @@ function Account({ account }) {
|
|||
})();
|
||||
}}
|
||||
>
|
||||
{following ? 'Unfollow' : 'Follow'}
|
||||
{following ? 'Unfollow…' : 'Follow'}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
|
|
|
@ -26,11 +26,20 @@
|
|||
#compose-container textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 3em;
|
||||
min-height: 3em;
|
||||
height: 4em;
|
||||
min-height: 4em;
|
||||
max-height: 10em;
|
||||
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 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
|
|
@ -4,6 +4,7 @@ import '@github/text-expander-element';
|
|||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import stringLength from 'string-length';
|
||||
|
||||
import urlRegex from '../data/url-regex';
|
||||
import emojifyText from '../utils/emojify-text';
|
||||
import openCompose from '../utils/open-compose';
|
||||
import store from '../utils/store';
|
||||
|
@ -36,6 +37,10 @@ const expiresInFromExpiresAt = (expiresAt) => {
|
|||
return expirySeconds.find((s) => s >= delta) || oneDay;
|
||||
};
|
||||
|
||||
const menu = document.createElement('ul');
|
||||
menu.role = 'listbox';
|
||||
menu.className = 'text-expander-menu';
|
||||
|
||||
function Compose({
|
||||
onClose,
|
||||
replyToStatus,
|
||||
|
@ -82,28 +87,45 @@ function Compose({
|
|||
const [mediaAttachments, setMediaAttachments] = useState([]);
|
||||
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(() => {
|
||||
if (replyToStatus) {
|
||||
const { spoilerText, visibility, sensitive } = replyToStatus;
|
||||
if (spoilerText && spoilerTextRef.current) {
|
||||
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);
|
||||
setSensitive(sensitive);
|
||||
}
|
||||
|
@ -122,7 +144,8 @@ function Compose({
|
|||
expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt),
|
||||
};
|
||||
textareaRef.current.value = status;
|
||||
textareaRef.current.dispatchEvent(new Event('input'));
|
||||
oninputTextarea();
|
||||
focusTextarea();
|
||||
spoilerTextRef.current.value = spoilerText;
|
||||
setVisibility(visibility);
|
||||
setSensitive(sensitive);
|
||||
|
@ -142,8 +165,9 @@ function Compose({
|
|||
console.log({ statusSource });
|
||||
const { text, spoilerText } = statusSource;
|
||||
textareaRef.current.value = text;
|
||||
textareaRef.current.dispatchEvent(new Event('input'));
|
||||
textareaRef.current.dataset.source = text;
|
||||
oninputTextarea();
|
||||
focusTextarea();
|
||||
spoilerTextRef.current.value = spoilerText;
|
||||
setVisibility(visibility);
|
||||
setSensitive(sensitive);
|
||||
|
@ -156,6 +180,8 @@ function Compose({
|
|||
setUIState('error');
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
focusTextarea();
|
||||
}
|
||||
}, [draftStatus, editStatus, replyToStatus]);
|
||||
|
||||
|
@ -167,6 +193,7 @@ function Compose({
|
|||
// console.log('text-expander-change', e);
|
||||
const { key, provide, text } = e.detail;
|
||||
textExpanderTextRef.current = text;
|
||||
|
||||
if (text === '') {
|
||||
provide(
|
||||
Promise.resolve({
|
||||
|
@ -175,6 +202,34 @@ function Compose({
|
|||
);
|
||||
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 = {
|
||||
'@': 'accounts',
|
||||
'#': 'hashtags',
|
||||
|
@ -192,9 +247,7 @@ function Compose({
|
|||
}
|
||||
const results = value[type];
|
||||
console.log('RESULTS', value, results);
|
||||
const menu = document.createElement('ul');
|
||||
menu.role = 'listbox';
|
||||
menu.className = 'text-expander-menu';
|
||||
let html = '';
|
||||
results.forEach((result) => {
|
||||
const {
|
||||
name,
|
||||
|
@ -205,27 +258,29 @@ function Compose({
|
|||
emojis,
|
||||
} = result;
|
||||
const displayNameWithEmoji = emojifyText(displayName, emojis);
|
||||
const item = document.createElement('li');
|
||||
item.setAttribute('role', 'option');
|
||||
// const item = menuItem.cloneNode();
|
||||
if (acct) {
|
||||
item.dataset.value = acct;
|
||||
// Want to use <Avatar /> here, but will need to render to string 😅
|
||||
item.innerHTML = `
|
||||
<span class="avatar">
|
||||
<img src="${avatarStatic}" width="16" height="16" alt="" loading="lazy" />
|
||||
</span>
|
||||
<span>
|
||||
<b>${displayNameWithEmoji || username}</b>
|
||||
<br>@${acct}
|
||||
</span>
|
||||
html += `
|
||||
<li role="option" data-value="${encodeHTML(acct)}">
|
||||
<span class="avatar">
|
||||
<img src="${encodeHTML(
|
||||
avatarStatic,
|
||||
)}" width="16" height="16" alt="" loading="lazy" />
|
||||
</span>
|
||||
<span>
|
||||
<b>${encodeHTML(displayNameWithEmoji || username)}</b>
|
||||
<br>@${encodeHTML(acct)}
|
||||
</span>
|
||||
</li>
|
||||
`;
|
||||
} else {
|
||||
item.dataset.value = name;
|
||||
item.innerHTML = `
|
||||
<span>#<b>${name}</b></span>
|
||||
html += `
|
||||
<li role="option" data-value="${encodeHTML(name)}">
|
||||
<span>#<b>${encodeHTML(name)}</b></span>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
menu.appendChild(item);
|
||||
menu.innerHTML = html;
|
||||
});
|
||||
console.log('MENU', results, menu);
|
||||
resolve({
|
||||
|
@ -244,7 +299,11 @@ function Compose({
|
|||
|
||||
textExpanderRef.current.addEventListener('text-expander-value', (e) => {
|
||||
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 (
|
||||
<div id="compose-container" class={standalone ? 'standalone' : ''}>
|
||||
<div class="compose-top">
|
||||
|
@ -497,6 +570,7 @@ function Compose({
|
|||
sensitive = sensitive === 'on'; // checkboxes return "on" if checked
|
||||
|
||||
// Validation
|
||||
/* Let the backend validate this
|
||||
if (stringLength(status) > maxCharacters) {
|
||||
alert(`Status is too long! Max characters: ${maxCharacters}`);
|
||||
return;
|
||||
|
@ -510,6 +584,7 @@ function Compose({
|
|||
);
|
||||
return;
|
||||
}
|
||||
*/
|
||||
if (poll) {
|
||||
if (poll.options.length < 2) {
|
||||
alert('Poll must have at least 2 options');
|
||||
|
@ -618,6 +693,9 @@ function Compose({
|
|||
opacity: sensitive ? 1 : 0,
|
||||
pointerEvents: sensitive ? 'auto' : 'none',
|
||||
}}
|
||||
onInput={() => {
|
||||
updateCharCount();
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
class="toolbar-button"
|
||||
|
@ -664,9 +742,8 @@ function Compose({
|
|||
</select>
|
||||
</label>{' '}
|
||||
</div>
|
||||
<text-expander ref={textExpanderRef} keys="@ #">
|
||||
<text-expander ref={textExpanderRef} keys="@ # :">
|
||||
<textarea
|
||||
class="large"
|
||||
ref={textareaRef}
|
||||
placeholder={
|
||||
replyToStatus
|
||||
|
@ -692,9 +769,11 @@ function Compose({
|
|||
e.target.style.height = value
|
||||
? scrollHeight + offset + 'px'
|
||||
: null;
|
||||
updateCharCount();
|
||||
}}
|
||||
style={{
|
||||
maxHeight: `${maxCharacters / 50}em`,
|
||||
'--text-weight': (1 + charCount / 140).toFixed(1) || 1,
|
||||
}}
|
||||
></textarea>
|
||||
</text-expander>
|
||||
|
@ -802,6 +881,27 @@ function Compose({
|
|||
</button>{' '}
|
||||
<div class="spacer" />
|
||||
{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'}>
|
||||
{replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post'}
|
||||
</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;
|
||||
|
|
|
@ -42,6 +42,9 @@ const ICONS = {
|
|||
popin: ['mingcute:external-link-line', '180deg'],
|
||||
plus: 'mingcute:add-circle-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 = '' }) {
|
||||
|
@ -49,9 +52,9 @@ function Icon({ icon, size = 'm', alt, title, class: className = '' }) {
|
|||
|
||||
const iconSize = SIZES[size];
|
||||
let iconName = ICONS[icon];
|
||||
let rotate;
|
||||
let rotate, flip;
|
||||
if (Array.isArray(iconName)) {
|
||||
[iconName, rotate] = iconName;
|
||||
[iconName, rotate, flip] = iconName;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
|
@ -70,6 +73,7 @@ function Icon({ icon, size = 'm', alt, title, class: className = '' }) {
|
|||
height={iconSize}
|
||||
icon={iconName}
|
||||
rotate={rotate}
|
||||
flip={flip}
|
||||
>
|
||||
{alt}
|
||||
</iconify-icon>
|
||||
|
|
|
@ -15,5 +15,4 @@ a.name-text.short:hover i {
|
|||
|
||||
.name-text .avatar {
|
||||
vertical-align: middle;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,11 @@ function NameText({ account, showAvatar, showAcct, short, external }) {
|
|||
|
||||
if (
|
||||
!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;
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
/* STATUS PRE META */
|
||||
|
||||
.status-pre-meta {
|
||||
line-height: 1.5;
|
||||
line-height: 1.4;
|
||||
padding: 8px 16px 0;
|
||||
opacity: 0.75;
|
||||
font-size: smaller;
|
||||
|
@ -45,6 +45,7 @@
|
|||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
.status-pre-meta .name-text {
|
||||
display: inline;
|
||||
|
@ -60,7 +61,7 @@
|
|||
.status {
|
||||
display: flex;
|
||||
padding: 16px 16px 20px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.4;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
|
@ -78,12 +79,6 @@
|
|||
.status.large.visibility-direct {
|
||||
background-image: var(--fade-in-out-bg), var(--yellow-stripes);
|
||||
}
|
||||
.status-pre-meta + .status {
|
||||
padding-top: 8px;
|
||||
}
|
||||
.status.small {
|
||||
/* font-size: 95%; */
|
||||
}
|
||||
.status.skeleton {
|
||||
color: var(--outline-color);
|
||||
}
|
||||
|
@ -97,13 +92,19 @@
|
|||
min-width: 0;
|
||||
}
|
||||
.status:not(.small) .container {
|
||||
padding-left: 16px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.status > .container > .meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status > .container > .meta > * {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.status.large > .container > .meta {
|
||||
min-height: 50px;
|
||||
|
@ -129,6 +130,39 @@
|
|||
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 {
|
||||
margin-left: calc(-50px - 16px);
|
||||
padding-top: 10px;
|
||||
|
@ -136,7 +170,7 @@
|
|||
}
|
||||
|
||||
.status .content-container.has-spoiler .spoiler {
|
||||
margin: 8px 0;
|
||||
margin: 4px 0;
|
||||
font-size: 90%;
|
||||
border: 1px dashed var(--button-bg-color);
|
||||
display: flex;
|
||||
|
@ -144,10 +178,17 @@
|
|||
align-items: center;
|
||||
}
|
||||
.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;
|
||||
transition: filter 0.5s;
|
||||
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 ~ * {
|
||||
opacity: 0.5;
|
||||
|
@ -156,7 +197,8 @@
|
|||
border-style: dotted;
|
||||
}
|
||||
.status .content-container.show-spoiler .spoiler ~ * {
|
||||
filter: none;
|
||||
filter: none !important;
|
||||
transform: none;
|
||||
pointer-events: auto;
|
||||
user-select: auto;
|
||||
}
|
||||
|
@ -165,7 +207,7 @@
|
|||
}
|
||||
|
||||
.status .content {
|
||||
margin-top: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.timeline-deck .status .content {
|
||||
max-height: 50vh;
|
||||
|
@ -238,6 +280,10 @@
|
|||
gap: 2px;
|
||||
flex-direction: row;
|
||||
}
|
||||
.status:not(.large) .media-container.media-gt4 {
|
||||
flex-wrap: nowrap;
|
||||
overflow: auto;
|
||||
}
|
||||
.status .media {
|
||||
flex-grow: 1;
|
||||
flex-basis: calc(50% - 8px);
|
||||
|
@ -247,6 +293,12 @@
|
|||
min-height: 80px;
|
||||
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 {
|
||||
border-color: var(--outline-hover-color);
|
||||
}
|
||||
|
@ -275,7 +327,7 @@
|
|||
}
|
||||
}
|
||||
.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 {
|
||||
width: 100%;
|
||||
|
@ -354,7 +406,7 @@
|
|||
border-inline-end: 1px solid var(--outline-color);
|
||||
}
|
||||
.card:hover .image {
|
||||
animation: position-object 5s ease-in-out 1s infinite;
|
||||
animation: position-object 5s ease-in-out 1s 5;
|
||||
}
|
||||
.card p {
|
||||
margin: 0;
|
||||
|
|
|
@ -99,6 +99,7 @@ function Status({
|
|||
reblog,
|
||||
uri,
|
||||
emojis,
|
||||
_deleted,
|
||||
} = status;
|
||||
|
||||
const createdAtDate = new Date(createdAt);
|
||||
|
@ -220,13 +221,13 @@ function Status({
|
|||
)}
|
||||
<div class="container">
|
||||
<div class="meta">
|
||||
<span>
|
||||
<NameText
|
||||
account={status.account}
|
||||
showAvatar={size === 's'}
|
||||
showAcct={size === 'l'}
|
||||
/>
|
||||
{inReplyToAccount && !withinContext && size !== 's' && (
|
||||
{/* <span> */}
|
||||
<NameText
|
||||
account={status.account}
|
||||
showAvatar={size === 's'}
|
||||
showAcct={size === 'l'}
|
||||
/>
|
||||
{/* {inReplyToAccount && !withinContext && size !== 's' && (
|
||||
<>
|
||||
{' '}
|
||||
<span class="ib">
|
||||
|
@ -234,8 +235,8 @@ function Status({
|
|||
<NameText account={inReplyToAccount} short />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>{' '}
|
||||
)} */}
|
||||
{/* </span> */}{' '}
|
||||
{size !== 'l' &&
|
||||
(uri ? (
|
||||
<a href={uri} target="_blank" class="time">
|
||||
|
@ -271,6 +272,26 @@ function Status({
|
|||
</span>
|
||||
))}
|
||||
</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
|
||||
class={`content-container ${
|
||||
sensitive || spoilerText ? 'has-spoiler' : ''
|
||||
|
@ -333,9 +354,12 @@ function Status({
|
|||
).innerText
|
||||
.trim()
|
||||
.replace(/^@/, '');
|
||||
const url = target.getAttribute('href');
|
||||
const mention = mentions.find(
|
||||
(mention) =>
|
||||
mention.username === username || mention.acct === username,
|
||||
mention.username === username ||
|
||||
mention.acct === username ||
|
||||
mention.url === url,
|
||||
);
|
||||
if (mention) {
|
||||
states.showAccount = mention.acct;
|
||||
|
@ -387,7 +411,11 @@ function Status({
|
|||
</button>
|
||||
)}
|
||||
{!!mediaAttachments.length && (
|
||||
<div class="media-container">
|
||||
<div
|
||||
class={`media-container ${
|
||||
mediaAttachments.length > 2 ? 'media-gt2' : ''
|
||||
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
|
||||
>
|
||||
{mediaAttachments.map((media, i) => (
|
||||
<Media
|
||||
media={media}
|
||||
|
@ -704,7 +732,9 @@ function Media({ media, showOriginal, onClick = () => {} }) {
|
|||
);
|
||||
} else if (type === 'gifv' || type === 'video') {
|
||||
// 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 (
|
||||
<div
|
||||
class={`media media-${isGIF ? 'gif' : 'video'}`}
|
||||
|
@ -713,30 +743,35 @@ function Media({ media, showOriginal, onClick = () => {} }) {
|
|||
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (isGIF) {
|
||||
if (showOriginal && isGIF) {
|
||||
try {
|
||||
videoRef.current?.pause();
|
||||
if (videoRef.current.paused) {
|
||||
videoRef.current.play();
|
||||
} else {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
onClick(e);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (isGIF) {
|
||||
if (!showOriginal && isGIF) {
|
||||
try {
|
||||
videoRef.current?.play();
|
||||
videoRef.current.play();
|
||||
} catch (e) {}
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (isGIF) {
|
||||
if (!showOriginal && isGIF) {
|
||||
try {
|
||||
videoRef.current?.pause();
|
||||
videoRef.current.pause();
|
||||
} catch (e) {}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showOriginal ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={url}
|
||||
poster={previewUrl}
|
||||
width={width}
|
||||
|
@ -746,14 +781,7 @@ function Media({ media, showOriginal, onClick = () => {} }) {
|
|||
muted={isGIF}
|
||||
controls={!isGIF}
|
||||
playsinline
|
||||
loop
|
||||
onClick={() => {
|
||||
if (isGIF) {
|
||||
try {
|
||||
videoRef.current?.play();
|
||||
} catch (e) {}
|
||||
}
|
||||
}}
|
||||
loop={loopable}
|
||||
></video>
|
||||
) : isGIF ? (
|
||||
<video
|
||||
|
@ -898,6 +926,41 @@ function Poll({ poll, readOnly, onUpdate = () => {} }) {
|
|||
|
||||
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 (
|
||||
<div
|
||||
class={`poll ${readOnly ? 'read-only' : ''} ${
|
||||
|
@ -907,9 +970,10 @@ function Poll({ poll, readOnly, onUpdate = () => {} }) {
|
|||
{voted || expired ? (
|
||||
options.map((option, i) => {
|
||||
const { title, votesCount: optionVotesCount } = option;
|
||||
const pollVotesCount = votersCount || votesCount;
|
||||
const percentage =
|
||||
Math.round((optionVotesCount / pollVotesCount) * 100) || 0;
|
||||
((optionVotesCount / pollVotesCount) * 100).toFixed(
|
||||
roundPrecision,
|
||||
) || 0;
|
||||
// check if current poll choice is the leading one
|
||||
const isLeading =
|
||||
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 {
|
||||
text-size-adjust: none;
|
||||
|
||||
--blue-color: royalblue;
|
||||
--purple-color: blueviolet;
|
||||
--green-color: green;
|
||||
--orange-color: orange;
|
||||
--orange-color: darkorange;
|
||||
--red-color: orangered;
|
||||
--bg-color: #fff;
|
||||
--bg-faded-color: #f0f2f5;
|
||||
|
@ -39,6 +41,7 @@
|
|||
--blue-color: CornflowerBlue;
|
||||
--purple-color: mediumpurple;
|
||||
--green-color: limegreen;
|
||||
--orange-color: orange;
|
||||
--bg-color: #242526;
|
||||
--bg-faded-color: #18191a;
|
||||
--bg-blur-color: #0009;
|
||||
|
@ -150,6 +153,9 @@ button,
|
|||
color: var(--text-color);
|
||||
border: 1px solid var(--outline-color);
|
||||
}
|
||||
:is(button, .button).light.danger:not(:disabled, .disabled) {
|
||||
color: var(--red-color);
|
||||
}
|
||||
|
||||
:is(button, .button).block {
|
||||
display: block;
|
||||
|
|
|
@ -7,7 +7,6 @@ import Icon from '../components/icon';
|
|||
import Loader from '../components/loader';
|
||||
import Status from '../components/status';
|
||||
import states from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
|
@ -70,46 +69,6 @@ function Home({ hidden }) {
|
|||
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();
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
.notification {
|
||||
display: flex;
|
||||
padding: 16px !important;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
.only-mentions .notification:not(.mention),
|
||||
.only-mentions .timeline-empty {
|
||||
|
@ -58,9 +58,11 @@
|
|||
|
||||
.notification-content {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.notification-content p:first-child {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#mentions-option {
|
||||
|
|
|
@ -292,7 +292,11 @@ function Notifications() {
|
|||
return (
|
||||
<div class="deck-container" ref={scrollableRef}>
|
||||
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
|
||||
<header>
|
||||
<header
|
||||
onClick={() => {
|
||||
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
>
|
||||
<div class="header-side">
|
||||
<a href="#" class="button plain">
|
||||
<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 { Link } from 'preact-router/match';
|
||||
import {
|
||||
|
@ -7,16 +9,22 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Icon from '../components/icon';
|
||||
import Loader from '../components/loader';
|
||||
import NameText from '../components/name-text';
|
||||
import Status from '../components/status';
|
||||
import htmlContentLength from '../utils/html-content-length';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import states from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
const LIMIT = 40;
|
||||
|
||||
function StatusPage({ id }) {
|
||||
const snapStates = useSnapshot(states);
|
||||
const [statuses, setStatuses] = useState([]);
|
||||
|
@ -43,32 +51,34 @@ function StatusPage({ id }) {
|
|||
useEffect(() => {
|
||||
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 }]);
|
||||
const cachedStatuses = store.session.getJSON('statuses-' + id);
|
||||
if (cachedStatuses) {
|
||||
// Case 1: It's cached, let's restore them to make it snappy
|
||||
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 {
|
||||
const cachedStatuses = store.session.getJSON('statuses-' + id);
|
||||
if (cachedStatuses) {
|
||||
// Case 2: Looks like we've cached this status before, let's restore them to make it snappy
|
||||
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 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);
|
||||
setStatuses(slicedStatuses);
|
||||
} else {
|
||||
// Case 3: Not cached and not in statuses, let's start from scratch
|
||||
setStatuses([{ id }]);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const heroFetch = masto.statuses.fetch(id);
|
||||
const contextFetch = masto.statuses.fetchContext(id);
|
||||
|
||||
const hasStatus = snapStates.statuses.has(id);
|
||||
let heroStatus = snapStates.statuses.get(id);
|
||||
try {
|
||||
heroStatus = await masto.statuses.fetch(id);
|
||||
heroStatus = await heroFetch;
|
||||
states.statuses.set(id, heroStatus);
|
||||
} catch (e) {
|
||||
// Silent fail if status is cached
|
||||
|
@ -80,7 +90,7 @@ function StatusPage({ id }) {
|
|||
}
|
||||
|
||||
try {
|
||||
const context = await masto.statuses.fetchContext(id);
|
||||
const context = await contextFetch;
|
||||
const { ancestors, descendants } = context;
|
||||
|
||||
ancestors.forEach((status) => {
|
||||
|
@ -124,7 +134,11 @@ function StatusPage({ id }) {
|
|||
accountID: s.account.id,
|
||||
descendant: true,
|
||||
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]);
|
||||
|
||||
const firstLoad = useRef(true);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!statuses.length) return;
|
||||
const isLoading = uiState === 'loading';
|
||||
|
@ -149,12 +165,18 @@ function StatusPage({ id }) {
|
|||
console.log('Case 1');
|
||||
heroStatusRef.current?.scrollIntoView();
|
||||
} else if (isLoading && statuses.length > 1) {
|
||||
// Case 2: User initiated, while statuses are loading, SMOOTH-SCROLL to hero status
|
||||
console.log('Case 2');
|
||||
heroStatusRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
if (firstLoad.current) {
|
||||
// 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({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const scrollPosition = states.scrollPositions.get(id);
|
||||
|
@ -168,12 +190,14 @@ function StatusPage({ id }) {
|
|||
isLoading,
|
||||
userInitiated: userInitiated.current,
|
||||
statusesLength: statuses.length,
|
||||
firstLoad: firstLoad.current,
|
||||
// scrollPosition,
|
||||
});
|
||||
|
||||
if (!isLoading) {
|
||||
// Reset user initiated flag after statuses are loaded
|
||||
userInitiated.current = false;
|
||||
firstLoad.current = false;
|
||||
}
|
||||
}, [statuses, uiState]);
|
||||
|
||||
|
@ -215,15 +239,18 @@ function StatusPage({ id }) {
|
|||
});
|
||||
const closeLink = `#${prevRoute || '/'}`;
|
||||
|
||||
const [limit, setLimit] = useState(40);
|
||||
const [limit, setLimit] = useState(LIMIT);
|
||||
const showMore = useMemo(() => {
|
||||
// return number of statuses to show
|
||||
return statuses.length - limit;
|
||||
}, [statuses.length, limit]);
|
||||
|
||||
const hasManyStatuses = statuses.length > 40;
|
||||
const hasManyStatuses = statuses.length > LIMIT;
|
||||
const hasDescendants = statuses.some((s) => s.descendant);
|
||||
|
||||
const [heroInView, setHeroInView] = useState(true);
|
||||
const onView = useDebouncedCallback(setHeroInView, 100);
|
||||
|
||||
return (
|
||||
<div class="deck-backdrop">
|
||||
<Link href={closeLink}></Link>
|
||||
|
@ -233,13 +260,43 @@ function StatusPage({ id }) {
|
|||
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>
|
||||
<Link class="button plain deck-close" href={closeLink}>
|
||||
<Icon icon="chevron-left" size="xl" />
|
||||
</Link>
|
||||
</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">
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
<Link class="button plain deck-close" href={closeLink}>
|
||||
|
@ -270,7 +327,9 @@ function StatusPage({ id }) {
|
|||
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
|
||||
>
|
||||
{isHero ? (
|
||||
<Status statusID={statusID} withinContext size="l" />
|
||||
<InView threshold={0.5} onChange={onView}>
|
||||
<Status statusID={statusID} withinContext size="l" />
|
||||
</InView>
|
||||
) : (
|
||||
<Link
|
||||
class="
|
||||
|
@ -286,33 +345,27 @@ function StatusPage({ id }) {
|
|||
withinContext
|
||||
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>
|
||||
)}
|
||||
{descendant && replies?.length > 0 && (
|
||||
<details class="replies" open={!hasManyStatuses}>
|
||||
<summary hidden={!hasManyStatuses}>
|
||||
<span title={replies.length}>
|
||||
{shortenNumber(replies.length)}
|
||||
</span>{' '}
|
||||
repl{replies.length === 1 ? 'y' : 'ies'}
|
||||
</summary>
|
||||
<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>
|
||||
)}
|
||||
{descendant &&
|
||||
replies?.length > 0 &&
|
||||
replies?.length <= LIMIT && (
|
||||
<SubComments
|
||||
hasManyStatuses={hasManyStatuses}
|
||||
replies={replies}
|
||||
onStatusLinkClick={() => {
|
||||
userInitiated.current = true;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{uiState === 'loading' &&
|
||||
isHero &&
|
||||
!!heroStatus?.repliesCount &&
|
||||
|
@ -330,11 +383,13 @@ function StatusPage({ id }) {
|
|||
type="button"
|
||||
class="plain block"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => setLimit((l) => l + 40)}
|
||||
onClick={() => setLimit((l) => l + LIMIT)}
|
||||
style={{ marginBlockEnd: '6em' }}
|
||||
>
|
||||
Show more…{' '}
|
||||
<span class="tag">{showMore > 40 ? '40+' : showMore}</span>
|
||||
<span class="tag">
|
||||
{showMore > LIMIT ? `${LIMIT}+` : showMore}
|
||||
</span>
|
||||
</button>
|
||||
</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;
|
||||
|
|
|
@ -29,20 +29,6 @@ function enhanceContent(content, opts = {}) {
|
|||
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
|
||||
// ===========
|
||||
// Convert ```code``` to <pre><code>code</code></pre>
|
||||
|
@ -57,6 +43,39 @@ function enhanceContent(content, opts = {}) {
|
|||
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) {
|
||||
postEnhanceDOM(dom); // mutate dom
|
||||
}
|
||||
|
@ -66,12 +85,60 @@ function enhanceContent(content, opts = {}) {
|
|||
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 walk = document.createTreeWalker(
|
||||
dom,
|
||||
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,
|
||||
);
|
||||
let node;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
export default {
|
||||
public: 'earth',
|
||||
unlisted: 'unlock',
|
||||
unlisted: 'group',
|
||||
private: 'lock',
|
||||
direct: 'message',
|
||||
};
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import preact from '@preact/preset-vite';
|
||||
import { execSync } from 'child_process';
|
||||
import { resolve } from 'path';
|
||||
import { defineConfig, splitVendorChunkPlugin } from 'vite';
|
||||
import { defineConfig, loadEnv, splitVendorChunkPlugin } from 'vite';
|
||||
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();
|
||||
|
||||
|
|
Loading…
Reference in a new issue