commit
2dc1343f54
|
@ -74,6 +74,15 @@ Everything is designed and engineered following my taste and vision. This is a p
|
|||
- Limit up to 3 API requests as the root post may be very old or the thread is super long.
|
||||
- If index number couldn't be found, badge will fallback to showing `Thread` without the number.
|
||||
|
||||
### Filtered posts
|
||||
|
||||
- "Hide completely"-filtered posts will be hidden, with no UI to reveal it.
|
||||
- "Hide with a warning"-filtered posts will be partially hidden, showing the filter name and author name.
|
||||
- Content can be partially revealed by hovering over the post, with tooltip showing the post text.
|
||||
- Clicking it will open the Post page.
|
||||
- Long-pressing or right-clicking it will "peek" the post with a bottom sheet UI.
|
||||
- On boosts carousel, they are not partially hidden, but sorted to the end of the carousel.
|
||||
|
||||
## Development
|
||||
|
||||
Prerequisites: Node.js 18+
|
||||
|
|
37
design/logo-text.svg
Normal file
37
design/logo-text.svg
Normal file
|
@ -0,0 +1,37 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 102 28">
|
||||
<path fill="none" d="M0 0h101.5v27.5H0z"/>
|
||||
<g fill-rule="nonzero">
|
||||
<path fill="url(#a)" d="M2.32 21.85c1.4 0 2.21-.85 2.21-2.3v-4.64H8.5c4.45 0 7.54-2.9 7.54-7.24 0-4.35-2.98-7.24-7.32-7.24h-6.4C.93.43.11 1.28.11 2.73v16.82c0 1.45.82 2.3 2.21 2.3Zm2.21-10.4V3.94h3c2.54 0 4 1.34 4 3.75s-1.47 3.76-4 3.76h-3Z"/>
|
||||
<path fill="url(#b)" d="M20.52 21.88c1.25 0 2.13-.76 2.13-2.23v-7.04c0-2.07 1.2-3.49 3.21-3.49 1.95 0 2.95 1.23 2.95 3.25v7.28c0 1.47.89 2.23 2.13 2.23 1.26 0 2.14-.76 2.14-2.23v-8.18c0-3.64-1.99-5.9-5.48-5.9-2.38 0-4.1 1.12-4.93 3.1h-.09V2.3c0-1.38-.78-2.2-2.1-2.2-1.31 0-2.1.82-2.1 2.2v17.34c0 1.47.9 2.23 2.14 2.23Z"/>
|
||||
<path fill="url(#c)" d="M40.45 21.82c1.96 0 3.93-.98 4.8-2.65h.1v.8c.08 1.27.89 1.91 2.05 1.91 1.21 0 2.08-.73 2.08-2.15v-8.95c0-3.17-2.63-5.25-6.65-5.25-3.26 0-5.78 1.16-6.5 3.04-.15.32-.23.63-.23.96 0 .97.75 1.64 1.79 1.64.69 0 1.23-.26 1.7-.79.95-1.23 1.74-1.65 3.04-1.65 1.62 0 2.64.85 2.64 2.31v1.04l-3.95.24c-3.93.23-6.13 1.88-6.13 4.74 0 2.83 2.27 4.76 5.26 4.76Zm1.4-3.09c-1.43 0-2.4-.73-2.4-1.9 0-1.12.91-1.83 2.51-1.95l3.31-.2v1.14c0 1.7-1.54 2.91-3.41 2.91Z"/>
|
||||
<path fill="url(#d)" d="M54.37 21.88c1.26 0 2.14-.76 2.14-2.23v-7.09c0-2.03 1.21-3.44 3.13-3.44s2.89 1.17 2.89 3.22v7.31c0 1.47.88 2.23 2.14 2.23 1.24 0 2.13-.76 2.13-2.23v-8.2c0-3.68-1.96-5.87-5.45-5.87-2.41 0-4 1.07-4.83 3.01h-.09v-.87c0-1.35-.85-2.17-2.14-2.17-1.28 0-2.06.82-2.06 2.15v11.95c0 1.47.9 2.23 2.14 2.23Z"/>
|
||||
<path fill="url(#e)" d="M71.65 27.17c1.26 0 2.14-.76 2.14-2.23v-6h.09a5.15 5.15 0 0 0 4.88 2.88c3.92 0 6.35-3.05 6.35-8.1 0-5.07-2.44-8.1-6.43-8.1a5.12 5.12 0 0 0-4.86 2.99h-.09v-.85c0-1.45-.88-2.21-2.1-2.21-1.24 0-2.11.76-2.11 2.2v17.2c0 1.46.89 2.22 2.13 2.22Zm5.6-8.8c-2.1 0-3.47-1.8-3.47-4.65 0-2.81 1.37-4.67 3.47-4.67 2.14 0 3.49 1.83 3.49 4.67 0 2.86-1.35 4.66-3.5 4.66Z"/>
|
||||
<path fill="url(#f)" d="M89.61 27.39c3.44 0 5.26-1.5 6.73-5.55l4.81-13.1a4 4 0 0 0 .24-1.26c0-1.13-.85-1.93-2.08-1.93-1.1 0-1.71.51-2.07 1.7l-3.4 10.9h-.08L90.35 7.28c-.36-1.25-.94-1.73-2.07-1.73-1.26 0-2.21.83-2.21 1.99 0 .35.09.82.25 1.26l5 13.21-.21.56c-.52 1.1-1.32 1.42-2.07 1.42l-.75-.01c-.96 0-1.56.54-1.56 1.4 0 1.29 1 2 2.88 2Z"/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="rotate(28.51 .06 .22) scale(57.6252)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a4bff7"/>
|
||||
<stop offset="1" stop-color="#6081e6"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="b" cx="0" cy="0" r="1" gradientTransform="rotate(28.51 .06 .22) scale(57.6252)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a4bff7"/>
|
||||
<stop offset="1" stop-color="#6081e6"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="c" cx="0" cy="0" r="1" gradientTransform="rotate(28.51 .06 .22) scale(57.6252)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a4bff7"/>
|
||||
<stop offset="1" stop-color="#6081e6"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="d" cx="0" cy="0" r="1" gradientTransform="rotate(28.51 .06 .22) scale(57.6252)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a4bff7"/>
|
||||
<stop offset="1" stop-color="#6081e6"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="e" cx="0" cy="0" r="1" gradientTransform="rotate(28.51 .06 .22) scale(57.6252)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a4bff7"/>
|
||||
<stop offset="1" stop-color="#6081e6"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="f" cx="0" cy="0" r="1" gradientTransform="rotate(28.51 .06 .22) scale(57.6252)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a4bff7"/>
|
||||
<stop offset="1" stop-color="#6081e6"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
@ -39,7 +39,7 @@
|
|||
property="og:description"
|
||||
content="Minimalistic opinionated Mastodon web client"
|
||||
/>
|
||||
<meta property="og:image" content="%VITE_WEBSITE%/og-image.png" />
|
||||
<meta property="og:image" content="%VITE_WEBSITE%/og-image-2.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
3360
package-lock.json
generated
3360
package-lock.json
generated
File diff suppressed because it is too large
Load diff
31
package.json
31
package.json
|
@ -10,12 +10,13 @@
|
|||
"sourcemap": "npx source-map-explorer dist/assets/*.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "~0.2.32",
|
||||
"@formatjs/intl-localematcher": "~0.4.0",
|
||||
"@github/text-expander-element": "~2.3.0",
|
||||
"@iconify-icons/mingcute": "~1.2.5",
|
||||
"@justinribeiro/lite-youtube": "~1.5.0",
|
||||
"@szhsin/react-menu": "~3.5.3",
|
||||
"dayjs": "~1.11.7",
|
||||
"@szhsin/react-menu": "~4.0.0",
|
||||
"@uidotdev/usehooks": "~2.0.1",
|
||||
"dayjs": "~1.11.8",
|
||||
"dayjs-twitter": "~0.5.0",
|
||||
"fast-blurhash": "~1.1.2",
|
||||
"fast-deep-equal": "~3.1.3",
|
||||
|
@ -25,36 +26,36 @@
|
|||
"mem": "~9.0.2",
|
||||
"p-retry": "~5.1.2",
|
||||
"p-throttle": "~5.1.0",
|
||||
"preact": "~10.15.0",
|
||||
"preact": "~10.15.1",
|
||||
"react-hotkeys-hook": "~4.4.0",
|
||||
"react-intersection-observer": "~9.4.3",
|
||||
"react-intersection-observer": "~9.4.4",
|
||||
"react-quick-pinch-zoom": "~4.9.0",
|
||||
"react-router-dom": "6.6.2",
|
||||
"string-length": "~5.0.1",
|
||||
"string-length": "5.0.1",
|
||||
"swiped-events": "~1.1.7",
|
||||
"toastify-js": "~1.12.0",
|
||||
"uid": "~2.0.2",
|
||||
"use-debounce": "~9.0.4",
|
||||
"use-long-press": "~3.1.3",
|
||||
"use-long-press": "~3.1.5",
|
||||
"use-resize-observer": "~9.1.0",
|
||||
"valtio": "1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "~2.5.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "~4.1.1",
|
||||
"postcss": "~8.4.23",
|
||||
"postcss": "~8.4.24",
|
||||
"postcss-dark-theme-class": "~0.7.3",
|
||||
"postcss-preset-env": "~8.4.1",
|
||||
"postcss-preset-env": "~8.4.2",
|
||||
"twitter-text": "~3.1.0",
|
||||
"vite": "~4.3.8",
|
||||
"vite": "~4.3.9",
|
||||
"vite-plugin-generate-file": "~0.0.4",
|
||||
"vite-plugin-html-config": "~1.0.11",
|
||||
"vite-plugin-pwa": "~0.15.0",
|
||||
"vite-plugin-pwa": "~0.16.4",
|
||||
"vite-plugin-remove-console": "~2.1.1",
|
||||
"workbox-cacheable-response": "~6.5.4",
|
||||
"workbox-expiration": "~6.5.4",
|
||||
"workbox-routing": "~6.5.4",
|
||||
"workbox-strategies": "~6.5.4"
|
||||
"workbox-cacheable-response": "~7.0.0",
|
||||
"workbox-expiration": "~7.0.0",
|
||||
"workbox-routing": "~7.0.0",
|
||||
"workbox-strategies": "~7.0.0"
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
|
|
BIN
public/og-image-2.jpg
Normal file
BIN
public/og-image-2.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 49 KiB |
|
@ -4,7 +4,7 @@ const { INSTANCES_SOCIAL_SECRET_TOKEN } = process.env;
|
|||
|
||||
const params = new URLSearchParams({
|
||||
count: 0,
|
||||
min_users: 1_000,
|
||||
min_users: 500,
|
||||
sort_by: 'active_users',
|
||||
sort_order: 'desc',
|
||||
});
|
||||
|
|
10
src/app.css
10
src/app.css
|
@ -641,6 +641,16 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
.status-reply-to:not(.visibility-direct):not(.status-card) {
|
||||
background-image: none;
|
||||
}
|
||||
.timeline:not(.flat)
|
||||
> li.timeline-item-diff-author
|
||||
> .status-link
|
||||
> .status
|
||||
> a
|
||||
> .avatar {
|
||||
transform: scale(0.8);
|
||||
filter: drop-shadow(0 0 16px var(--bg-color))
|
||||
drop-shadow(0 0 8px var(--bg-color)) drop-shadow(0 0 8px var(--bg-color));
|
||||
}
|
||||
|
||||
.timeline .show-more {
|
||||
padding-left: calc(var(--line-end) + var(--line-margin-end)) !important;
|
||||
|
|
37
src/assets/logo-text.svg
Normal file
37
src/assets/logo-text.svg
Normal file
|
@ -0,0 +1,37 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 102 28">
|
||||
<path fill="none" d="M0 0h101.5v27.5H0z"/>
|
||||
<g fill-rule="nonzero">
|
||||
<path fill="url(#a)" d="M2.32 21.85c1.4 0 2.21-.85 2.21-2.3v-4.64H8.5c4.45 0 7.54-2.9 7.54-7.24 0-4.35-2.98-7.24-7.32-7.24h-6.4C.93.43.11 1.28.11 2.73v16.82c0 1.45.82 2.3 2.21 2.3Zm2.21-10.4V3.94h3c2.54 0 4 1.34 4 3.75s-1.47 3.76-4 3.76h-3Z"/>
|
||||
<path fill="url(#b)" d="M20.52 21.88c1.25 0 2.13-.76 2.13-2.23v-7.04c0-2.07 1.2-3.49 3.21-3.49 1.95 0 2.95 1.23 2.95 3.25v7.28c0 1.47.89 2.23 2.13 2.23 1.26 0 2.14-.76 2.14-2.23v-8.18c0-3.64-1.99-5.9-5.48-5.9-2.38 0-4.1 1.12-4.93 3.1h-.09V2.3c0-1.38-.78-2.2-2.1-2.2-1.31 0-2.1.82-2.1 2.2v17.34c0 1.47.9 2.23 2.14 2.23Z"/>
|
||||
<path fill="url(#c)" d="M40.45 21.82c1.96 0 3.93-.98 4.8-2.65h.1v.8c.08 1.27.89 1.91 2.05 1.91 1.21 0 2.08-.73 2.08-2.15v-8.95c0-3.17-2.63-5.25-6.65-5.25-3.26 0-5.78 1.16-6.5 3.04-.15.32-.23.63-.23.96 0 .97.75 1.64 1.79 1.64.69 0 1.23-.26 1.7-.79.95-1.23 1.74-1.65 3.04-1.65 1.62 0 2.64.85 2.64 2.31v1.04l-3.95.24c-3.93.23-6.13 1.88-6.13 4.74 0 2.83 2.27 4.76 5.26 4.76Zm1.4-3.09c-1.43 0-2.4-.73-2.4-1.9 0-1.12.91-1.83 2.51-1.95l3.31-.2v1.14c0 1.7-1.54 2.91-3.41 2.91Z"/>
|
||||
<path fill="url(#d)" d="M54.37 21.88c1.26 0 2.14-.76 2.14-2.23v-7.09c0-2.03 1.21-3.44 3.13-3.44s2.89 1.17 2.89 3.22v7.31c0 1.47.88 2.23 2.14 2.23 1.24 0 2.13-.76 2.13-2.23v-8.2c0-3.68-1.96-5.87-5.45-5.87-2.41 0-4 1.07-4.83 3.01h-.09v-.87c0-1.35-.85-2.17-2.14-2.17-1.28 0-2.06.82-2.06 2.15v11.95c0 1.47.9 2.23 2.14 2.23Z"/>
|
||||
<path fill="url(#e)" d="M71.65 27.17c1.26 0 2.14-.76 2.14-2.23v-6h.09a5.15 5.15 0 0 0 4.88 2.88c3.92 0 6.35-3.05 6.35-8.1 0-5.07-2.44-8.1-6.43-8.1a5.12 5.12 0 0 0-4.86 2.99h-.09v-.85c0-1.45-.88-2.21-2.1-2.21-1.24 0-2.11.76-2.11 2.2v17.2c0 1.46.89 2.22 2.13 2.22Zm5.6-8.8c-2.1 0-3.47-1.8-3.47-4.65 0-2.81 1.37-4.67 3.47-4.67 2.14 0 3.49 1.83 3.49 4.67 0 2.86-1.35 4.66-3.5 4.66Z"/>
|
||||
<path fill="url(#f)" d="M89.61 27.39c3.44 0 5.26-1.5 6.73-5.55l4.81-13.1a4 4 0 0 0 .24-1.26c0-1.13-.85-1.93-2.08-1.93-1.1 0-1.71.51-2.07 1.7l-3.4 10.9h-.08L90.35 7.28c-.36-1.25-.94-1.73-2.07-1.73-1.26 0-2.21.83-2.21 1.99 0 .35.09.82.25 1.26l5 13.21-.21.56c-.52 1.1-1.32 1.42-2.07 1.42l-.75-.01c-.96 0-1.56.54-1.56 1.4 0 1.29 1 2 2.88 2Z"/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="rotate(28.51 .06 .22) scale(57.6252)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a4bff7"/>
|
||||
<stop offset="1" stop-color="#6081e6"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="b" cx="0" cy="0" r="1" gradientTransform="rotate(28.51 .06 .22) scale(57.6252)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a4bff7"/>
|
||||
<stop offset="1" stop-color="#6081e6"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="c" cx="0" cy="0" r="1" gradientTransform="rotate(28.51 .06 .22) scale(57.6252)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a4bff7"/>
|
||||
<stop offset="1" stop-color="#6081e6"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="d" cx="0" cy="0" r="1" gradientTransform="rotate(28.51 .06 .22) scale(57.6252)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a4bff7"/>
|
||||
<stop offset="1" stop-color="#6081e6"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="e" cx="0" cy="0" r="1" gradientTransform="rotate(28.51 .06 .22) scale(57.6252)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a4bff7"/>
|
||||
<stop offset="1" stop-color="#6081e6"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="f" cx="0" cy="0" r="1" gradientTransform="rotate(28.51 .06 .22) scale(57.6252)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a4bff7"/>
|
||||
<stop offset="1" stop-color="#6081e6"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 3.8 KiB |
|
@ -1,3 +1,7 @@
|
|||
body.cloak a {
|
||||
text-decoration-color: var(--link-color);
|
||||
}
|
||||
|
||||
body.cloak .name-text,
|
||||
body.cloak .name-text *,
|
||||
body.cloak .status .content-container,
|
||||
|
@ -25,3 +29,11 @@ body.cloak .header-banner {
|
|||
filter: contrast(0) !important;
|
||||
background-color: #000 !important;
|
||||
}
|
||||
|
||||
/* SPECIAL CASES */
|
||||
|
||||
@supports (display: -webkit-box) {
|
||||
body.cloak .card :is(.title, .meta) {
|
||||
background-color: var(--text-color) !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,11 +2,11 @@ import './account-block.css';
|
|||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import emojifyText from '../utils/emojify-text';
|
||||
import niceDateTime from '../utils/nice-date-time';
|
||||
import states from '../utils/states';
|
||||
|
||||
import Avatar from './avatar';
|
||||
import EmojiText from './emoji-text';
|
||||
|
||||
function AccountBlock({
|
||||
skeleton,
|
||||
|
@ -46,7 +46,6 @@ function AccountBlock({
|
|||
lastStatusAt,
|
||||
bot,
|
||||
} = account;
|
||||
const displayNameWithEmoji = emojifyText(displayName, emojis);
|
||||
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
|
||||
|
||||
return (
|
||||
|
@ -72,11 +71,9 @@ function AccountBlock({
|
|||
<Avatar url={avatar} size={avatarSize} squircle={bot} />
|
||||
<span>
|
||||
{displayName ? (
|
||||
<b
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: displayNameWithEmoji,
|
||||
}}
|
||||
/>
|
||||
<b>
|
||||
<EmojiText text={displayName} emojis={emojis} />
|
||||
</b>
|
||||
) : (
|
||||
<b>{username}</b>
|
||||
)}
|
||||
|
|
|
@ -38,6 +38,11 @@
|
|||
margin-bottom: -44px;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
.account-container .header-banner.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
.sheet .account-container .header-banner {
|
||||
border-top-left-radius: 16px;
|
||||
|
|
|
@ -4,7 +4,6 @@ import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu';
|
|||
import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import emojifyText from '../utils/emojify-text';
|
||||
import enhanceContent from '../utils/enhance-content';
|
||||
import getHTMLText from '../utils/getHTMLText';
|
||||
import handleContentLinks from '../utils/handle-content-links';
|
||||
|
@ -16,6 +15,7 @@ import store from '../utils/store';
|
|||
|
||||
import AccountBlock from './account-block';
|
||||
import Avatar from './avatar';
|
||||
import EmojiText from './emoji-text';
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import ListAddEdit from './list-add-edit';
|
||||
|
@ -186,6 +186,7 @@ function AccountInfo({
|
|||
}}
|
||||
crossOrigin="anonymous"
|
||||
onLoad={(e) => {
|
||||
e.target.classList.add('loaded');
|
||||
try {
|
||||
// Get color from four corners of image
|
||||
const canvas = document.createElement('canvas');
|
||||
|
@ -275,6 +276,13 @@ function AccountInfo({
|
|||
</span>
|
||||
</>
|
||||
)}
|
||||
{group && (
|
||||
<>
|
||||
<span class="tag">
|
||||
<Icon icon="group" /> Group
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
class="note"
|
||||
onClick={handleContentLinks({
|
||||
|
@ -294,11 +302,7 @@ function AccountInfo({
|
|||
key={name}
|
||||
>
|
||||
<b>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: emojifyText(name, emojis),
|
||||
}}
|
||||
/>{' '}
|
||||
<EmojiText text={name} emojis={emojis} />{' '}
|
||||
{!!verifiedAt && <Icon icon="check-circle" size="s" />}
|
||||
</b>
|
||||
<p
|
||||
|
@ -673,7 +677,7 @@ function RelatedActions({ info, instance, authenticated }) {
|
|||
openTrigger="clickOnly"
|
||||
direction="bottom"
|
||||
overflow="auto"
|
||||
offsetX={-16}
|
||||
shift={16}
|
||||
label={
|
||||
<>
|
||||
<Icon icon="mute" />
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
border-radius: 0;
|
||||
}
|
||||
.avatar:not(.has-alpha).squircle {
|
||||
border-radius: 12px;
|
||||
border-radius: 25%;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
|
|
|
@ -13,6 +13,11 @@ const SIZES = {
|
|||
|
||||
const alphaCache = {};
|
||||
|
||||
const canvas = window.OffscreenCanvas
|
||||
? new OffscreenCanvas(1, 1)
|
||||
: document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
function Avatar({ url, size, alt = '', squircle, ...props }) {
|
||||
size = SIZES[size] || size || SIZES.m;
|
||||
const avatarRef = useRef();
|
||||
|
@ -37,6 +42,7 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
|
|||
height={size}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
crossOrigin={
|
||||
alphaCache[url] === undefined && !isMissing
|
||||
? 'anonymous'
|
||||
|
@ -54,17 +60,11 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
|
|||
if (isMissing) return;
|
||||
try {
|
||||
// Check if image has alpha channel
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = e.target.width;
|
||||
canvas.height = e.target.height;
|
||||
const { width, height } = e.target;
|
||||
if (canvas.width !== width) canvas.width = width;
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
ctx.drawImage(e.target, 0, 0);
|
||||
const allPixels = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
const allPixels = ctx.getImageData(0, 0, width, height);
|
||||
// At least 10% of pixels have alpha <= 128
|
||||
const hasAlpha =
|
||||
allPixels.data.filter((pixel, i) => i % 4 === 3 && pixel <= 128)
|
||||
|
@ -76,6 +76,7 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
|
|||
avatarRef.current.classList.add('has-alpha');
|
||||
}
|
||||
alphaCache[url] = hasAlpha;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
alphaCache[url] = false;
|
||||
|
|
|
@ -1415,7 +1415,7 @@ function MediaAttachment({
|
|||
const suffixType = type.split('/')[0];
|
||||
const debouncedOnDescriptionChange = useDebouncedCallback(
|
||||
onDescriptionChange,
|
||||
500,
|
||||
250,
|
||||
);
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
|
42
src/components/emoji-text.jsx
Normal file
42
src/components/emoji-text.jsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
function EmojiText({ text, emojis }) {
|
||||
if (!text) return '';
|
||||
if (!emojis?.length) return text;
|
||||
if (text.indexOf(':') === -1) return text;
|
||||
|
||||
const components = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
emojis.forEach((shortcodeObj) => {
|
||||
const { shortcode, staticUrl, url } = shortcodeObj;
|
||||
const regex = new RegExp(`:${shortcode}:`, 'g');
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text))) {
|
||||
const beforeText = text.substring(lastIndex, match.index);
|
||||
if (beforeText) {
|
||||
components.push(beforeText);
|
||||
}
|
||||
components.push(
|
||||
<img
|
||||
src={url}
|
||||
alt={shortcode}
|
||||
class="shortcode-emoji emoji"
|
||||
width="12"
|
||||
height="12"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>,
|
||||
);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
});
|
||||
|
||||
const afterText = text.substring(lastIndex);
|
||||
if (afterText) {
|
||||
components.push(afterText);
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
export default EmojiText;
|
|
@ -191,7 +191,7 @@ function MediaModal({
|
|||
align="end"
|
||||
position="anchor"
|
||||
boundingBoxPadding="8 8 8 8"
|
||||
offsetY={4}
|
||||
gap={4}
|
||||
menuClassName="glass-menu"
|
||||
menuButton={
|
||||
<button type="button" class="carousel-button plain3">
|
||||
|
@ -219,14 +219,14 @@ function MediaModal({
|
|||
: ''
|
||||
}`}
|
||||
class="button carousel-button media-post-link plain3"
|
||||
onClick={() => {
|
||||
// if small screen (not media query min-width 40em + 350px), run onClose
|
||||
if (
|
||||
!window.matchMedia('(min-width: calc(40em + 350px))').matches
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
// onClick={() => {
|
||||
// // if small screen (not media query min-width 40em + 350px), run onClose
|
||||
// if (
|
||||
// !window.matchMedia('(min-width: calc(40em + 350px))').matches
|
||||
// ) {
|
||||
// onClose();
|
||||
// }
|
||||
// }}
|
||||
>
|
||||
<span class="button-label">See post </span>»
|
||||
</Link>
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||
import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
|
||||
|
||||
import Icon from './icon';
|
||||
|
@ -95,16 +101,33 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
[to],
|
||||
);
|
||||
|
||||
if (type === 'image' || (type === 'unknown' && previewUrl && url)) {
|
||||
const isImage = type === 'image' || (type === 'unknown' && previewUrl);
|
||||
|
||||
const parentRef = useRef();
|
||||
const [imageSmallerThanParent, setImageSmallerThanParent] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
if (!isImage) return;
|
||||
if (!showOriginal) return;
|
||||
if (!parentRef.current) return;
|
||||
const { offsetWidth, offsetHeight } = parentRef.current;
|
||||
const smaller = width < offsetWidth && height < offsetHeight;
|
||||
if (smaller) setImageSmallerThanParent(smaller);
|
||||
}, [width, height]);
|
||||
|
||||
if (isImage) {
|
||||
// Note: type: unknown might not have width/height
|
||||
quickPinchZoomProps.containerProps.style.display = 'inherit';
|
||||
return (
|
||||
<Parent
|
||||
ref={parentRef}
|
||||
class={`media media-image`}
|
||||
onClick={onClick}
|
||||
style={
|
||||
showOriginal && {
|
||||
backgroundImage: `url(${previewUrl})`,
|
||||
backgroundSize: imageSmallerThanParent
|
||||
? `${width}px ${height}px`
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
>
|
||||
|
@ -118,7 +141,7 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
height={height}
|
||||
data-orientation={orientation}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
decoding="sync"
|
||||
onLoad={(e) => {
|
||||
e.target.closest('.media-image').style.backgroundImage = '';
|
||||
e.target.closest('.media-zoom').style.display = '';
|
||||
|
|
31
src/components/menu2.jsx
Normal file
31
src/components/menu2.jsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { Menu } from '@szhsin/react-menu';
|
||||
import { useWindowSize } from '@uidotdev/usehooks';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
|
||||
|
||||
// It's like Menu but with sensible defaults, bug fixes and improvements.
|
||||
function Menu2(props) {
|
||||
const { containerProps } = props;
|
||||
const size = useWindowSize();
|
||||
const instanceRef = useRef();
|
||||
return (
|
||||
<Menu
|
||||
boundingBoxPadding={safeBoundingBoxPadding()}
|
||||
repositionFlag={`${size.width}x${size.height}`}
|
||||
{...props}
|
||||
instanceRef={instanceRef}
|
||||
containerProps={{
|
||||
onClick: (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
instanceRef.current?.closeMenu?.();
|
||||
}
|
||||
containerProps?.onClick?.(e);
|
||||
},
|
||||
...containerProps,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Menu2;
|
|
@ -1,9 +1,9 @@
|
|||
import './name-text.css';
|
||||
|
||||
import emojifyText from '../utils/emojify-text';
|
||||
import states from '../utils/states';
|
||||
|
||||
import Avatar from './avatar';
|
||||
import EmojiText from './emoji-text';
|
||||
|
||||
function NameText({
|
||||
account,
|
||||
|
@ -18,8 +18,6 @@ function NameText({
|
|||
account;
|
||||
let { username } = account;
|
||||
|
||||
const displayNameWithEmoji = emojifyText(displayName, emojis);
|
||||
|
||||
const trimmedUsername = username.toLowerCase().trim();
|
||||
const trimmedDisplayName = (displayName || '').toLowerCase().trim();
|
||||
const shortenedDisplayName = trimmedDisplayName
|
||||
|
@ -58,11 +56,9 @@ function NameText({
|
|||
)}
|
||||
{displayName && !short ? (
|
||||
<>
|
||||
<b
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: displayNameWithEmoji,
|
||||
}}
|
||||
/>
|
||||
<b>
|
||||
<EmojiText text={displayName} emojis={emojis} />
|
||||
</b>
|
||||
{!showAcct && username && (
|
||||
<>
|
||||
{' '}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import './nav-menu.css';
|
||||
|
||||
import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useLongPress } from 'use-long-press';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
|
@ -16,11 +16,18 @@ import MenuLink from './menu-link';
|
|||
function NavMenu(props) {
|
||||
const snapStates = useSnapshot(states);
|
||||
const { instance, authenticated } = api();
|
||||
const accounts = store.local.getJSON('accounts') || [];
|
||||
const currentAccount = accounts.find(
|
||||
(account) => account.info.id === store.session.get('currentAccount'),
|
||||
);
|
||||
const moreThanOneAccount = accounts.length > 1;
|
||||
|
||||
const [currentAccount, setCurrentAccount] = useState();
|
||||
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const accounts = store.local.getJSON('accounts') || [];
|
||||
const acc = accounts.find(
|
||||
(account) => account.info.id === store.session.get('currentAccount'),
|
||||
);
|
||||
if (acc) setCurrentAccount(acc);
|
||||
setMoreThanOneAccount(accounts.length > 1);
|
||||
}, []);
|
||||
|
||||
// Home = Following
|
||||
// But when in multi-column mode, Home becomes columns of anything
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import emojifyText from '../utils/emojify-text';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
|
||||
import EmojiText from './emoji-text';
|
||||
import Icon from './icon';
|
||||
import RelativeTime from './relative-time';
|
||||
|
||||
|
@ -112,11 +112,9 @@ export default function Poll({
|
|||
}}
|
||||
>
|
||||
<div class="poll-option-title">
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: emojifyText(title, emojis),
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<EmojiText text={title} emojis={emojis} />
|
||||
</span>
|
||||
{voted && ownVotes.includes(i) && (
|
||||
<>
|
||||
{' '}
|
||||
|
@ -179,12 +177,9 @@ export default function Poll({
|
|||
disabled={uiState === 'loading'}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<span
|
||||
class="poll-option-title"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: emojifyText(title, emojis),
|
||||
}}
|
||||
/>
|
||||
<span class="poll-option-title">
|
||||
<EmojiText text={title} emojis={emojis} />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -132,7 +132,7 @@ function Shortcuts() {
|
|||
viewScroll="close"
|
||||
boundingBoxPadding="8 8 8 8"
|
||||
menuClassName="glass-menu shortcuts-menu"
|
||||
offsetY={8}
|
||||
gap={8}
|
||||
position="anchor"
|
||||
menuButton={
|
||||
<button
|
||||
|
|
|
@ -520,6 +520,9 @@
|
|||
margin-inline: 0;
|
||||
padding-inline-start: 1.5em;
|
||||
}
|
||||
.status .content ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.status .content .invisible {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -12,7 +12,13 @@ import { decodeBlurHash } from 'fast-blurhash';
|
|||
import mem from 'mem';
|
||||
import pThrottle from 'p-throttle';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import { useLongPress } from 'use-long-press';
|
||||
import useResizeObserver from 'use-resize-observer';
|
||||
|
@ -20,12 +26,12 @@ import { useSnapshot } from 'valtio';
|
|||
import { snapshot } from 'valtio/vanilla';
|
||||
|
||||
import AccountBlock from '../components/account-block';
|
||||
import EmojiText from '../components/emoji-text';
|
||||
import Loader from '../components/loader';
|
||||
import Modal from '../components/modal';
|
||||
import NameText from '../components/name-text';
|
||||
import Poll from '../components/poll';
|
||||
import { api } from '../utils/api';
|
||||
import emojifyText from '../utils/emojify-text';
|
||||
import enhanceContent from '../utils/enhance-content';
|
||||
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
|
||||
import getHTMLText from '../utils/getHTMLText';
|
||||
|
@ -34,6 +40,7 @@ import htmlContentLength from '../utils/html-content-length';
|
|||
import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe';
|
||||
import localeMatch from '../utils/locale-match';
|
||||
import niceDateTime from '../utils/nice-date-time';
|
||||
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import showToast from '../utils/show-toast';
|
||||
import states, { getStatus, saveStatus, statusKey } from '../utils/states';
|
||||
|
@ -285,11 +292,15 @@ function Status({
|
|||
|
||||
const unauthInteractionErrorMessage = `Sorry, your current logged-in instance can't interact with this post from another instance.`;
|
||||
|
||||
const textWeight = () =>
|
||||
Math.max(
|
||||
Math.round((spoilerText.length + htmlContentLength(content)) / 140) || 1,
|
||||
1,
|
||||
);
|
||||
const textWeight = useCallback(
|
||||
() =>
|
||||
Math.max(
|
||||
Math.round((spoilerText.length + htmlContentLength(content)) / 140) ||
|
||||
1,
|
||||
1,
|
||||
),
|
||||
[spoilerText, content],
|
||||
);
|
||||
|
||||
const createdDateText = niceDateTime(createdAtDate);
|
||||
const editedDateText = editedAt && niceDateTime(editedAtDate);
|
||||
|
@ -824,7 +835,7 @@ function Status({
|
|||
},
|
||||
}}
|
||||
align="end"
|
||||
offsetY={4}
|
||||
gap={4}
|
||||
overflow="auto"
|
||||
viewScroll="close"
|
||||
boundingBoxPadding="8 8 8 8"
|
||||
|
@ -915,11 +926,9 @@ function Status({
|
|||
ref={spoilerContentRef}
|
||||
data-read-more={readMoreText}
|
||||
>
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: emojifyText(spoilerText, emojis),
|
||||
}}
|
||||
/>
|
||||
<p>
|
||||
<EmojiText text={spoilerText} emojis={emojis} />
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class={`light spoiler ${showSpoiler ? 'spoiling' : ''}`}
|
||||
|
@ -1182,7 +1191,7 @@ function Status({
|
|||
document.querySelector('.status-deck') || document.body,
|
||||
}}
|
||||
align="end"
|
||||
offsetY={4}
|
||||
gap={4}
|
||||
overflow="auto"
|
||||
viewScroll="close"
|
||||
boundingBoxPadding="8 8 8 8"
|
||||
|
@ -1817,27 +1826,6 @@ const unfurlMastodonLink = throttle(
|
|||
}),
|
||||
);
|
||||
|
||||
const root = document.documentElement;
|
||||
const defaultBoundingBoxPadding = 8;
|
||||
function safeBoundingBoxPadding() {
|
||||
// Get safe area inset variables from root
|
||||
const style = getComputedStyle(root);
|
||||
const safeAreaInsetTop = style.getPropertyValue('--sai-top');
|
||||
const safeAreaInsetRight = style.getPropertyValue('--sai-right');
|
||||
const safeAreaInsetBottom = style.getPropertyValue('--sai-bottom');
|
||||
const safeAreaInsetLeft = style.getPropertyValue('--sai-left');
|
||||
const str = [
|
||||
safeAreaInsetTop,
|
||||
safeAreaInsetRight,
|
||||
safeAreaInsetBottom,
|
||||
safeAreaInsetLeft,
|
||||
]
|
||||
.map((v) => parseInt(v, 10) || defaultBoundingBoxPadding)
|
||||
.join(' ');
|
||||
// console.log(str);
|
||||
return str;
|
||||
}
|
||||
|
||||
function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
|
||||
const {
|
||||
account: { avatar, avatarStatic, bot },
|
||||
|
|
|
@ -398,7 +398,7 @@ function Timeline({
|
|||
}
|
||||
const manyItems = items.length > 3;
|
||||
return items.map((item, i) => {
|
||||
const { id: statusID } = item;
|
||||
const { id: statusID, _differentAuthor } = item;
|
||||
const url = instance
|
||||
? `/${instance}/s/${statusID}`
|
||||
: `/s/${statusID}`;
|
||||
|
@ -416,6 +416,8 @@ function Timeline({
|
|||
: i === items.length - 1
|
||||
? 'end'
|
||||
: 'middle'
|
||||
} ${
|
||||
_differentAuthor ? 'timeline-item-diff-author' : ''
|
||||
}`}
|
||||
>
|
||||
<Link class="status-link timeline-item" to={url}>
|
||||
|
|
|
@ -114,7 +114,7 @@ function TranslationBlock({
|
|||
<span>
|
||||
{uiState === 'loading'
|
||||
? 'Translating…'
|
||||
: sourceLanguage && !detectedLang
|
||||
: sourceLanguage && sourceLangText && !detectedLang
|
||||
? `Translate from ${sourceLangText}`
|
||||
: `Translate`}
|
||||
</span>
|
||||
|
|
|
@ -1,415 +1,568 @@
|
|||
[
|
||||
"daystorm.netz.org",
|
||||
"mastodon.social",
|
||||
"mstdn.jp",
|
||||
"pawoo.net",
|
||||
"mstdn.social",
|
||||
"mastodon.world",
|
||||
"mstdn.jp",
|
||||
"mas.to",
|
||||
"mastodon.online",
|
||||
"mastodon.world",
|
||||
"infosec.exchange",
|
||||
"hachyderm.io",
|
||||
"fosstodon.org",
|
||||
"mastodon.lol",
|
||||
"hachyderm.io",
|
||||
"troet.cafe",
|
||||
"m.cmx.im",
|
||||
"fedibird.com",
|
||||
"techhub.social",
|
||||
"mastodonapp.uk",
|
||||
"troet.cafe",
|
||||
"pawoo.net",
|
||||
"fedibird.com",
|
||||
"universeodon.com",
|
||||
"m.cmx.im",
|
||||
"mastodon.uno",
|
||||
"mastodon.sdf.org",
|
||||
"mastodon.nl",
|
||||
"planet.moe",
|
||||
"kolektiva.social",
|
||||
"masto.ai",
|
||||
"chaos.social",
|
||||
"mastodon.gamedev.place",
|
||||
"piaille.fr",
|
||||
"mstdn.ca",
|
||||
"c.im",
|
||||
"mstdn.party",
|
||||
"sfba.social",
|
||||
"mastodon.cloud",
|
||||
"chaos.social",
|
||||
"home.social",
|
||||
"mastodon.nl",
|
||||
"mastodon.art",
|
||||
"twingyeo.kr",
|
||||
"mastodon.scot",
|
||||
"social.vivaldi.net",
|
||||
"aus.social",
|
||||
"det.social",
|
||||
"norden.social",
|
||||
"nrw.social",
|
||||
"toot.community",
|
||||
"mindly.social",
|
||||
"ohai.social",
|
||||
"mastodon.cloud",
|
||||
"mastodon.sdf.org",
|
||||
"kolektiva.social",
|
||||
"thu.closed.social",
|
||||
"mstdn.ca",
|
||||
"masto.ai",
|
||||
"c.im",
|
||||
"alive.bar",
|
||||
"mastodon.ie",
|
||||
"sfba.social",
|
||||
"o3o.ca",
|
||||
"social.vivaldi.net",
|
||||
"norden.social",
|
||||
"social.tchncs.de",
|
||||
"noagendasocial.com",
|
||||
"o3o.ca",
|
||||
"mastodon.top",
|
||||
"sueden.social",
|
||||
"mastodon.au",
|
||||
"det.social",
|
||||
"wxw.moe",
|
||||
"mstdn.party",
|
||||
"aus.social",
|
||||
"nrw.social",
|
||||
"home.social",
|
||||
"mastodon.scot",
|
||||
"tech.lgbt",
|
||||
"newsie.social",
|
||||
"mastodontech.de",
|
||||
"mathstodon.xyz",
|
||||
"loforo.com",
|
||||
"ioc.exchange",
|
||||
"twit.social",
|
||||
"toot.community",
|
||||
"ohai.social",
|
||||
"mastodon.top",
|
||||
"mastodon.ie",
|
||||
"mamot.fr",
|
||||
"sueden.social",
|
||||
"mindly.social",
|
||||
"mathstodon.xyz",
|
||||
"meow.social",
|
||||
"dice.camp",
|
||||
"nerdculture.de",
|
||||
"mastodon.nu",
|
||||
"masto.es",
|
||||
"hessen.social",
|
||||
"mastouille.fr",
|
||||
"ruhr.social",
|
||||
"social.cologne",
|
||||
"mastodon.green",
|
||||
"muenchen.social",
|
||||
"mastodon.nz",
|
||||
"mastodon.com.tr",
|
||||
"qoto.org",
|
||||
"tkz.one",
|
||||
"botsin.space",
|
||||
"sigmoid.social",
|
||||
"social.anoxinon.de",
|
||||
"mastodontech.de",
|
||||
"loforo.com",
|
||||
"dice.camp",
|
||||
"ro-mastodon.puyo.jp",
|
||||
"fediscience.org",
|
||||
"mstdn.science",
|
||||
"masthead.social",
|
||||
"ravenation.club",
|
||||
"defcon.social",
|
||||
"indieweb.social",
|
||||
"ecoevo.social",
|
||||
"med-mastodon.com",
|
||||
"zirk.us",
|
||||
"econtwitter.net",
|
||||
"mastodont.cat",
|
||||
"social.linux.pizza",
|
||||
"noc.social",
|
||||
"toot.wales",
|
||||
"wandering.shop",
|
||||
"framapiaf.org",
|
||||
"twit.social",
|
||||
"planet.moe",
|
||||
"ioc.exchange",
|
||||
"mastodon.au",
|
||||
"mastodon.nu",
|
||||
"hessen.social",
|
||||
"ruhr.social",
|
||||
"mastodon.green",
|
||||
"social.cologne",
|
||||
"mastodon.nz",
|
||||
"muenchen.social",
|
||||
"mastouille.fr",
|
||||
"qoto.org",
|
||||
"social.anoxinon.de",
|
||||
"twingyeo.kr",
|
||||
"mastodon.xyz",
|
||||
"eldritch.cafe",
|
||||
"masto.nu",
|
||||
"mastodon-japan.net",
|
||||
"g0v.social",
|
||||
"ieji.de",
|
||||
"toot.io",
|
||||
"social.dev-wiki.de",
|
||||
"fediscience.org",
|
||||
"framapiaf.org",
|
||||
"akamdon.com",
|
||||
"indieweb.social",
|
||||
"social.linux.pizza",
|
||||
"wandering.shop",
|
||||
"me.dm",
|
||||
"sigmoid.social",
|
||||
"aethy.com",
|
||||
"eldritch.cafe",
|
||||
"zirk.us",
|
||||
"ruby.social",
|
||||
"respublicae.eu",
|
||||
"mstdn.plus",
|
||||
"bildung.social",
|
||||
"urbanists.social",
|
||||
"mstdn.guru",
|
||||
"cyberplace.social",
|
||||
"sciences.social",
|
||||
"climatejustice.social",
|
||||
"glasgow.social",
|
||||
"livellosegreto.it",
|
||||
"pouet.chapril.org",
|
||||
"mastodontti.fi",
|
||||
"101010.pl",
|
||||
"mastodonczech.cz",
|
||||
"octodon.social",
|
||||
"mastodon-japan.net",
|
||||
"mstdn.science",
|
||||
"defcon.social",
|
||||
"noc.social",
|
||||
"ravenation.club",
|
||||
"social.librem.one",
|
||||
"metalhead.club",
|
||||
"socel.net",
|
||||
"thu.closed.social",
|
||||
"stranger.social",
|
||||
"mastodon.iriseden.eu",
|
||||
"mstdn.guru",
|
||||
"mastodont.cat",
|
||||
"g0v.social",
|
||||
"ecoevo.social",
|
||||
"tkz.one",
|
||||
"livellosegreto.it",
|
||||
"masto.nu",
|
||||
"med-mastodon.com",
|
||||
"toot.wales",
|
||||
"ieji.de",
|
||||
"bildung.social",
|
||||
"octodon.social",
|
||||
"urbanists.social",
|
||||
"pouet.chapril.org",
|
||||
"mastodon.com.tr",
|
||||
"social.dev-wiki.de",
|
||||
"toot.io",
|
||||
"digitalcourage.social",
|
||||
"econtwitter.net",
|
||||
"climatejustice.social",
|
||||
"kinky.business",
|
||||
"mastodontti.fi",
|
||||
"mastodon.radio",
|
||||
"berlin.social",
|
||||
"metalhead.club",
|
||||
"sciences.social",
|
||||
"mastodon.bida.im",
|
||||
"phpc.social",
|
||||
"kinky.business",
|
||||
"genomic.social",
|
||||
"vocalodon.net",
|
||||
"qdon.space",
|
||||
"androiddev.social",
|
||||
"masto.pt",
|
||||
"digitalcourage.social",
|
||||
"theblower.au",
|
||||
"graphics.social",
|
||||
"rollenspiel.social",
|
||||
"mastodonners.nl",
|
||||
"awscommunity.social",
|
||||
"witches.live",
|
||||
"pol.social",
|
||||
"mstdn.id",
|
||||
"swiss.social",
|
||||
"geekdom.social",
|
||||
"mast.lat",
|
||||
"mastodon.me.uk",
|
||||
"mastodon.fun",
|
||||
"journa.host",
|
||||
"mastodon-belgium.be",
|
||||
"tooot.im",
|
||||
"sself.co",
|
||||
"dresden.network",
|
||||
"expressional.social",
|
||||
"berlin.social",
|
||||
"mstdn.plus",
|
||||
"mastodon.iriseden.eu",
|
||||
"101010.pl",
|
||||
"woof.group",
|
||||
"hostux.social",
|
||||
"union.place",
|
||||
"mastodon.eus",
|
||||
"dju.social",
|
||||
"best-friends.chat",
|
||||
"scholar.social",
|
||||
"obo.sh",
|
||||
"geekdom.social",
|
||||
"androiddev.social",
|
||||
"rollenspiel.social",
|
||||
"social.lol",
|
||||
"cr8r.gg",
|
||||
"hci.social",
|
||||
"kemonodon.club",
|
||||
"toad.social",
|
||||
"genomic.social",
|
||||
"socel.net",
|
||||
"best-friends.chat",
|
||||
"mastodonczech.cz",
|
||||
"wien.rocks",
|
||||
"mastodon.me.uk",
|
||||
"scholar.social",
|
||||
"swiss.social",
|
||||
"dresden.network",
|
||||
"yiff.life",
|
||||
"cyberplace.social",
|
||||
"glasgow.social",
|
||||
"masto.pt",
|
||||
"sself.co",
|
||||
"hostux.social",
|
||||
"theblower.au",
|
||||
"openbiblio.social",
|
||||
"mstdn.games",
|
||||
"todon.eu",
|
||||
"typo.social",
|
||||
"floss.social",
|
||||
"tabletop.social",
|
||||
"mastodon.ml",
|
||||
"freiburg.social",
|
||||
"writing.exchange",
|
||||
"rubber.social",
|
||||
"mastodonbooks.net",
|
||||
"mstdn.io",
|
||||
"paquita.masto.host",
|
||||
"hci.social",
|
||||
"snabelen.no",
|
||||
"astrodon.social",
|
||||
"muenster.im",
|
||||
"paquita.masto.host",
|
||||
"mastodon.ml",
|
||||
"todon.eu",
|
||||
"freiburg.social",
|
||||
"h4.io",
|
||||
"cupoftea.social",
|
||||
"toot.aquilenet.fr",
|
||||
"lor.sh",
|
||||
"bark.lgbt",
|
||||
"convo.casa",
|
||||
"rheinneckar.social",
|
||||
"yiff.life",
|
||||
"stoners.social",
|
||||
"writing.exchange",
|
||||
"mastodon.se",
|
||||
"hcommons.social",
|
||||
"mstdn.games",
|
||||
"spore.social",
|
||||
"mastodon.berlin",
|
||||
"vmst.io",
|
||||
"vis.social",
|
||||
"discuss.systems",
|
||||
"uri.life",
|
||||
"aethy.com",
|
||||
"mstdn.io",
|
||||
"ursal.zone",
|
||||
"tooting.ch",
|
||||
"mastodon-belgium.be",
|
||||
"queer.party",
|
||||
"pewtix.com",
|
||||
"journa.host",
|
||||
"hcommons.social",
|
||||
"mastodonners.nl",
|
||||
"toot.aquilenet.fr",
|
||||
"awscommunity.social",
|
||||
"mastodon.zaclys.com",
|
||||
"historians.social",
|
||||
"typo.social",
|
||||
"pettingzoo.co",
|
||||
"peoplemaking.games",
|
||||
"abdl.link",
|
||||
"climatejustice.rocks",
|
||||
"tilde.zone",
|
||||
"wien.rocks",
|
||||
"floss.social",
|
||||
"toot.funami.tech",
|
||||
"dju.social",
|
||||
"ursal.zone",
|
||||
"stranger.social",
|
||||
"tooot.im",
|
||||
"muenster.im",
|
||||
"social.coop",
|
||||
"lile.cl",
|
||||
"openbiblio.social",
|
||||
"twiukraine.com",
|
||||
"tabletop.social",
|
||||
"imastodon.net",
|
||||
"bitcoinhackers.org",
|
||||
"medibubble.org",
|
||||
"disabled.social",
|
||||
"photog.social",
|
||||
"macaw.social",
|
||||
"mustard.blog",
|
||||
"mstdn.maud.io",
|
||||
"nofan.xyz",
|
||||
"mapstodon.space",
|
||||
"bonn.social",
|
||||
"vkl.world",
|
||||
"lounge.town",
|
||||
"fulda.social",
|
||||
"mast.dragon-fly.club",
|
||||
"masto.nobigtech.es",
|
||||
"sciencemastodon.com",
|
||||
"weirder.earth",
|
||||
"todon.nl",
|
||||
"obo.sh",
|
||||
"tooting.ch",
|
||||
"abdl.link",
|
||||
"toad.social",
|
||||
"rheinneckar.social",
|
||||
"social.treehouse.systems",
|
||||
"shakedown.social",
|
||||
"musician.social",
|
||||
"bsd.network",
|
||||
"mastodon.gal",
|
||||
"mastodon.coffee",
|
||||
"toot.cat",
|
||||
"libretooth.gr",
|
||||
"scicomm.xyz",
|
||||
"layer8.space",
|
||||
"uiuxdev.social",
|
||||
"veganism.social",
|
||||
"oslo.town",
|
||||
"artsio.com",
|
||||
"cybre.space",
|
||||
"freeradical.zone",
|
||||
"social.veraciousnetwork.com",
|
||||
"douchi.space",
|
||||
"mstdn.dk",
|
||||
"federated.press",
|
||||
"jorts.horse",
|
||||
"girlcock.club",
|
||||
"artisan.chat",
|
||||
"bolha.us",
|
||||
"liker.social",
|
||||
"vulpine.club",
|
||||
"linuxrocks.online",
|
||||
"eupolicy.social",
|
||||
"peoplemaking.games",
|
||||
"lor.sh",
|
||||
"union.place",
|
||||
"witches.live",
|
||||
"vis.social",
|
||||
"equestria.social",
|
||||
"graz.social",
|
||||
"mastodo.fi",
|
||||
"pnw.zone",
|
||||
"dizl.de",
|
||||
"tilde.zone",
|
||||
"lewdieheaven.com",
|
||||
"wetdry.world",
|
||||
"nofan.xyz",
|
||||
"h4.io",
|
||||
"photog.social",
|
||||
"discuss.systems",
|
||||
"mastoturk.org",
|
||||
"bonn.social",
|
||||
"vmst.io",
|
||||
"spore.social",
|
||||
"pol.social",
|
||||
"flipboard.social",
|
||||
"imastodon.net",
|
||||
"cybre.space",
|
||||
"bsd.network",
|
||||
"mstdn.maud.io",
|
||||
"girlcock.club",
|
||||
"pettingzoo.co",
|
||||
"mast.lat",
|
||||
"cupoftea.social",
|
||||
"bark.lgbt",
|
||||
"moth.social",
|
||||
"toot.cat",
|
||||
"furry.engineer",
|
||||
"qdon.space",
|
||||
"otadon.com",
|
||||
"gruene.social",
|
||||
"historians.social",
|
||||
"mapstodon.space",
|
||||
"douchi.space",
|
||||
"vocalodon.net",
|
||||
"layer8.space",
|
||||
"todon.nl",
|
||||
"types.pl",
|
||||
"ludosphere.fr",
|
||||
"merveilles.town",
|
||||
"iosdev.space",
|
||||
"feuerwehr.social",
|
||||
"mast.dragon-fly.club",
|
||||
"kemonodon.club",
|
||||
"macaw.social",
|
||||
"oldbytes.space",
|
||||
"medibubble.org",
|
||||
"expressional.social",
|
||||
"disabled.social",
|
||||
"bolha.us",
|
||||
"freeradical.zone",
|
||||
"scicomm.xyz",
|
||||
"graphics.social",
|
||||
"mona.do",
|
||||
"guitar.rodeo",
|
||||
"sociale.network",
|
||||
"opalstack.social",
|
||||
"mas.town",
|
||||
"mastodon.la",
|
||||
"arvr.social",
|
||||
"zeroes.ca",
|
||||
"mastorol.es",
|
||||
"ffxiv-mastodon.com",
|
||||
"data-folks.masto.host",
|
||||
"witter.cz",
|
||||
"romancelandia.club",
|
||||
"freeatlantis.com",
|
||||
"darmstadt.social",
|
||||
"mastodon.cat",
|
||||
"mastodon.energy",
|
||||
"computerfairi.es",
|
||||
"mastodon.org.uk",
|
||||
"xarxa.cloud",
|
||||
"masto.nyc",
|
||||
"cryptodon.lol",
|
||||
"gametoots.de",
|
||||
"sunny.garden",
|
||||
"toot.blue",
|
||||
"emacs.ch",
|
||||
"lile.cl",
|
||||
"social.sciences.re",
|
||||
"ai.wiki",
|
||||
"linuxrocks.online",
|
||||
"jorts.horse",
|
||||
"persiansmastodon.com",
|
||||
"mastodon.berlin",
|
||||
"liker.social",
|
||||
"literatur.social",
|
||||
"masto.bike",
|
||||
"retro.pizza",
|
||||
"climatejustice.rocks",
|
||||
"neurodifferent.me",
|
||||
"post.lurk.org",
|
||||
"mastodon.coffee",
|
||||
"mastodon.gal",
|
||||
"oslo.town",
|
||||
"neuromatch.social",
|
||||
"ika.queloud.net",
|
||||
"nederland.online",
|
||||
"hometech.social",
|
||||
"ura-mstdn.com",
|
||||
"shelter.moe",
|
||||
"kurry.social",
|
||||
"halifaxsocial.ca",
|
||||
"bbq.snoot.com",
|
||||
"mastodon.arch-linux.cz",
|
||||
"toot.cafe",
|
||||
"mao.mastodonhub.com",
|
||||
"ani.work",
|
||||
"mastodon.uy",
|
||||
"mastodon.com.py",
|
||||
"mstdn.mx",
|
||||
"mastodon.chasem.dev",
|
||||
"toolboxtalk.tech",
|
||||
"mastodon.in.th",
|
||||
"ichiji.social",
|
||||
"mstdn.beer",
|
||||
"graz.social",
|
||||
"libretooth.gr",
|
||||
"mastodonbooks.net",
|
||||
"xoxo.zone",
|
||||
"mastodon.design",
|
||||
"convo.casa",
|
||||
"bitbang.social",
|
||||
"freeatlantis.com",
|
||||
"masto.nobigtech.es",
|
||||
"eupolicy.social",
|
||||
"sociale.network",
|
||||
"famichiki.jp",
|
||||
"pkm.social",
|
||||
"4bear.com",
|
||||
"freak.university",
|
||||
"opalstack.social",
|
||||
"chitter.xyz",
|
||||
"sciencemastodon.com",
|
||||
"lgbtqia.space",
|
||||
"ffxiv-mastodon.com",
|
||||
"mental.social",
|
||||
"nnia.space",
|
||||
"iztasocial.site",
|
||||
"nojack.easydns.ca",
|
||||
"arsenalfc.social",
|
||||
"tyrol.social",
|
||||
"est.social",
|
||||
"kinkyelephant.com",
|
||||
"mograph.social",
|
||||
"mastodon.mnetwork.co.kr",
|
||||
"kirakiratter.com",
|
||||
"h-net.social",
|
||||
"podcastindex.social",
|
||||
"esperanto.masto.host",
|
||||
"artisan.chat",
|
||||
"vulpine.club",
|
||||
"musician.social",
|
||||
"sunny.garden",
|
||||
"dizl.de",
|
||||
"glammr.us",
|
||||
"mastodo.fi",
|
||||
"kirche.social",
|
||||
"mastodon.energy",
|
||||
"kind.social",
|
||||
"shelter.moe",
|
||||
"computerfairi.es",
|
||||
"mastodon.la",
|
||||
"mastodon.org.uk",
|
||||
"awoo.space",
|
||||
"kopiti.am",
|
||||
"is.nota.live",
|
||||
"social.politicaconciencia.org",
|
||||
"nafo.uk",
|
||||
"liberdon.com",
|
||||
"oc.todon.fr",
|
||||
"mstdn.kemono-friends.info",
|
||||
"me.dm",
|
||||
"fulda.social",
|
||||
"witter.cz",
|
||||
"freemasonry.social",
|
||||
"jawns.club",
|
||||
"mao.mastodonhub.com",
|
||||
"trpg.cloud",
|
||||
"ramen-fsm.eu.org",
|
||||
"toot.cafe",
|
||||
"darmstadt.social",
|
||||
"mstdn.mx",
|
||||
"pokemon.mastportal.info",
|
||||
"toot.lv",
|
||||
"romancelandia.club",
|
||||
"better.boston",
|
||||
"pnw.zone",
|
||||
"mastodon.content.town",
|
||||
"rivals.space",
|
||||
"thecanadian.social",
|
||||
"cr8r.gg",
|
||||
"plural.cafe",
|
||||
"donphan.social",
|
||||
"cloud-native.social",
|
||||
"blacktwitter.io",
|
||||
"mastodon.be",
|
||||
"mastodon.vlaanderen",
|
||||
"mastodon.com.br",
|
||||
"gensokyo.town",
|
||||
"xarxa.cloud",
|
||||
"esperanto.masto.host",
|
||||
"federated.press",
|
||||
"nnia.space",
|
||||
"digipres.club",
|
||||
"h5q.net",
|
||||
"kinkyelephant.com",
|
||||
"pawb.fun",
|
||||
"data-folks.masto.host",
|
||||
"mastodon.uy",
|
||||
"worldkey.io",
|
||||
"mastorol.es",
|
||||
"zeroes.ca",
|
||||
"mastodon.arch-linux.cz",
|
||||
"mastodon.acm.org",
|
||||
"social.bau-ha.us",
|
||||
"bbq.snoot.com",
|
||||
"akademienl.social",
|
||||
"toot.bike",
|
||||
"vtdon.com",
|
||||
"uri.life",
|
||||
"machteburch.social",
|
||||
"mas.town",
|
||||
"vkl.world",
|
||||
"vt.social",
|
||||
"mastodon.cat",
|
||||
"podcastindex.social",
|
||||
"artsio.com",
|
||||
"dotnet.social",
|
||||
"oc.todon.fr",
|
||||
"functional.cafe",
|
||||
"halifaxsocial.ca",
|
||||
"babka.social",
|
||||
"ichiji.social",
|
||||
"ura-mstdn.com",
|
||||
"eightpoint.app",
|
||||
"liberdon.com",
|
||||
"toot.portes-imaginaire.org",
|
||||
"mograph.social",
|
||||
"kirakiratter.com",
|
||||
"mstdn.tokyocameraclub.com",
|
||||
"kfem.cat",
|
||||
"earthstream.social",
|
||||
"sunbeam.city",
|
||||
"colorid.es",
|
||||
"gearheads.social",
|
||||
"est.social",
|
||||
"mastodon.mim-libre.fr",
|
||||
"swiss-talk.net",
|
||||
"donphan.social",
|
||||
"masto.nyc",
|
||||
"blorbo.social",
|
||||
"qubit-social.xyz",
|
||||
"en.osm.town",
|
||||
"gulp.cafe",
|
||||
"assemblag.es",
|
||||
"mstdn.kemono-friends.info",
|
||||
"tyrol.social",
|
||||
"social.seattle.wa.us",
|
||||
"toot.kif.rocks",
|
||||
"twiukraine.com",
|
||||
"social.politicaconciencia.org",
|
||||
"icosahedron.website",
|
||||
"toot.si",
|
||||
"mastodon.in.th",
|
||||
"norcal.social",
|
||||
"warhammer.social",
|
||||
"bookwor.ms",
|
||||
"kanoa.de",
|
||||
"veganism.social",
|
||||
"cryptodon.lol",
|
||||
"jasette.facil.services",
|
||||
"is.nota.live",
|
||||
"epicure.social",
|
||||
"sauropods.win",
|
||||
"kurry.social",
|
||||
"hometech.social",
|
||||
"kopiti.am",
|
||||
"biplus.date",
|
||||
"spacey.space",
|
||||
"photodn.net",
|
||||
"otogamer.me",
|
||||
"hello.2heng.xin",
|
||||
"blabber.lu-rp.net",
|
||||
"im-in.space",
|
||||
"wargamers.social",
|
||||
"toot.berlin",
|
||||
"archaeo.social",
|
||||
"col.social",
|
||||
"h-net.social",
|
||||
"social.kyiv.dcomm.net.ua",
|
||||
"dobbs.town",
|
||||
"mastodon.com.br",
|
||||
"toot.funami.tech",
|
||||
"nafo.uk",
|
||||
"arsenalfc.social",
|
||||
"social.edu.nl",
|
||||
"sunbeam.city",
|
||||
"federate.social",
|
||||
"hello.2heng.xin",
|
||||
"gensokyo.town",
|
||||
"mastodon.tetaneutral.net",
|
||||
"tablegame.mstdn.cloud",
|
||||
"elekk.xyz",
|
||||
"uwu.social",
|
||||
"hispagatos.space",
|
||||
"blacktwitter.io",
|
||||
"burma.social",
|
||||
"osna.social",
|
||||
"seocommunity.social",
|
||||
"otogamer.me",
|
||||
"mstdn.fr",
|
||||
"dobbs.town",
|
||||
"toki.social",
|
||||
"colearn.social",
|
||||
"cloud-native.social",
|
||||
"mstdn-bike.net",
|
||||
"mastodon.hypnoguys.com",
|
||||
"lounge.town",
|
||||
"guitar.rodeo",
|
||||
"mastodon.mit.edu",
|
||||
"hispagatos.space",
|
||||
"mstdn.id",
|
||||
"flower.afn.social",
|
||||
"parfait.day",
|
||||
"nederland.online",
|
||||
"ani.work",
|
||||
"mastodon.education",
|
||||
"mastodon.gougere.fr",
|
||||
"cztwitter.cz",
|
||||
"uwu.social",
|
||||
"mastodon.bayern",
|
||||
"gameliberty.club",
|
||||
"poweredbygay.social",
|
||||
"sukebe.hostdon.ne.jp",
|
||||
"social.veraciousnetwork.com",
|
||||
"mastodon.vlaanderen",
|
||||
"earthstream.social",
|
||||
"xn--lofll-1sat.is",
|
||||
"social.datalabour.com",
|
||||
"gametoots.de",
|
||||
"mastodon.com.py",
|
||||
"outdoors.lgbt",
|
||||
"arvr.social",
|
||||
"loðfíll.is",
|
||||
"social.yesterweb.org",
|
||||
"9kb.me",
|
||||
"mstdn.dk",
|
||||
"occitania.social",
|
||||
"apobangpo.space",
|
||||
"dingdash.com",
|
||||
"seo.chat",
|
||||
"mastodon.cc",
|
||||
"mastodon.chasem.dev",
|
||||
"oulipo.social",
|
||||
"lou.lt",
|
||||
"digforfire.org",
|
||||
"mastodon.partipirate.org",
|
||||
"gensokyo.social",
|
||||
"anticapitalist.party",
|
||||
"eletusk.club",
|
||||
"mastodon.juggler.jp",
|
||||
"social.slat.org",
|
||||
"bear.community",
|
||||
"mathtod.online",
|
||||
"mastodon.pirateparty.be",
|
||||
"toot.turbo.chat",
|
||||
"lgbt.io",
|
||||
"anarchism.space",
|
||||
"otoya.space",
|
||||
"acg.mn",
|
||||
"social.chinwag.org",
|
||||
"mastodon.hk",
|
||||
"mastoot.fr",
|
||||
"eigadon.net",
|
||||
"aleph.land",
|
||||
"irsoluciones.social",
|
||||
"maly.io",
|
||||
"birds.town",
|
||||
"kfem.cat",
|
||||
"beekeeping.ninja",
|
||||
"mastodon.juggler.jp",
|
||||
"oransns.com",
|
||||
"anticapitalist.party",
|
||||
"deadinsi.de",
|
||||
"gardenstate.social",
|
||||
"mastodon.cc",
|
||||
"piano.masto.host",
|
||||
"baraag.net",
|
||||
"m.rthome.me",
|
||||
"eletusk.club",
|
||||
"lewacki.space",
|
||||
"mastodon.pirateparty.be",
|
||||
"anarchism.space",
|
||||
"mastodon.cisti.org",
|
||||
"metalverse.social",
|
||||
"truthsocial.co.in",
|
||||
"mstdn.osaka",
|
||||
"mastodol.jp",
|
||||
"mastodon.cipherbliss.com",
|
||||
"social.targaryen.house",
|
||||
"baraag.net",
|
||||
"yakyudon.net",
|
||||
"lou.lt",
|
||||
"social.slat.org",
|
||||
"gensokyo.social",
|
||||
"social.chinwag.org",
|
||||
"tribe.net",
|
||||
"lgbt.io",
|
||||
"toots.social",
|
||||
"pravda.me",
|
||||
"aleph.land",
|
||||
"poweredbygay.social",
|
||||
"masto.yttrx.com",
|
||||
"yttrx.com",
|
||||
"toot.pizza",
|
||||
"drumstodon.net",
|
||||
"acg.mn",
|
||||
"kpop.social",
|
||||
"toolboxtalk.tech",
|
||||
"bear.community",
|
||||
"otoya.space",
|
||||
"mastodon.triggerphra.se",
|
||||
"mastodon.free-solutions.org",
|
||||
"rcsocial.net",
|
||||
"kith.kitchen",
|
||||
"vocalounge.cafe",
|
||||
"pieville.net",
|
||||
"mstdn.osaka",
|
||||
"mastodon.mnetwork.co.kr",
|
||||
"mstdn.es",
|
||||
"seo.chat",
|
||||
"mastodol.jp",
|
||||
"renkontu.com",
|
||||
"mastodon.cipherbliss.com",
|
||||
"toot.turbo.chat",
|
||||
"catdon.life",
|
||||
"social.coletivos.org",
|
||||
"mastoturk.org",
|
||||
"mastodon.librelabucm.org",
|
||||
"toot.thoughtworks.com",
|
||||
"mastodon-swiss.org",
|
||||
"social.targaryen.house",
|
||||
"moe.cat",
|
||||
"bologna.one",
|
||||
"toot.site",
|
||||
"e.fo",
|
||||
"mastodon.holeyfox.co",
|
||||
"m.rthome.me",
|
||||
"stereodon.social",
|
||||
"social.opendesktop.org",
|
||||
"mastodon.elte.hu",
|
||||
"toot.site",
|
||||
"vipgirlfriend.xxx",
|
||||
"nasface.cz",
|
||||
"bgme.me",
|
||||
"social.caa-ins.org",
|
||||
"nojack.easydns.ca",
|
||||
"mastodon.oeru.org",
|
||||
"mastodon.elte.hu",
|
||||
"nasface.cz",
|
||||
"lilymagic.com",
|
||||
"mast.moe",
|
||||
"mastodon.librelabucm.org",
|
||||
"fetswing.org",
|
||||
"mastodon.cosmicanimal.jp",
|
||||
"todon.ploud.fr",
|
||||
"ephemeral.glitch.social",
|
||||
"mikumikudance.cloud",
|
||||
"summoners-riftodon.jp",
|
||||
"kinbaku.club",
|
||||
"www.mstddntfdn.online",
|
||||
"dev.brighteon.social",
|
||||
"jaxbeach.social",
|
||||
"animalliberation.social",
|
||||
"med-mammoth.com",
|
||||
"mastodon.gza.jp",
|
||||
"hearthtodon.com",
|
||||
"counter.social",
|
||||
"onmasto.com",
|
||||
"pet123.club",
|
||||
"ostatus.ikeji.ma",
|
||||
"counter.social",
|
||||
"the.resize.club",
|
||||
"social.outsourcedmath.com",
|
||||
"nerdculture.de",
|
||||
"pewtix.com",
|
||||
"med-mammoth.com",
|
||||
"ping-pong-sandbox.herokuapp.com",
|
||||
"id.cc",
|
||||
"freespeechextremist.com",
|
||||
"cawfee.club",
|
||||
|
@ -419,53 +572,67 @@
|
|||
"go5.dev",
|
||||
"poa.st",
|
||||
"patriot.online",
|
||||
"stereophonic.space",
|
||||
"kazv.moe",
|
||||
"seaofog.com",
|
||||
"libranet.de",
|
||||
"tea.codes",
|
||||
"pixelfed.social",
|
||||
"stop.voring.me",
|
||||
"shitposter.club",
|
||||
"squeet.me",
|
||||
"shared.graphics",
|
||||
"glindr.org",
|
||||
"devs.live",
|
||||
"pxlmo.com",
|
||||
"pixel.tchncs.de",
|
||||
"pythondevs.social",
|
||||
"pleroma.pibvt.net",
|
||||
"books.theunseen.city",
|
||||
"love.alicecomplex.com",
|
||||
"mastodon.london",
|
||||
"greenish.red",
|
||||
"pixelfed.sdf.org",
|
||||
"anar.chi.st",
|
||||
"friendica.eskimo.com",
|
||||
"meatbag.app",
|
||||
"fediverse.bbad.com",
|
||||
"dudu.best",
|
||||
"pix.diaspodon.fr",
|
||||
"shpposter.club",
|
||||
"pix.toot.wales",
|
||||
"pleroma.noellabo.jp",
|
||||
"fgc.network",
|
||||
"bookrastinating.com",
|
||||
"electricrequiem.com",
|
||||
"pixey.org",
|
||||
"mk.pupbrained.xyz",
|
||||
"fe.disroot.org",
|
||||
"pixelfed.tokyo",
|
||||
"chudbuds.lol",
|
||||
"mastodon.wien",
|
||||
"448c.net",
|
||||
"freeframe.masto.host",
|
||||
"pixelfed.photos",
|
||||
"varishangout.net",
|
||||
"pixelfed.fr",
|
||||
"friendica.vrije-mens.org",
|
||||
"mastodon.tech",
|
||||
"bae.st",
|
||||
"brighteon.social",
|
||||
"pixelfed.nz",
|
||||
"hayu.sh",
|
||||
"pixelfed.uno",
|
||||
"pixelfed.au",
|
||||
"helladoge.com",
|
||||
"donotban.com",
|
||||
"miniwa.moe",
|
||||
"genserver.social",
|
||||
"bookwyrm.social",
|
||||
"spinster.xyz",
|
||||
"pixelfed.de",
|
||||
"metapixl.com",
|
||||
"neenster.org",
|
||||
"venera.social",
|
||||
"outerheaven.club",
|
||||
"gleasonator.com",
|
||||
"pixelfed.fi",
|
||||
"blob.cat",
|
||||
"onevery.ignorelist.com",
|
||||
"cliq.buzz",
|
||||
"pxl.roflcopter.fr",
|
||||
"p.1069-3.com",
|
||||
"gc2.jp",
|
||||
"an.eldritch.gift",
|
||||
"bz.pawdev.me",
|
||||
"ck.borgar.space",
|
||||
"fedi.s1i.dev",
|
||||
"kids.0px.io"
|
||||
]
|
|
@ -969,11 +969,6 @@
|
|||
"Silesian",
|
||||
"ślůnsko godka"
|
||||
],
|
||||
[
|
||||
"tai",
|
||||
"Tai",
|
||||
"ภาษาไท or ภาษาไต"
|
||||
],
|
||||
[
|
||||
"tok",
|
||||
"Toki Pona",
|
||||
|
|
|
@ -4,11 +4,12 @@ import { useParams, useSearchParams } from 'react-router-dom';
|
|||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import AccountInfo from '../components/account-info';
|
||||
import EmojiText from '../components/emoji-text';
|
||||
import Icon from '../components/icon';
|
||||
import Link from '../components/link';
|
||||
import Menu2 from '../components/menu2';
|
||||
import Timeline from '../components/timeline';
|
||||
import { api } from '../utils/api';
|
||||
import emojifyText from '../utils/emojify-text';
|
||||
import showToast from '../utils/show-toast';
|
||||
import states from '../utils/states';
|
||||
import { saveStatus } from '../utils/states';
|
||||
|
@ -235,11 +236,9 @@ function AccountStatuses() {
|
|||
// };
|
||||
// }}
|
||||
>
|
||||
<b
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: emojifyText(displayName, emojis),
|
||||
}}
|
||||
/>
|
||||
<b>
|
||||
<EmojiText text={displayName} emojis={emojis} />
|
||||
</b>
|
||||
<div>
|
||||
<span>@{acct}</span>
|
||||
</div>
|
||||
|
@ -255,15 +254,12 @@ function AccountStatuses() {
|
|||
timelineStart={TimelineStart}
|
||||
refresh={excludeReplies + excludeBoosts + tagged + media}
|
||||
headerEnd={
|
||||
<Menu
|
||||
portal={{
|
||||
target: document.body,
|
||||
}}
|
||||
<Menu2
|
||||
portal
|
||||
// setDownOverflow
|
||||
overflow="auto"
|
||||
viewScroll="close"
|
||||
position="anchor"
|
||||
boundingBoxPadding="8 8 8 8"
|
||||
menuButton={
|
||||
<button type="button" class="plain">
|
||||
<Icon icon="more" size="l" />
|
||||
|
@ -295,7 +291,7 @@ function AccountStatuses() {
|
|||
Switch to account's instance (<b>{accountInstance}</b>)
|
||||
</small>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Menu2>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -9,6 +9,7 @@ import { useEffect, useRef, useState } from 'preact/hooks';
|
|||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import Icon from '../components/icon';
|
||||
import Menu2 from '../components/menu2';
|
||||
import Timeline from '../components/timeline';
|
||||
import { api } from '../utils/api';
|
||||
import showToast from '../utils/show-toast';
|
||||
|
@ -122,15 +123,12 @@ function Hashtags(props) {
|
|||
checkForUpdates={checkForUpdates}
|
||||
useItemID
|
||||
headerEnd={
|
||||
<Menu
|
||||
portal={{
|
||||
target: document.body,
|
||||
}}
|
||||
<Menu2
|
||||
portal
|
||||
setDownOverflow
|
||||
overflow="auto"
|
||||
viewScroll="close"
|
||||
position="anchor"
|
||||
boundingBoxPadding="8 8 8 8"
|
||||
menuButton={
|
||||
<button type="button" class="plain">
|
||||
<Icon icon="more" size="l" />
|
||||
|
@ -306,7 +304,7 @@ function Hashtags(props) {
|
|||
>
|
||||
<Icon icon="bus" /> <span>Go to another instance…</span>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Menu2>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -10,6 +10,7 @@ import AccountBlock from '../components/account-block';
|
|||
import Icon from '../components/icon';
|
||||
import Link from '../components/link';
|
||||
import ListAddEdit from '../components/list-add-edit';
|
||||
import Menu2 from '../components/menu2';
|
||||
import Modal from '../components/modal';
|
||||
import Timeline from '../components/timeline';
|
||||
import { api } from '../utils/api';
|
||||
|
@ -108,15 +109,12 @@ function List(props) {
|
|||
</Link>
|
||||
}
|
||||
headerEnd={
|
||||
<Menu
|
||||
portal={{
|
||||
target: document.body,
|
||||
}}
|
||||
<Menu2
|
||||
portal
|
||||
setDownOverflow
|
||||
overflow="auto"
|
||||
viewScroll="close"
|
||||
position="anchor"
|
||||
boundingBoxPadding="8 8 8 8"
|
||||
menuButton={
|
||||
<button type="button" class="plain">
|
||||
<Icon icon="more" size="l" />
|
||||
|
@ -137,7 +135,7 @@ function List(props) {
|
|||
<Icon icon="group" size="l" />
|
||||
<span>Manage members</span>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Menu2>
|
||||
}
|
||||
/>
|
||||
{showListAddEditModal && (
|
||||
|
|
|
@ -4,6 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Icon from '../components/icon';
|
||||
import Menu2 from '../components/menu2';
|
||||
import Timeline from '../components/timeline';
|
||||
import { api } from '../utils/api';
|
||||
import { filteredItems } from '../utils/filters';
|
||||
|
@ -92,15 +93,12 @@ function Public({ local, ...props }) {
|
|||
boostsCarousel={snapStates.settings.boostsCarousel}
|
||||
allowFilters
|
||||
headerEnd={
|
||||
<Menu
|
||||
portal={{
|
||||
target: document.body,
|
||||
}}
|
||||
<Menu2
|
||||
portal
|
||||
// setDownOverflow
|
||||
overflow="auto"
|
||||
viewScroll="close"
|
||||
position="anchor"
|
||||
boundingBoxPadding="8 8 8 8"
|
||||
menuButton={
|
||||
<button type="button" class="plain">
|
||||
<Icon icon="more" size="l" />
|
||||
|
@ -136,7 +134,7 @@ function Public({ local, ...props }) {
|
|||
>
|
||||
<Icon icon="bus" /> <span>Go to another instance…</span>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Menu2>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -7,6 +7,7 @@ import { memo } from 'preact/compat';
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
|
@ -386,7 +387,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
|||
useEffect(() => {
|
||||
if (!statuses.length) return;
|
||||
console.debug('STATUSES', statuses);
|
||||
const scrollPosition = states.scrollPositions[id];
|
||||
const scrollPosition = snapStates.scrollPositions[id];
|
||||
console.debug('scrollPosition', scrollPosition);
|
||||
if (!!scrollPosition) {
|
||||
console.debug('Case 1', {
|
||||
|
@ -1089,7 +1090,7 @@ function SubComments({
|
|||
}, []);
|
||||
|
||||
const detailsRef = useRef();
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
function handleScroll(e) {
|
||||
e.target.dataset.scrollLeft = e.target.scrollLeft;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Icon from '../components/icon';
|
||||
import Menu2 from '../components/menu2';
|
||||
import Timeline from '../components/timeline';
|
||||
import { api } from '../utils/api';
|
||||
import { filteredItems } from '../utils/filters';
|
||||
|
@ -92,15 +93,12 @@ function Trending(props) {
|
|||
boostsCarousel={snapStates.settings.boostsCarousel}
|
||||
allowFilters
|
||||
headerEnd={
|
||||
<Menu
|
||||
portal={{
|
||||
target: document.body,
|
||||
}}
|
||||
<Menu2
|
||||
portal
|
||||
// setDownOverflow
|
||||
overflow="auto"
|
||||
viewScroll="close"
|
||||
position="anchor"
|
||||
boundingBoxPadding="8 8 8 8"
|
||||
menuButton={
|
||||
<button type="button" class="plain">
|
||||
<Icon icon="more" size="l" />
|
||||
|
@ -124,7 +122,7 @@ function Trending(props) {
|
|||
>
|
||||
<Icon icon="bus" /> <span>Go to another instance…</span>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Menu2>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,88 +1,139 @@
|
|||
#welcome {
|
||||
text-align: center;
|
||||
background-image: radial-gradient(
|
||||
closest-side at 50% 50%,
|
||||
var(--bg-faded-color),
|
||||
transparent
|
||||
);
|
||||
circle at center,
|
||||
var(--bg-color),
|
||||
transparent 16em
|
||||
),
|
||||
radial-gradient(circle at center, var(--bg-color), transparent 8em);
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
padding: 16px;
|
||||
cursor: default;
|
||||
}
|
||||
#welcome ~ * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#welcome .hero-container {
|
||||
padding-block: 60px;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#welcome h1 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 1.2em;
|
||||
font-size: 5em;
|
||||
line-height: 1;
|
||||
letter-spacing: -1px;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
@keyframes shine2 {
|
||||
0% {
|
||||
left: -100%;
|
||||
}
|
||||
20% {
|
||||
left: 100%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
#welcome h1:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: linear-gradient(
|
||||
100deg,
|
||||
rgba(255, 255, 255, 0) 30%,
|
||||
rgba(255, 255, 255, 0.4),
|
||||
rgba(255, 255, 255, 0) 70%
|
||||
);
|
||||
top: 0;
|
||||
left: -100%;
|
||||
pointer-events: none;
|
||||
animation: shine2 5s ease-in-out 1s infinite;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#welcome {
|
||||
background-image: none;
|
||||
}
|
||||
#welcome h1 {
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
#welcome h1:before {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
#welcome img {
|
||||
vertical-align: top;
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
#welcome h2 {
|
||||
font-size: 3em;
|
||||
letter-spacing: -0.05ex;
|
||||
margin: 16px 0;
|
||||
padding: 0;
|
||||
/* gradiented text */
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
var(--blue-color) 30%,
|
||||
var(--purple-color),
|
||||
var(--red-color) 70%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
#welcome h1 img {
|
||||
filter: drop-shadow(-1px -1px var(--bg-blur-color))
|
||||
drop-shadow(0 -1px 1px #fff)
|
||||
drop-shadow(0 16px 32px var(--drop-shadow-color));
|
||||
}
|
||||
|
||||
@keyframes psychedelic {
|
||||
0% {
|
||||
filter: hue-rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
filter: hue-rotate(-90deg);
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#welcome h1 img {
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
#welcome:hover h2 {
|
||||
animation: psychedelic 10s infinite alternate;
|
||||
|
||||
#welcome h1:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
#welcome .desc {
|
||||
font-size: 1.4em;
|
||||
text-wrap: balance;
|
||||
opacity: 0.7;
|
||||
}
|
||||
#welcome .hero-container > p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#why-container summary {
|
||||
font-weight: bold;
|
||||
margin: 16px 0;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
#why-container .sections {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
#why-container[open] summary {
|
||||
text-decoration: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#why-container .sections section {
|
||||
text-align: start;
|
||||
max-width: 480px;
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 30px;
|
||||
border: 1px solid var(--bg-color);
|
||||
font-weight: 600;
|
||||
font-size: 106.25%;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 1px var(--outline-color),
|
||||
0 4px 16px -8px var(--drop-shadow-color);
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 17px 20px 40px var(--drop-shadow-color);
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
#why-container .sections section h4 {
|
||||
margin: 0;
|
||||
padding: 30px 30px 0;
|
||||
font-size: 111.765%;
|
||||
color: var(--blue-color);
|
||||
font-size: 1.4em;
|
||||
font-weight: 600;
|
||||
}
|
||||
#why-container .sections section p {
|
||||
margin-inline: 30px;
|
||||
margin-bottom: 30px;
|
||||
opacity: 0.7;
|
||||
text-wrap: balance;
|
||||
}
|
||||
#why-container .sections section img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-top: 1px solid var(--outline-color);
|
||||
border-bottom: 1px solid var(--outline-color);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#why-container .sections section img {
|
||||
filter: invert(0.85) hue-rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import groupedNotificationsUrl from '../assets/features/grouped-notifications.jp
|
|||
import multiColumnUrl from '../assets/features/multi-column.jpg';
|
||||
import multiHashtagTimelineUrl from '../assets/features/multi-hashtag-timeline.jpg';
|
||||
import nestedCommentsThreadUrl from '../assets/features/nested-comments-thread.jpg';
|
||||
import logoText from '../assets/logo-text.svg';
|
||||
import logo from '../assets/logo.svg';
|
||||
import Link from '../components/link';
|
||||
import states from '../utils/states';
|
||||
|
@ -14,101 +15,88 @@ function Welcome() {
|
|||
useTitle(null, ['/', '/welcome']);
|
||||
return (
|
||||
<main id="welcome">
|
||||
<h1>
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
width="24"
|
||||
height="24"
|
||||
style={{
|
||||
aspectRatio: '1/1',
|
||||
}}
|
||||
/>{' '}
|
||||
Phanpy
|
||||
</h1>
|
||||
<h2>
|
||||
Trunk-tastic
|
||||
<br />
|
||||
Mastodon Experience
|
||||
</h2>
|
||||
<p>A minimalistic opinionated Mastodon web client.</p>
|
||||
<p>
|
||||
<big>
|
||||
<b>
|
||||
<Link to="/login" class="button">
|
||||
Log in
|
||||
</Link>
|
||||
</b>
|
||||
</big>
|
||||
</p>
|
||||
<details id="why-container">
|
||||
<summary>Why Phanpy?</summary>
|
||||
<div class="hero-container">
|
||||
<h1>
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
width="200"
|
||||
height="200"
|
||||
style={{
|
||||
aspectRatio: '1/1',
|
||||
marginBlockEnd: -16,
|
||||
}}
|
||||
/>
|
||||
<img src={logoText} alt="Phanpy" width="250" />
|
||||
</h1>
|
||||
<p>
|
||||
<big>
|
||||
<b>
|
||||
<Link to="/login" class="button">
|
||||
Log in
|
||||
</Link>
|
||||
</b>
|
||||
</big>
|
||||
</p>
|
||||
<p class="desc">A minimalistic opinionated Mastodon web client.</p>
|
||||
</div>
|
||||
<div id="why-container">
|
||||
<div class="sections">
|
||||
<section>
|
||||
<h4>Boosts Carousel</h4>
|
||||
<p>
|
||||
Visually separate original posts and re-shared posts (boosted
|
||||
posts).
|
||||
</p>
|
||||
<img
|
||||
src={boostsCarouselUrl}
|
||||
alt="Screenshot of Boosts Carousel"
|
||||
loading="lazy"
|
||||
/>
|
||||
<h4>Boosts Carousel</h4>
|
||||
<p>
|
||||
Visually separate original posts and re-shared posts (boosted
|
||||
posts).
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>Nested comments thread</h4>
|
||||
<p>Effortlessly follow conversations. Semi-collapsible replies.</p>
|
||||
<img
|
||||
src={nestedCommentsThreadUrl}
|
||||
alt="Screenshot of nested comments thread"
|
||||
loading="lazy"
|
||||
/>
|
||||
<h4>Nested comments thread</h4>
|
||||
<p>Effortlessly follow conversations. Semi-collapsible replies.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>Grouped notifications</h4>
|
||||
<p>
|
||||
Similar notifications are grouped and collapsed to reduce clutter.
|
||||
</p>
|
||||
<img
|
||||
src={groupedNotificationsUrl}
|
||||
alt="Screenshot of grouped notifications"
|
||||
loading="lazy"
|
||||
/>
|
||||
<h4>Grouped notifications</h4>
|
||||
<p>
|
||||
Similar notifications are grouped and collapsed to reduce clutter.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>Single or multi-column</h4>
|
||||
<p>
|
||||
By default, single column for zen-mode seekers. Configurable
|
||||
multi-column for power users.
|
||||
</p>
|
||||
<img
|
||||
src={multiColumnUrl}
|
||||
alt="Screenshot of multi-column UI"
|
||||
loading="lazy"
|
||||
/>
|
||||
<h4>Single or multi-column</h4>
|
||||
<p>
|
||||
By default, single column for zen-mode seekers. Configurable
|
||||
multi-column for power users.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>Multi-hashtag timeline</h4>
|
||||
<p>Up to 5 hashtags combined into a single timeline.</p>
|
||||
<img
|
||||
src={multiHashtagTimelineUrl}
|
||||
alt="Screenshot of multi-hashtag timeline with a form to add more hashtags"
|
||||
loading="lazy"
|
||||
/>
|
||||
<h4>Multi-hashtag timeline</h4>
|
||||
<p>Up to 5 hashtags combined into a single timeline.</p>
|
||||
</section>
|
||||
<p>Convinced yet?</p>
|
||||
<p>
|
||||
<big>
|
||||
<b>
|
||||
<Link to="/login" class="button">
|
||||
Log in
|
||||
</Link>
|
||||
</b>
|
||||
</big>
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<hr />
|
||||
<p>
|
||||
<a href="https://github.com/cheeaun/phanpy" target="_blank">
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
function emojifyText(text, emojis = []) {
|
||||
if (!text) return '';
|
||||
if (!emojis.length) return text;
|
||||
if (text.indexOf(':') === -1) return text;
|
||||
// Replace shortcodes in text with emoji
|
||||
// emojis = [{ shortcode: 'smile', url: 'https://example.com/emoji.png' }]
|
||||
emojis.forEach((emoji) => {
|
||||
const { shortcode, staticUrl, url } = emoji;
|
||||
text = text.replace(
|
||||
new RegExp(`:${shortcode}:`, 'g'),
|
||||
`<img class="shortcode-emoji emoji" src="${url}" alt=":${shortcode}:" width="12" height="12" loading="lazy" />`,
|
||||
`<img class="shortcode-emoji emoji" src="${url}" alt=":${shortcode}:" width="12" height="12" loading="lazy" decoding="async" />`,
|
||||
);
|
||||
});
|
||||
// console.log(text, emojis);
|
||||
|
|
|
@ -7,174 +7,193 @@ function enhanceContent(content, opts = {}) {
|
|||
let enhancedContent = content;
|
||||
const dom = document.createElement('div');
|
||||
dom.innerHTML = enhancedContent;
|
||||
const hasLink = /<a/i.test(enhancedContent);
|
||||
const hasCodeBlock = enhancedContent.indexOf('```') !== -1;
|
||||
|
||||
// Add target="_blank" to all links with no target="_blank"
|
||||
// E.g. `note` in `account`
|
||||
const noTargetBlankLinks = Array.from(
|
||||
dom.querySelectorAll('a:not([target="_blank"])'),
|
||||
);
|
||||
noTargetBlankLinks.forEach((link) => {
|
||||
link.setAttribute('target', '_blank');
|
||||
});
|
||||
if (hasLink) {
|
||||
const noTargetBlankLinks = Array.from(
|
||||
dom.querySelectorAll('a:not([target="_blank"])'),
|
||||
);
|
||||
noTargetBlankLinks.forEach((link) => {
|
||||
link.setAttribute('target', '_blank');
|
||||
});
|
||||
}
|
||||
|
||||
// Spanify un-spanned mentions
|
||||
const notMentionLinks = Array.from(dom.querySelectorAll('a[href]'));
|
||||
notMentionLinks.forEach((link) => {
|
||||
const text = link.innerText.trim();
|
||||
const hasChildren = link.querySelector('*');
|
||||
// If text looks like @username@domain, then it's a mention
|
||||
if (/^@[^@]+(@[^@]+)?$/g.test(text)) {
|
||||
// Only show @username
|
||||
const username = text.split('@')[1];
|
||||
if (!hasChildren) link.innerHTML = `@<span>${username}</span>`;
|
||||
link.classList.add('mention');
|
||||
}
|
||||
// If text looks like #hashtag, then it's a hashtag
|
||||
if (/^#[^#]+$/g.test(text)) {
|
||||
if (!hasChildren) link.innerHTML = `#<span>${text.slice(1)}</span>`;
|
||||
link.classList.add('mention', 'hashtag');
|
||||
}
|
||||
});
|
||||
if (hasLink) {
|
||||
const notMentionLinks = Array.from(dom.querySelectorAll('a[href]'));
|
||||
notMentionLinks.forEach((link) => {
|
||||
const text = link.innerText.trim();
|
||||
const hasChildren = link.querySelector('*');
|
||||
// If text looks like @username@domain, then it's a mention
|
||||
if (/^@[^@]+(@[^@]+)?$/g.test(text)) {
|
||||
// Only show @username
|
||||
const username = text.split('@')[1];
|
||||
if (!hasChildren) link.innerHTML = `@<span>${username}</span>`;
|
||||
link.classList.add('mention');
|
||||
}
|
||||
// If text looks like #hashtag, then it's a hashtag
|
||||
if (/^#[^#]+$/g.test(text)) {
|
||||
if (!hasChildren) link.innerHTML = `#<span>${text.slice(1)}</span>`;
|
||||
link.classList.add('mention', 'hashtag');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// EMOJIS
|
||||
// ======
|
||||
// Convert :shortcode: to <img />
|
||||
let textNodes = extractTextNodes(dom);
|
||||
textNodes.forEach((node) => {
|
||||
let html = node.nodeValue
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
if (emojis) {
|
||||
html = emojifyText(html, emojis);
|
||||
}
|
||||
fauxDiv.innerHTML = html;
|
||||
const nodes = Array.from(fauxDiv.childNodes);
|
||||
node.replaceWith(...nodes);
|
||||
});
|
||||
let textNodes;
|
||||
if (enhancedContent.indexOf(':') !== -1) {
|
||||
textNodes = extractTextNodes(dom);
|
||||
textNodes.forEach((node) => {
|
||||
let html = node.nodeValue
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
if (emojis) {
|
||||
html = emojifyText(html, emojis);
|
||||
}
|
||||
fauxDiv.innerHTML = html;
|
||||
const nodes = Array.from(fauxDiv.childNodes);
|
||||
node.replaceWith(...nodes);
|
||||
});
|
||||
}
|
||||
|
||||
// CODE BLOCKS
|
||||
// ===========
|
||||
// Convert ```code``` to <pre><code>code</code></pre>
|
||||
const blocks = Array.from(dom.querySelectorAll('p')).filter((p) =>
|
||||
/^```[^]+```$/g.test(p.innerText.trim()),
|
||||
);
|
||||
blocks.forEach((block) => {
|
||||
const pre = document.createElement('pre');
|
||||
// Replace <br /> with newlines
|
||||
block.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
|
||||
pre.innerHTML = `<code>${block.innerHTML.trim()}</code>`;
|
||||
block.replaceWith(pre);
|
||||
});
|
||||
if (hasCodeBlock) {
|
||||
const blocks = Array.from(dom.querySelectorAll('p')).filter((p) =>
|
||||
/^```[^]+```$/g.test(p.innerText.trim()),
|
||||
);
|
||||
blocks.forEach((block) => {
|
||||
const pre = document.createElement('pre');
|
||||
// Replace <br /> with newlines
|
||||
block.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
|
||||
pre.innerHTML = `<code>${block.innerHTML.trim()}</code>`;
|
||||
block.replaceWith(pre);
|
||||
});
|
||||
}
|
||||
|
||||
// Convert multi-paragraph code blocks to <pre><code>code</code></pre>
|
||||
const paragraphs = Array.from(dom.querySelectorAll('p'));
|
||||
// Filter out paragraphs with ``` in beginning only
|
||||
const codeBlocks = paragraphs.filter((p) => /^```/g.test(p.innerText));
|
||||
// For each codeBlocks, get all paragraphs until the last paragraph with ``` at the end only
|
||||
codeBlocks.forEach((block) => {
|
||||
const nextParagraphs = [block];
|
||||
let hasCodeBlock = false;
|
||||
let currentBlock = block;
|
||||
while (currentBlock.nextElementSibling) {
|
||||
const next = currentBlock.nextElementSibling;
|
||||
if (next && next.tagName === 'P') {
|
||||
if (/```$/g.test(next.innerText)) {
|
||||
nextParagraphs.push(next);
|
||||
hasCodeBlock = true;
|
||||
break;
|
||||
if (hasCodeBlock) {
|
||||
const paragraphs = Array.from(dom.querySelectorAll('p'));
|
||||
// Filter out paragraphs with ``` in beginning only
|
||||
const codeBlocks = paragraphs.filter((p) => /^```/g.test(p.innerText));
|
||||
// For each codeBlocks, get all paragraphs until the last paragraph with ``` at the end only
|
||||
codeBlocks.forEach((block) => {
|
||||
const nextParagraphs = [block];
|
||||
let hasCodeBlock = false;
|
||||
let currentBlock = block;
|
||||
while (currentBlock.nextElementSibling) {
|
||||
const next = currentBlock.nextElementSibling;
|
||||
if (next && next.tagName === 'P') {
|
||||
if (/```$/g.test(next.innerText)) {
|
||||
nextParagraphs.push(next);
|
||||
hasCodeBlock = true;
|
||||
break;
|
||||
} else {
|
||||
nextParagraphs.push(next);
|
||||
}
|
||||
} else {
|
||||
nextParagraphs.push(next);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
currentBlock = next;
|
||||
}
|
||||
currentBlock = next;
|
||||
}
|
||||
if (hasCodeBlock) {
|
||||
const pre = document.createElement('pre');
|
||||
nextParagraphs.forEach((p) => {
|
||||
// Replace <br /> with newlines
|
||||
p.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
|
||||
});
|
||||
const codeText = nextParagraphs.map((p) => p.innerHTML).join('\n\n');
|
||||
pre.innerHTML = `<code>${codeText}</code>`;
|
||||
block.replaceWith(pre);
|
||||
nextParagraphs.forEach((p) => p.remove());
|
||||
}
|
||||
});
|
||||
if (hasCodeBlock) {
|
||||
const pre = document.createElement('pre');
|
||||
nextParagraphs.forEach((p) => {
|
||||
// Replace <br /> with newlines
|
||||
p.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
|
||||
});
|
||||
const codeText = nextParagraphs.map((p) => p.innerHTML).join('\n\n');
|
||||
pre.innerHTML = `<code>${codeText}</code>`;
|
||||
block.replaceWith(pre);
|
||||
nextParagraphs.forEach((p) => p.remove());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// INLINE CODE
|
||||
// ===========
|
||||
// Convert `code` to <code>code</code>
|
||||
textNodes = extractTextNodes(dom);
|
||||
textNodes.forEach((node) => {
|
||||
let html = node.nodeValue
|
||||
.replace(/&/g, '&')
|
||||
.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);
|
||||
});
|
||||
if (enhancedContent.indexOf('`') !== -1) {
|
||||
textNodes = extractTextNodes(dom);
|
||||
textNodes.forEach((node) => {
|
||||
let html = node.nodeValue
|
||||
.replace(/&/g, '&')
|
||||
.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, '<')
|
||||
.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 (/twitter\.com/i.test(enhancedContent)) {
|
||||
textNodes = extractTextNodes(dom, {
|
||||
rejectFilter: ['A'],
|
||||
});
|
||||
textNodes.forEach((node) => {
|
||||
let html = node.nodeValue
|
||||
.replace(/&/g, '&')
|
||||
.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);
|
||||
});
|
||||
}
|
||||
|
||||
// HASHTAG STUFFING
|
||||
// ================
|
||||
// Get the <p> that contains a lot of hashtags, add a class to it
|
||||
const hashtagStuffedParagraph = Array.from(dom.querySelectorAll('p')).find(
|
||||
(p) => {
|
||||
let hashtagCount = 0;
|
||||
for (let i = 0; i < p.childNodes.length; i++) {
|
||||
const node = p.childNodes[i];
|
||||
if (enhancedContent.indexOf('#') !== -1) {
|
||||
const hashtagStuffedParagraph = Array.from(dom.querySelectorAll('p')).find(
|
||||
(p) => {
|
||||
let hashtagCount = 0;
|
||||
for (let i = 0; i < p.childNodes.length; i++) {
|
||||
const node = p.childNodes[i];
|
||||
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent.trim();
|
||||
if (text !== '') {
|
||||
return false;
|
||||
}
|
||||
} else if (node.tagName === 'A') {
|
||||
const linkText = node.textContent.trim();
|
||||
if (!linkText || !linkText.startsWith('#')) {
|
||||
return false;
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent.trim();
|
||||
if (text !== '') {
|
||||
return false;
|
||||
}
|
||||
} else if (node.tagName === 'A') {
|
||||
const linkText = node.textContent.trim();
|
||||
if (!linkText || !linkText.startsWith('#')) {
|
||||
return false;
|
||||
} else {
|
||||
hashtagCount++;
|
||||
}
|
||||
} else {
|
||||
hashtagCount++;
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Only consider "stuffing" if there are more than 3 hashtags
|
||||
return hashtagCount > 3;
|
||||
},
|
||||
);
|
||||
if (hashtagStuffedParagraph) {
|
||||
hashtagStuffedParagraph.classList.add('hashtag-stuffing');
|
||||
hashtagStuffedParagraph.title = hashtagStuffedParagraph.innerText;
|
||||
// Only consider "stuffing" if there are more than 3 hashtags
|
||||
return hashtagCount > 3;
|
||||
},
|
||||
);
|
||||
if (hashtagStuffedParagraph) {
|
||||
hashtagStuffedParagraph.classList.add('hashtag-stuffing');
|
||||
hashtagStuffedParagraph.title = hashtagStuffedParagraph.innerText;
|
||||
}
|
||||
}
|
||||
|
||||
if (postEnhanceDOM) {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
export default function isMastodonLinkMaybe(url) {
|
||||
const { pathname } = new URL(url);
|
||||
return (
|
||||
/^\/.*\/\d+$/i.test(pathname) || /^\/notes\/[a-z0-9]+$/i.test(pathname) // Misskey, Calckey
|
||||
/^\/.*\/\d+$/i.test(pathname) ||
|
||||
/^\/@[^/]+\/statuses\/\w+$/i.test(pathname) || // GoToSocial
|
||||
/^\/notes\/[a-z0-9]+$/i.test(pathname) // Misskey, Calckey
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { match } from '@formatjs/intl-localematcher';
|
||||
import mem from 'mem';
|
||||
|
||||
function localeMatch(...args) {
|
||||
function _localeMatch(...args) {
|
||||
// Wrap in try/catch because localeMatcher throws on invalid locales
|
||||
try {
|
||||
return match(...args);
|
||||
|
@ -8,5 +9,8 @@ function localeMatch(...args) {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
const localeMatch = mem(_localeMatch, {
|
||||
cacheKey: (args) => args.join(),
|
||||
});
|
||||
|
||||
export default localeMatch;
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
export default function localeCode2Text(code) {
|
||||
return new Intl.DisplayNames(navigator.languages, {
|
||||
type: 'language',
|
||||
}).of(code);
|
||||
try {
|
||||
return new Intl.DisplayNames(navigator.languages, {
|
||||
type: 'language',
|
||||
}).of(code);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
27
src/utils/safe-bounding-box-padding.jsx
Normal file
27
src/utils/safe-bounding-box-padding.jsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import mem from 'mem';
|
||||
|
||||
const root = document.documentElement;
|
||||
const style = getComputedStyle(root);
|
||||
const defaultBoundingBoxPadding = 8;
|
||||
function _safeBoundingBoxPadding() {
|
||||
// Get safe area inset variables from root
|
||||
const safeAreaInsetTop = style.getPropertyValue('--sai-top');
|
||||
const safeAreaInsetRight = style.getPropertyValue('--sai-right');
|
||||
const safeAreaInsetBottom = style.getPropertyValue('--sai-bottom');
|
||||
const safeAreaInsetLeft = style.getPropertyValue('--sai-left');
|
||||
const str = [
|
||||
safeAreaInsetTop,
|
||||
safeAreaInsetRight,
|
||||
safeAreaInsetBottom,
|
||||
safeAreaInsetLeft,
|
||||
]
|
||||
.map((v) => parseInt(v, 10) || defaultBoundingBoxPadding)
|
||||
.join(' ');
|
||||
// console.log(str);
|
||||
return str;
|
||||
}
|
||||
const safeBoundingBoxPadding = mem(_safeBoundingBoxPadding, {
|
||||
maxAge: 10000, // 10 seconds
|
||||
});
|
||||
|
||||
export default safeBoundingBoxPadding;
|
|
@ -130,6 +130,16 @@ export function groupContext(items) {
|
|||
});
|
||||
});
|
||||
|
||||
// Tag items that has different author than first post's author
|
||||
contexts.forEach((context) => {
|
||||
const firstItemAccountID = context[0].account.id;
|
||||
context.forEach((item) => {
|
||||
if (item.account.id !== firstItemAccountID) {
|
||||
item._differentAuthor = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (contexts.length) console.log('🧵 Contexts', contexts);
|
||||
|
||||
const newItems = [];
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useLayoutEffect, useState } from 'preact/hooks';
|
||||
|
||||
export default function useScroll({
|
||||
scrollableRef,
|
||||
|
@ -17,7 +17,7 @@ export default function useScroll({
|
|||
const [nearReachEnd, setNearReachEnd] = useState(false);
|
||||
const isVertical = direction === 'vertical';
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const scrollableElement = scrollableRef.current;
|
||||
if (!scrollableElement) return {};
|
||||
let previousScrollStart = isVertical
|
||||
|
|
Loading…
Reference in a new issue