Merge pull request #156 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-06-15 09:05:28 +08:00 committed by GitHub
commit 2dc1343f54
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 3171 additions and 2179 deletions

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@
border-radius: 0;
}
.avatar:not(.has-alpha).squircle {
border-radius: 12px;
border-radius: 25%;
}
.avatar img {

View file

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

View file

@ -1415,7 +1415,7 @@ function MediaAttachment({
const suffixType = type.split('/')[0];
const debouncedOnDescriptionChange = useDebouncedCallback(
onDescriptionChange,
500,
250,
);
const [showModal, setShowModal] = useState(false);

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

View file

@ -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>&raquo;
</Link>

View file

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

View file

@ -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 && (
<>
{' '}

View file

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

View file

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

View file

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

View file

@ -520,6 +520,9 @@
margin-inline: 0;
padding-inline-start: 1.5em;
}
.status .content ul {
list-style-type: disc;
}
.status .content .invisible {
display: none;
}

View file

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

View file

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

View file

@ -114,7 +114,7 @@ function TranslationBlock({
<span>
{uiState === 'loading'
? 'Translating…'
: sourceLanguage && !detectedLang
: sourceLanguage && sourceLangText && !detectedLang
? `Translate from ${sourceLangText}`
: `Translate`}
</span>

View file

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

View file

@ -969,11 +969,6 @@
"Silesian",
"ślůnsko godka"
],
[
"tai",
"Tai",
"ภาษาไท or ภาษาไต"
],
[
"tok",
"Toki Pona",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
if (/`[^`]+`/g.test(html)) {
html = html.replaceAll(/(`[^]+?`)/g, '<code>$1</code>');
}
fauxDiv.innerHTML = html;
const nodes = Array.from(fauxDiv.childNodes);
node.replaceWith(...nodes);
});
if (enhancedContent.indexOf('`') !== -1) {
textNodes = extractTextNodes(dom);
textNodes.forEach((node) => {
let html = node.nodeValue
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
if (/`[^`]+`/g.test(html)) {
html = html.replaceAll(/(`[^]+?`)/g, '<code>$1</code>');
}
fauxDiv.innerHTML = html;
const nodes = Array.from(fauxDiv.childNodes);
node.replaceWith(...nodes);
});
}
// TWITTER USERNAMES
// =================
// Convert @username@twitter.com to <a href="https://twitter.com/username">@username@twitter.com</a>
textNodes = extractTextNodes(dom, {
rejectFilter: ['A'],
});
textNodes.forEach((node) => {
let html = node.nodeValue
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
if (/@[a-zA-Z0-9_]+@twitter\.com/g.test(html)) {
html = html.replaceAll(
/(@([a-zA-Z0-9_]+)@twitter\.com)/g,
'<a href="https://twitter.com/$2" rel="nofollow noopener noreferrer" target="_blank">$1</a>',
);
}
fauxDiv.innerHTML = html;
const nodes = Array.from(fauxDiv.childNodes);
node.replaceWith(...nodes);
});
if (/twitter\.com/i.test(enhancedContent)) {
textNodes = extractTextNodes(dom, {
rejectFilter: ['A'],
});
textNodes.forEach((node) => {
let html = node.nodeValue
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
if (/@[a-zA-Z0-9_]+@twitter\.com/g.test(html)) {
html = html.replaceAll(
/(@([a-zA-Z0-9_]+)@twitter\.com)/g,
'<a href="https://twitter.com/$2" rel="nofollow noopener noreferrer" target="_blank">$1</a>',
);
}
fauxDiv.innerHTML = html;
const nodes = Array.from(fauxDiv.childNodes);
node.replaceWith(...nodes);
});
}
// 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) {

View file

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

View file

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

View file

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

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

View file

@ -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 = [];

View file

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