Merge pull request #429 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2024-03-06 17:52:16 +08:00 committed by GitHub
commit 83f9498b79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 4520 additions and 332 deletions

110
package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@formatjs/intl-localematcher": "~0.5.4",
"@formatjs/intl-segmenter": "~11.5.5",
"@formkit/auto-animate": "~0.8.1",
"@github/text-expander-element": "~2.6.1",
"@iconify-icons/mingcute": "~1.2.9",
@ -20,10 +21,11 @@
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
"fast-equals": "~5.0.1",
"html-prettify": "^1.0.7",
"idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0",
"masto": "~6.6.0",
"masto": "~6.6.4",
"moize": "~6.1.6",
"p-retry": "~6.2.0",
"p-throttle": "~6.1.0",
@ -32,15 +34,14 @@
"react-intersection-observer": "~9.8.1",
"react-quick-pinch-zoom": "~5.1.0",
"react-router-dom": "6.6.2",
"runes2": "~1.1.4",
"string-length": "5.0.1",
"string-length": "6.0.0",
"swiped-events": "~1.1.9",
"toastify-js": "~1.12.0",
"uid": "~2.0.2",
"use-debounce": "~10.0.0",
"use-long-press": "~3.2.0",
"use-resize-observer": "~9.1.0",
"valtio": "1.9.0"
"valtio": "1.13.2"
},
"devDependencies": {
"@preact/preset-vite": "~2.8.1",
@ -49,10 +50,10 @@
"postcss-dark-theme-class": "~1.2.1",
"postcss-preset-env": "~9.4.0",
"twitter-text": "~3.1.0",
"vite": "~5.1.4",
"vite": "~5.1.5",
"vite-plugin-generate-file": "~0.1.1",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.19.0",
"vite-plugin-pwa": "~0.19.2",
"vite-plugin-remove-console": "~2.2.0",
"workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.0.0",
@ -2923,6 +2924,15 @@
"node": ">=12"
}
},
"node_modules/@formatjs/ecma402-abstract": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.18.2.tgz",
"integrity": "sha512-+QoPW4csYALsQIl8GbN14igZzDbuwzcpWrku9nyMXlaqAlwRBgl5V+p0vWMGFqHOw37czNXaP/lEk4wbLgcmtA==",
"dependencies": {
"@formatjs/intl-localematcher": "0.5.4",
"tslib": "^2.4.0"
}
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz",
@ -2931,6 +2941,16 @@
"tslib": "^2.4.0"
}
},
"node_modules/@formatjs/intl-segmenter": {
"version": "11.5.5",
"resolved": "https://registry.npmjs.org/@formatjs/intl-segmenter/-/intl-segmenter-11.5.5.tgz",
"integrity": "sha512-mMbJKFGzwYJBcwfL9EfqFje75Ce5WPar5rSi7wWvFtBPFY2Zi1cWIss7FSm2MNNM9l1BycBAsBQuXFt+Hd+0tQ==",
"dependencies": {
"@formatjs/ecma402-abstract": "1.18.2",
"@formatjs/intl-localematcher": "0.5.4",
"tslib": "^2.4.0"
}
},
"node_modules/@formkit/auto-animate": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.1.tgz",
@ -3966,15 +3986,6 @@
"tslib": "^2.0.3"
}
},
"node_modules/char-regex": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.1.tgz",
"integrity": "sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@ -4262,6 +4273,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/derive-valtio": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.1.0.tgz",
"integrity": "sha512-OCg2UsLbXK7GmmpzMXhYkdO64vhJ1ROUUGaTFyHjVwEdMEcTTRj7W1TxLbSBxdY8QLBPCcp66MTyaSy0RpO17A==",
"peerDependencies": {
"valtio": "*"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@ -4964,6 +4983,11 @@
"tslib": "^2.0.3"
}
},
"node_modules/html-prettify": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/html-prettify/-/html-prettify-1.0.7.tgz",
"integrity": "sha512-99pRsP2PV2DyWnrVibNyad7gNmzCP7AANO8jw7Z9yanWyXH9dPdqdMXGefySplroqCNdk95u7j5TLxfyJ1Cbbg=="
},
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@ -5622,9 +5646,9 @@
}
},
"node_modules/masto": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/masto/-/masto-6.6.0.tgz",
"integrity": "sha512-spXDwI22M7cWaFPBmpEY+05F+9kAE0nLwzUx5YPECKsLEEQ+c+a92yo/WOnocNs/ILr9/OCKjYPyCykL6TVw+w==",
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/masto/-/masto-6.6.4.tgz",
"integrity": "sha512-qoq08UAzdRhVNo9eUG+LIxVNx8UWq+ZqyyeoPGYKLsCOdNyxZpkdK7r3M1negyBscfsNzppdMDpuRhmxDOizxw==",
"dependencies": {
"change-case": "^4.1.2",
"events-to-async": "^2.0.1",
@ -6740,10 +6764,9 @@
}
},
"node_modules/proxy-compare": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.4.0.tgz",
"integrity": "sha512-FD8KmQUQD6Mfpd0hywCOzcon/dbkFP8XBd9F1ycbKtvVsfv6TsFUKJ2eC0Iz2y+KzlkdT1Z8SY6ZSgm07zOyqg==",
"license": "MIT"
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.6.0.tgz",
"integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="
},
"node_modules/punycode": {
"version": "2.3.0",
@ -7103,11 +7126,6 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/runes2": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz",
"integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -7249,16 +7267,14 @@
"license": "MIT"
},
"node_modules/string-length": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz",
"integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==",
"license": "MIT",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-6.0.0.tgz",
"integrity": "sha512-1U361pxZHEQ+FeSjzqRpV+cu2vTzYeWeafXFLykiFlv4Vc0n3njgU8HrMbyik5uwm77naWMuVG8fhEF+Ovb1Kg==",
"dependencies": {
"char-regex": "^2.0.0",
"strip-ansi": "^7.0.1"
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=12.20"
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@ -7774,30 +7790,34 @@
"dev": true
},
"node_modules/valtio": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-1.9.0.tgz",
"integrity": "sha512-mQLFsAlKbYascZygFQh6lXuDjU5WHLoeZ8He4HqMnWfasM96V6rDbeFkw1XeG54xycmDonr/Jb4xgviHtuySrA==",
"license": "MIT",
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-1.13.2.tgz",
"integrity": "sha512-Qik0o+DSy741TmkqmRfjq+0xpZBXi/Y6+fXZLn0xNF1z/waFMbE3rkivv5Zcf9RrMUp6zswf2J7sbh2KBlba5A==",
"dependencies": {
"proxy-compare": "2.4.0",
"derive-valtio": "0.1.0",
"proxy-compare": "2.6.0",
"use-sync-external-store": "1.2.0"
},
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/vite": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.4.tgz",
"integrity": "sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==",
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.5.tgz",
"integrity": "sha512-BdN1xh0Of/oQafhU+FvopafUp6WaYenLU/NFoL5WyJL++GxkNfieKzBhM24H3HVsPQrlAqB7iJYTHabzaRed5Q==",
"dev": true,
"dependencies": {
"esbuild": "^0.19.3",
@ -7876,9 +7896,9 @@
}
},
"node_modules/vite-plugin-pwa": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.19.0.tgz",
"integrity": "sha512-Unfb4Jk/ka4HELtpMLIPCmGcW4LFT+CL7Ri1/Of1544CVKXS2ftP91kUkNzkzeI1sGpOdVGuxprVLB9NjMoCAA==",
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.19.2.tgz",
"integrity": "sha512-LSQJFPxCAQYbRuSyc9EbRLRqLpaBA9onIZuQFomfUYjWSgHuQLonahetDlPSC9zsxmkSEhQH8dXZN8yL978h3w==",
"dev": true,
"dependencies": {
"debug": "^4.3.4",

View file

@ -11,6 +11,7 @@
},
"dependencies": {
"@formatjs/intl-localematcher": "~0.5.4",
"@formatjs/intl-segmenter": "~11.5.5",
"@formkit/auto-animate": "~0.8.1",
"@github/text-expander-element": "~2.6.1",
"@iconify-icons/mingcute": "~1.2.9",
@ -22,10 +23,11 @@
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
"fast-equals": "~5.0.1",
"html-prettify": "^1.0.7",
"idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0",
"masto": "~6.6.0",
"masto": "~6.6.4",
"moize": "~6.1.6",
"p-retry": "~6.2.0",
"p-throttle": "~6.1.0",
@ -34,15 +36,14 @@
"react-intersection-observer": "~9.8.1",
"react-quick-pinch-zoom": "~5.1.0",
"react-router-dom": "6.6.2",
"runes2": "~1.1.4",
"string-length": "5.0.1",
"string-length": "6.0.0",
"swiped-events": "~1.1.9",
"toastify-js": "~1.12.0",
"uid": "~2.0.2",
"use-debounce": "~10.0.0",
"use-long-press": "~3.2.0",
"use-resize-observer": "~9.1.0",
"valtio": "1.9.0"
"valtio": "1.13.2"
},
"devDependencies": {
"@preact/preset-vite": "~2.8.1",
@ -51,10 +52,10 @@
"postcss-dark-theme-class": "~1.2.1",
"postcss-preset-env": "~9.4.0",
"twitter-text": "~3.1.0",
"vite": "~5.1.4",
"vite": "~5.1.5",
"vite-plugin-generate-file": "~0.1.1",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.19.0",
"vite-plugin-pwa": "~0.19.2",
"vite-plugin-remove-console": "~2.2.0",
"workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.0.0",

32
public/404.html Normal file
View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title>Page not found</title>
<meta name="color-scheme" content="dark light" />
<style>
body {
text-align: center;
font-family: ui-rounded, -apple-system, BlinkMacSystemFont, Segoe UI,
Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
}
h1 {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<h1>Page not found</h1>
<p><a href="/">Go home</a></p>
</body>
</html>

View file

@ -33,8 +33,9 @@ const imageRoute = new Route(
const isRemote = !sameOrigin;
const isImage = request.destination === 'image';
const isAvatar = request.url.includes('/avatars/');
const isCustomEmoji = request.url.includes('/custom/_emojis');
const isEmoji = request.url.includes('/emoji/');
return isRemote && isImage && (isAvatar || isEmoji);
return isRemote && isImage && (isAvatar || isCustomEmoji || isEmoji);
},
new CacheFirst({
cacheName: 'remote-images',

View file

@ -103,6 +103,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
max-width: 100%;
background-color: var(--bg-color);
overflow-anchor: auto;
&.wide {
width: 60em;
}
}
.deck.contained {
overflow: auto;
@ -1752,7 +1756,7 @@ body > .szh-menu-container {
max-width: 90vw;
/* overflow: hidden; */
}
.szh-menu[aria-label='Submenu'] {
.szh-menu[aria-label='Submenu'].menu-blur {
background-color: var(--bg-blur-color);
backdrop-filter: blur(4px);
box-shadow: 0 3px 24px -3px var(--drop-shadow-color);
@ -1920,6 +1924,10 @@ body > .szh-menu-container {
).danger {
color: var(--red-color);
}
.szh-menu
.szh-menu__item.danger:not(.szh-menu__item--disabled).szh-menu__item--hover {
background-color: var(--red-color);
}
.szh-menu
.szh-menu__item:not(.szh-menu__item--disabled):not(
.szh-menu__item--hover
@ -2108,7 +2116,7 @@ meter.donut[hidden] {
:root .toastify {
user-select: none;
padding: 8px 16px;
border-radius: 999px;
border-radius: 44px;
pointer-events: none;
color: var(--button-text-color);
text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color);

View file

@ -1,6 +1,7 @@
import './app.css';
import debounce from 'just-debounce-it';
import { lazy, Suspense } from 'preact/compat';
import {
useEffect,
useLayoutEffect,
@ -17,13 +18,14 @@ import ComposeButton from './components/compose-button';
import { ICONS } from './components/ICONS';
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
import Loader from './components/loader';
import Modals from './components/modals';
// import Modals from './components/modals';
import NotificationService from './components/notification-service';
import SearchCommand from './components/search-command';
import Shortcuts from './components/shortcuts';
import NotFound from './pages/404';
import AccountStatuses from './pages/account-statuses';
import Bookmarks from './pages/bookmarks';
// import Catchup from './pages/catchup';
import Favourites from './pages/favourites';
import FollowedHashtags from './pages/followed-hashtags';
import Following from './pages/following';
@ -54,6 +56,9 @@ import store from './utils/store';
import { getCurrentAccount } from './utils/store-utils';
import './utils/toast-alert';
const Catchup = lazy(() => import('./pages/catchup'));
const Modals = lazy(() => import('./components/modals'));
window.__STATES__ = states;
window.__STATES_STATS__ = () => {
const keys = [
@ -381,7 +386,9 @@ function App() {
)}
{isLoggedIn && <ComposeButton />}
{isLoggedIn && <Shortcuts />}
<Modals />
<Suspense>
<Modals />
</Suspense>
{isLoggedIn && <NotificationService />}
<BackgroundService isLoggedIn={isLoggedIn} />
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
@ -394,7 +401,7 @@ function PrimaryRoutes({ isLoggedIn, loading }) {
const location = useLocation();
const nonRootLocation = useMemo(() => {
const { pathname } = location;
return !/^\/(login|welcome)/.test(pathname);
return !/^\/(login|welcome)/i.test(pathname);
}, [location]);
return (
@ -457,6 +464,14 @@ function SecondaryRoutes({ isLoggedIn }) {
<Route path=":id" element={<List />} />
</Route>
<Route path="/ft" element={<FollowedHashtags />} />
<Route
path="/catchup"
element={
<Suspense>
<Catchup />
</Suspense>
}
/>
</>
)}
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -8,11 +8,14 @@ body.cloak,
.name-text *,
.status .content-container,
.status .content-container *,
.status .content-compact,
.status .content-compact > *,
.account-container :is(header, main > *:not(.actions)),
.account-container :is(header, main > *:not(.actions)) *,
.header-double-lines,
.account-block {
.account-block,
.catchup-filters .filter-author *,
.post-peek-html *,
.post-peek-content > * {
text-decoration-thickness: 1.1em;
text-decoration-line: line-through;
/* text-rendering: optimizeSpeed; */
@ -20,15 +23,17 @@ body.cloak,
}
.name-text *,
.status .content-container *,
.account-container :is(header, main > *:not(.actions)) * {
.account-container :is(header, main > *:not(.actions)) *,
.post-peek-content > * {
filter: none;
}
.status :is(img, video, audio),
.media-post .media,
.avatar,
.avatar *,
.emoji,
.header-banner {
.header-banner,
.post-peek-media {
filter: contrast(0) !important;
background-color: #000 !important;
}
@ -37,7 +42,16 @@ body.cloak,
/* SPECIAL CASES */
@supports (display: -webkit-box) {
body.cloak .card :is(.title, .meta) {
background-color: var(--text-color) !important;
:is(body.cloak, .cloak) .card :is(.title, .meta) {
background-color: currentColor !important;
}
}
body.cloak,
.cloak {
.media-container figcaption,
.media-container figcaption > *,
.catchup-filters .filter-author * {
color: var(--text-color) !important;
}
}

View file

@ -73,7 +73,7 @@ export const ICONS = {
() => import('@iconify-icons/mingcute/forbid-circle-line'),
'180deg',
],
flag: () => import('@iconify-icons/mingcute/flag-4-line'),
flag: () => import('@iconify-icons/mingcute/flag-1-line'),
time: () => import('@iconify-icons/mingcute/time-line'),
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
@ -98,7 +98,9 @@ export const ICONS = {
media: () => import('@iconify-icons/mingcute/photo-album-line'),
speak: () => import('@iconify-icons/mingcute/radar-line'),
building: () => import('@iconify-icons/mingcute/building-5-line'),
history: () => import('@iconify-icons/mingcute/history-2-line'),
history2: () => import('@iconify-icons/mingcute/history-2-line'),
document: () => import('@iconify-icons/mingcute/document-line'),
'arrows-right': () => import('@iconify-icons/mingcute/arrows-right-line'),
code: () => import('@iconify-icons/mingcute/code-line'),
copy: () => import('@iconify-icons/mingcute/copy-2-line'),
};

View file

@ -9,11 +9,15 @@
--original-color: var(--link-color);
.note {
font-size: 95%;
font-size: 0.95em;
line-height: 1.4;
text-wrap: pretty;
margin-bottom: 16px;
&:empty {
display: none;
}
> *:first-child {
margin-top: 0;
padding-top: 0;

View file

@ -343,7 +343,7 @@ function AccountInfo({
return (
<div
tabIndex="-1"
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
style={{
'--header-color-1': headerCornerColors[0],
'--header-color-2': headerCornerColors[1],
@ -1053,6 +1053,27 @@ function RelatedActions({
<MenuDivider />
</>
)}
<MenuItem
onClick={() => {
const handle = `@${currentInfo?.acct || acct}`;
try {
navigator.clipboard.writeText(handle);
showToast('Handle copied');
} catch (e) {
console.error(e);
showToast('Unable to copy handle');
}
}}
>
<Icon icon="copy" />
<small>
Copy handle
<br />
<span class="more-insignificant">
@{currentInfo?.acct || acct}
</span>
</small>
</MenuItem>
<MenuItem href={url} target="_blank">
<Icon icon="external" />
<small class="menu-double-lines">{niceAccountURL(url)}</small>
@ -1124,6 +1145,7 @@ function RelatedActions({
</MenuItem>
) : (
<SubMenu
menuClassName="menu-blur"
openTrigger="clickOnly"
direction="bottom"
overflow="auto"
@ -1238,10 +1260,38 @@ function RelatedActions({
</>
)}
</MenuConfirm>
{/* <MenuItem>
<Icon icon="flag" />
<span>Report @{username}</span>
</MenuItem> */}
<MenuItem
className="danger"
onClick={() => {
states.showReportModal = {
account: currentInfo || info,
};
}}
>
<Icon icon="flag" />
<span>Report @{username}</span>
</MenuItem>
</>
)}
{import.meta.env.DEV && currentAuthenticated && isSelf && (
<>
<MenuDivider />
<MenuItem
onClick={async () => {
const relationships =
await currentMasto.v1.accounts.relationships.fetch({
id: [accountID.current],
});
const { note } = relationships[0] || {};
if (note) {
alert(note);
console.log(note);
}
}}
>
<Icon icon="pencil" />
<span>See note</span>
</MenuItem>
</>
)}
</Menu2>
@ -1324,7 +1374,6 @@ function RelatedActions({
</div>
{!!showTranslatedBio && (
<Modal
class="light"
onClose={() => {
setShowTranslatedBio(false);
}}
@ -1338,7 +1387,6 @@ function RelatedActions({
)}
{!!showAddRemoveLists && (
<Modal
class="light"
onClose={() => {
setShowAddRemoveLists(false);
}}
@ -1351,7 +1399,6 @@ function RelatedActions({
)}
{!!showPrivateNoteModal && (
<Modal
class="light"
onClose={() => {
setShowPrivateNoteModal(false);
}}
@ -1543,7 +1590,6 @@ function AddRemoveListsSheet({ accountID, onClose }) {
</main>
{showListAddEditModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowListAddEditModal(false);

View file

@ -6,7 +6,6 @@ import { deepEqual } from 'fast-equals';
import { forwardRef } from 'preact/compat';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { substring } from 'runes2';
import stringLength from 'string-length';
import { uid } from 'uid/single';
import { useDebouncedCallback, useThrottledCallback } from 'use-debounce';
@ -131,6 +130,7 @@ const SCAN_RE = new RegExp(
'g',
);
const segmenter = new Intl.Segmenter();
function highlightText(text, { maxCharacters = Infinity }) {
// Accept text string, return formatted HTML string
// Escape all HTML special characters
@ -143,19 +143,25 @@ function highlightText(text, { maxCharacters = Infinity }) {
// Exceeded characters limit
const { composerCharacterCount } = states;
let leftoverHTML = '';
if (composerCharacterCount > maxCharacters) {
// NOTE: runes2 substring considers surrogate pairs
// const leftoverCount = composerCharacterCount - maxCharacters;
// Highlight exceeded characters
leftoverHTML =
'<mark class="compose-highlight-exceeded">' +
// html.slice(-leftoverCount) +
substring(html, maxCharacters) +
'</mark>';
// html = html.slice(0, -leftoverCount);
html = substring(html, 0, maxCharacters);
return html + leftoverHTML;
let withinLimitHTML = '',
exceedLimitHTML = '';
const htmlSegments = segmenter.segment(html);
for (const { segment, index } of htmlSegments) {
if (index < maxCharacters) {
withinLimitHTML += segment;
} else {
exceedLimitHTML += segment;
}
}
if (exceedLimitHTML) {
exceedLimitHTML =
'<mark class="compose-highlight-exceeded">' +
exceedLimitHTML +
'</mark>';
}
return withinLimitHTML + exceedLimitHTML;
}
return html
@ -1254,7 +1260,6 @@ function Compose({
</div>
{showEmoji2Picker && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowEmoji2Picker(false);
@ -1558,7 +1563,7 @@ const Textarea = forwardRef((props, ref) => {
onKeyDown={(e) => {
// Get line before cursor position after pressing 'Enter'
const { key, target } = e;
if (key === 'Enter') {
if (key === 'Enter' && !(e.ctrlKey || e.metaKey)) {
try {
const { value, selectionStart } = target;
const textBeforeCursor = value.slice(0, selectionStart);
@ -1768,7 +1773,6 @@ function MediaAttachment({
</div>
{showModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowModal(false);

View file

@ -32,7 +32,7 @@ export default memo(function KeyboardShortcutsHelp() {
return (
!!snapStates.showKeyboardShortcutsHelp && (
<Modal class="light" onClose={onClose}>
<Modal onClose={onClose}>
<div id="keyboard-shortcuts-help-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />

View file

@ -15,7 +15,7 @@
text-shadow: 0 1px var(--bg-blur-color);
transition: opacity 0.3s ease-out;
&:not(#columns &) {
#trending-page &:not(#columns &) {
@media (min-width: 40em) {
width: 95vw;
max-width: calc(320px * 3.3);
@ -96,6 +96,7 @@
}
article {
width: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
@ -113,34 +114,34 @@
margin: 0 0 -16px;
padding: 0;
position: relative;
}
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
vertical-align: top;
mask-image: linear-gradient(
to bottom,
hsl(0, 0%, 0%) 0%,
hsla(0, 0%, 0%, 0.987) 14%,
hsla(0, 0%, 0%, 0.951) 26.2%,
hsla(0, 0%, 0%, 0.896) 36.8%,
hsla(0, 0%, 0%, 0.825) 45.9%,
hsla(0, 0%, 0%, 0.741) 53.7%,
hsla(0, 0%, 0%, 0.648) 60.4%,
hsla(0, 0%, 0%, 0.55) 66.2%,
hsla(0, 0%, 0%, 0.45) 71.2%,
hsla(0, 0%, 0%, 0.352) 75.6%,
hsla(0, 0%, 0%, 0.259) 79.6%,
hsla(0, 0%, 0%, 0.175) 83.4%,
hsla(0, 0%, 0%, 0.104) 87.2%,
hsla(0, 0%, 0%, 0.049) 91.1%,
hsla(0, 0%, 0%, 0.013) 95.3%,
hsla(0, 0%, 0%, 0) 100%
);
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
vertical-align: top;
mask-image: linear-gradient(
to bottom,
hsl(0, 0%, 0%) 0%,
hsla(0, 0%, 0%, 0.987) 14%,
hsla(0, 0%, 0%, 0.951) 26.2%,
hsla(0, 0%, 0%, 0.896) 36.8%,
hsla(0, 0%, 0%, 0.825) 45.9%,
hsla(0, 0%, 0%, 0.741) 53.7%,
hsla(0, 0%, 0%, 0.648) 60.4%,
hsla(0, 0%, 0%, 0.55) 66.2%,
hsla(0, 0%, 0%, 0.45) 71.2%,
hsla(0, 0%, 0%, 0.352) 75.6%,
hsla(0, 0%, 0%, 0.259) 79.6%,
hsla(0, 0%, 0%, 0.175) 83.4%,
hsla(0, 0%, 0%, 0.104) 87.2%,
hsla(0, 0%, 0%, 0.049) 91.1%,
hsla(0, 0%, 0%, 0.013) 95.3%,
hsla(0, 0%, 0%, 0) 100%
);
}
}
}
@ -187,5 +188,9 @@
overflow: hidden;
font-size: 90%;
}
hr {
margin: 4px 0;
}
}
}

View file

@ -54,6 +54,7 @@ const AltBadge = (props) => {
};
const MEDIA_CAPTION_LIMIT = 140;
const MEDIA_CAPTION_LIMIT_LONGER = 280;
export const isMediaCaptionLong = mem((caption) =>
caption?.length
? caption.length > MEDIA_CAPTION_LIMIT ||
@ -69,6 +70,7 @@ function Media({
showOriginal,
autoAnimate,
showCaption,
allowLongerCaption,
altIndex,
onClick = () => {},
}) {
@ -198,8 +200,15 @@ function Media({
};
const longDesc = isMediaCaptionLong(description);
const showInlineDesc =
let showInlineDesc =
!!showCaption && !showOriginal && !!description && !longDesc;
if (
allowLongerCaption &&
!showInlineDesc &&
description?.length <= MEDIA_CAPTION_LIMIT_LONGER
) {
showInlineDesc = true;
}
const Figure = !showInlineDesc
? Fragment
: (props) => {

View file

@ -3,11 +3,12 @@ import { FocusableItem } from '@szhsin/react-menu';
import Link from './link';
function MenuLink(props) {
const { className, disabled, ...restProps } = props;
return (
<FocusableItem>
<FocusableItem className={className} disabled={disabled}>
{({ ref, closeMenu }) => (
<Link
{...props}
{...restProps}
ref={ref}
onClick={({ detail }) =>
closeMenu(detail === 0 ? 'Enter' : undefined)

View file

@ -9,17 +9,18 @@
justify-content: center;
align-items: center;
background-color: var(--backdrop-color);
backdrop-filter: blur(24px);
animation: appear 0.5s var(--timing-function) both;
}
#modal-container > div .sheet {
transition: transform 0.3s var(--timing-function);
transform-origin: center bottom;
}
#modal-container > div:has(~ div) .sheet {
transform: scale(0.975);
}
#modal-container > .light {
backdrop-filter: saturate(0.75);
&.solid {
background-color: var(--backdrop-solid-color);
}
.sheet {
transition: transform 0.3s var(--timing-function);
transform-origin: center bottom;
}
&:has(~ div) .sheet {
transform: scale(0.975);
}
}

View file

@ -56,8 +56,19 @@ function Modal({ children, onClose, onClick, class: className }) {
}}
tabIndex="-1"
onFocus={(e) => {
if (e.target === e.currentTarget) {
modalRef.current?.querySelector?.('[tabindex="-1"]')?.focus?.();
try {
if (e.target === e.currentTarget) {
const focusElement =
modalRef.current?.querySelector('[tabindex="-1"]');
const isFocusable =
!!focusElement &&
getComputedStyle(focusElement)?.pointerEvents !== 'none';
if (focusElement && isFocusable) {
focusElement.focus();
}
}
} catch (err) {
console.error(err);
}
}}
>

View file

@ -15,6 +15,7 @@ import GenericAccounts from './generic-accounts';
import MediaAltModal from './media-alt-modal';
import MediaModal from './media-modal';
import Modal from './modal';
import ReportModal from './report-modal';
import ShortcutsSettings from './shortcuts-settings';
subscribe(states, (changes) => {
@ -34,7 +35,7 @@ export default function Modals() {
return (
<>
{!!snapStates.showCompose && (
<Modal>
<Modal class="solid">
<Compose
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
@ -108,7 +109,6 @@ export default function Modals() {
)}
{!!snapStates.showAccount && (
<Modal
class="light"
onClose={() => {
states.showAccount = false;
}}
@ -159,7 +159,6 @@ export default function Modals() {
)}
{!!snapStates.showShortcutsSettings && (
<Modal
class="light"
onClose={() => {
states.showShortcutsSettings = false;
}}
@ -171,7 +170,6 @@ export default function Modals() {
)}
{!!snapStates.showGenericAccounts && (
<Modal
class="light"
onClose={() => {
states.showGenericAccounts = false;
}}
@ -187,7 +185,6 @@ export default function Modals() {
)}
{!!snapStates.showMediaAlt && (
<Modal
class="light"
onClose={(e) => {
states.showMediaAlt = false;
}}
@ -203,6 +200,7 @@ export default function Modals() {
)}
{!!snapStates.showEmbedModal && (
<Modal
class="solid"
onClose={() => {
states.showEmbedModal = false;
}}
@ -218,6 +216,21 @@ export default function Modals() {
/>
</Modal>
)}
{!!snapStates.showReportModal && (
<Modal
onClose={() => {
states.showReportModal = false;
}}
>
<ReportModal
account={snapStates.showReportModal.account}
post={snapStates.showReportModal.post}
onClose={() => {
states.showReportModal = false;
}}
/>
</Modal>
)}
</>
);
}

View file

@ -1,6 +1,11 @@
import './nav-menu.css';
import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import {
ControlledMenu,
MenuDivider,
MenuItem,
SubMenu,
} from '@szhsin/react-menu';
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useLongPress } from 'use-long-press';
@ -130,7 +135,7 @@ function NavMenu(props) {
if (Date.now() - buttonClickTS.current < 300) {
return;
}
setMenuState(undefined);
// setMenuState(undefined);
},
}}
portal={{
@ -169,13 +174,17 @@ function NavMenu(props) {
<MenuLink to="/">
<Icon icon="home" size="l" /> <span>Home</span>
</MenuLink>
{authenticated && (
{authenticated ? (
<>
{showFollowing && (
<MenuLink to="/following">
<Icon icon="following" size="l" /> <span>Following</span>
</MenuLink>
)}
<MenuLink to="/catchup">
<Icon icon="history2" size="l" />
<span>Catch-up</span>
</MenuLink>
<MenuLink to="/mentions">
<Icon icon="at" size="l" /> <span>Mentions</span>
</MenuLink>
@ -188,44 +197,64 @@ function NavMenu(props) {
</sup>
)}
</MenuLink>
<MenuDivider />
<MenuLink to="/l">
<Icon icon="list" size="l" /> <span>Lists</span>
</MenuLink>
<MenuLink to="/ft">
<Icon icon="hashtag" size="l" /> <span>Followed Hashtags</span>
</MenuLink>
<MenuLink to="/b">
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
</MenuLink>
<MenuLink to="/f">
<Icon icon="heart" size="l" /> <span>Likes</span>
</MenuLink>
</>
)}
<MenuDivider />
<MenuLink to={`/search`}>
<Icon icon="search" size="l" /> <span>Search</span>
</MenuLink>
<MenuLink to={`/${instance}/p/l`}>
<Icon icon="building" size="l" /> <span>Local</span>
</MenuLink>
<MenuLink to={`/${instance}/p`}>
<Icon icon="earth" size="l" /> <span>Federated</span>
</MenuLink>
<MenuLink to={`/${instance}/trending`}>
<Icon icon="chart" size="l" /> <span>Trending</span>
</MenuLink>
</section>
<section>
{authenticated ? (
<>
<MenuDivider />
{currentAccount?.info?.id && (
<MenuLink to={`/${instance}/a/${currentAccount.info.id}`}>
<Icon icon="user" size="l" /> <span>Profile</span>
</MenuLink>
)}
<MenuLink to="/l">
<Icon icon="list" size="l" /> <span>Lists</span>
</MenuLink>
<MenuLink to="/b">
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
</MenuLink>
<SubMenu
overflow="auto"
gap={-8}
label={
<>
<Icon icon="more" size="l" />
<span class="menu-grow">More</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/f">
<Icon icon="heart" size="l" /> <span>Likes</span>
</MenuLink>
<MenuLink to="/ft">
<Icon icon="hashtag" size="l" />{' '}
<span>Followed Hashtags</span>
</MenuLink>
<MenuDivider />
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'mute',
heading: 'Muted users',
fetchAccounts: fetchMutes,
excludeRelationshipAttrs: ['muting'],
};
}}
>
<Icon icon="mute" size="l" /> Muted users&hellip;
</MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'block',
heading: 'Blocked users',
fetchAccounts: fetchBlocks,
excludeRelationshipAttrs: ['blocking'],
};
}}
>
<Icon icon="block" size="l" />
Blocked users&hellip;
</MenuItem>{' '}
</SubMenu>
<MenuDivider />
<MenuItem
onClick={() => {
states.showAccounts = true;
@ -233,31 +262,32 @@ function NavMenu(props) {
>
<Icon icon="group" size="l" /> <span>Accounts&hellip;</span>
</MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'mute',
heading: 'Muted users',
fetchAccounts: fetchMutes,
excludeRelationshipAttrs: ['muting'],
};
}}
>
<Icon icon="mute" size="l" /> Muted users&hellip;
</MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'block',
heading: 'Blocked users',
fetchAccounts: fetchBlocks,
excludeRelationshipAttrs: ['blocking'],
};
}}
>
<Icon icon="block" size="l" />
Blocked users&hellip;
</MenuItem>
</>
) : (
<>
<MenuDivider />
<MenuLink to="/login">
<Icon icon="user" size="l" /> <span>Log in</span>
</MenuLink>
</>
)}
</section>
<section>
<MenuDivider />
<MenuLink to={`/search`}>
<Icon icon="search" size="l" /> <span>Search</span>
</MenuLink>
<MenuLink to={`/${instance}/trending`}>
<Icon icon="chart" size="l" /> <span>Trending</span>
</MenuLink>
<MenuLink to={`/${instance}/p/l`}>
<Icon icon="building" size="l" /> <span>Local</span>
</MenuLink>
<MenuLink to={`/${instance}/p`}>
<Icon icon="earth" size="l" /> <span>Federated</span>
</MenuLink>
{authenticated ? (
<>
<MenuDivider className="divider-grow" />
<MenuItem
onClick={() => {
@ -286,9 +316,6 @@ function NavMenu(props) {
) : (
<>
<MenuDivider />
<MenuLink to="/login">
<Icon icon="user" size="l" /> <span>Log in</span>
</MenuLink>
<MenuItem
onClick={() => {
states.showSettings = true;

View file

@ -144,7 +144,6 @@ export default memo(function NotificationService() {
const { id, account, notification, sameInstance } = showNotificationSheet;
return (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();

View file

@ -0,0 +1,196 @@
.report-modal-container {
width: 100%;
max-height: 100%;
display: flex;
flex-direction: column;
max-width: 40em;
background-color: var(--bg-color);
box-shadow: 0 16px 32px -8px var(--drop-shadow-color);
overflow-y: auto;
animation: slide-up-smooth 0.3s ease-in-out;
position: relative;
@media (min-width: 40em) {
max-height: calc(100% - 32px);
}
h1 {
margin: 0;
padding: 0;
}
.top-controls {
position: sticky;
top: var(--sai-top, 0);
z-index: 1;
background-color: var(--bg-blur-color);
backdrop-filter: blur(16px);
padding: 16px;
display: flex;
gap: 8px;
justify-content: space-between;
pointer-events: auto;
align-items: center;
h1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
main {
padding: 0 16px 16px;
/* display: flex;
flex-direction: column;
gap: 16px; */
}
form {
/* display: flex; */
/* flex-direction: column; */
/* gap: 16px; */
text-wrap: pretty;
input {
margin-inline: 0;
}
}
.report-preview {
background-color: var(--bg-color);
border-radius: 8px;
border: 2px dashed var(--red-color);
box-shadow: inset 0 0 16px -4px var(--red-bg-color);
overflow: auto;
max-height: 33vh;
.status {
font-size: 90%;
user-select: none;
pointer-events: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
filter: grayscale(0.5);
}
.account-block {
margin: 16px;
user-select: none;
pointer-events: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
filter: grayscale(0.5);
}
}
.rubber-stamp {
pointer-events: none;
user-select: none;
position: absolute;
right: 32px;
margin-top: -48px;
animation: rubber-stamp 0.3s ease-in both;
position: absolute;
font-weight: bold;
color: var(--red-color);
text-transform: uppercase;
letter-spacing: -0.5px;
font-size: 2em;
line-height: 1;
padding: 0.1em;
border: 0.15em solid var(--red-color);
border-radius: 0.3em;
background-color: var(--bg-blur-color);
text-align: center;
/* Noise pattern - https://css-tricks.com/making-static-noise-from-a-weird-css-gradient-bug/ */
mask-image: repeating-conic-gradient(
#000 0 0.01%,
rgba(0, 0, 0, 0.45) 0 0.02%
);
small {
display: block;
font-size: 11px;
}
}
p {
margin-block: 0.5em;
}
section {
label {
display: flex;
gap: 8px;
align-items: center;
cursor: pointer;
margin-bottom: 8px;
&:has(:checked) {
.insignificant {
color: var(--text-color);
}
}
}
> label:last-child {
margin-bottom: 0;
}
}
.report-categories {
label {
align-items: flex-start;
}
.report-rules {
margin-left: 1.75em;
}
}
.report-comment {
display: flex;
gap: 8px;
align-items: flex-start;
margin-top: 2em;
flex-wrap: wrap;
p {
margin: 0;
padding: 8px 0 0;
flex-shrink: 0;
label {
margin-bottom: 0;
}
}
textarea {
flex-grow: 1;
resize: vertical;
}
}
footer {
margin-top: 2em;
display: flex;
gap: 8px;
align-items: center;
button {
border-radius: 8px !important;
align-self: stretch;
}
}
}
@keyframes rubber-stamp {
0% {
transform: rotate(-20deg) scale(5);
opacity: 0;
}
100% {
transform: rotate(-20deg) scale(1);
opacity: 1;
}
}

View file

@ -0,0 +1,298 @@
import './report-modal.css';
import { Fragment } from 'preact';
import { useMemo, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api';
import showToast from '../utils/show-toast';
import { getCurrentInstance } from '../utils/store-utils';
import AccountBlock from './account-block';
import Icon from './icon';
import Loader from './loader';
import Status from './status';
// NOTE: `dislike` hidden for now, it's actually not used for reporting
// Mastodon shows another screen for unfollowing, muting or blocking instead of reporting
const CATEGORIES = [, /*'dislike'*/ 'spam', 'legal', 'violation', 'other'];
// `violation` will be set if there are `rule_ids[]`
const CATEGORIES_INFO = {
// dislike: {
// label: 'Dislike',
// description: 'Not something you want to see',
// },
spam: {
label: 'Spam',
description: 'Malicious links, fake engagement, or repetitive replies',
},
legal: {
label: 'Illegal',
description: "Violates the law of your or the server's country",
},
violation: {
label: 'Server rule violation',
description: 'Breaks specific server rules',
stampLabel: 'Violation',
},
other: {
label: 'Other',
description: "Issue doesn't fit other categories",
excludeStamp: true,
},
};
function ReportModal({ account, post, onClose }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const [username, domain] = account.acct.split('@');
const [rules, currentDomain] = useMemo(() => {
const { rules, domain } = getCurrentInstance();
return [rules || [], domain];
});
const [selectedCategory, setSelectedCategory] = useState(null);
const [showRules, setShowRules] = useState(false);
const rulesRef = useRef(null);
const [hasRules, setHasRules] = useState(false);
return (
<div class="report-modal-container">
<div class="top-controls">
<h1>{post ? 'Report Post' : `Report @${username}`}</h1>
<button
type="button"
class="plain4 small"
disabled={uiState === 'loading'}
onClick={() => onClose()}
>
<Icon icon="x" size="xl" />
</button>
</div>
<main>
<div class="report-preview">
{post ? (
<Status status={post} size="s" previewMode />
) : (
<AccountBlock
account={account}
avatarSize="xxl"
useAvatarStatic
showStats
showActivity
/>
)}
</div>
{!!selectedCategory &&
!CATEGORIES_INFO[selectedCategory].excludeStamp && (
<span
class="rubber-stamp"
key={selectedCategory}
aria-hidden="true"
>
{CATEGORIES_INFO[selectedCategory].stampLabel ||
CATEGORIES_INFO[selectedCategory].label}
<small>Pending review</small>
</span>
)}
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
const entries = Object.fromEntries(formData.entries());
console.log('ENTRIES', entries);
let { category, comment, forward } = entries;
if (!comment) comment = undefined;
if (forward === 'on') forward = true;
const ruleIds =
category === 'violation'
? Object.entries(entries)
.filter(([key]) => key.startsWith('rule_ids'))
.map(([key, value]) => value)
: undefined;
const params = {
category,
comment,
forward,
ruleIds,
};
console.log('PARAMS', params);
setUIState('loading');
(async () => {
try {
await masto.v1.reports.create({
accountId: account.id,
statusIds: post?.id ? [post.id] : undefined,
category,
comment,
ruleIds,
forward,
});
setUIState('success');
showToast(post ? 'Post reported' : 'Profile reported');
onClose();
} catch (error) {
console.error(error);
setUIState('error');
showToast(
error?.message ||
(post
? 'Unable to report post'
: 'Unable to report profile'),
);
}
})();
}}
>
<p>
{post
? `What's the issue with this post?`
: `What's the issue with this profile?`}
</p>
<section class="report-categories">
{CATEGORIES.map((category) =>
category === 'violation' && !rules?.length ? null : (
<Fragment key={category}>
<label class="report-category">
<input
type="radio"
name="category"
value={category}
required
disabled={uiState === 'loading'}
onChange={(e) => {
setSelectedCategory(e.target.value);
setShowRules(e.target.value === 'violation');
}}
/>
<span>
{CATEGORIES_INFO[category].label} &nbsp;
<small class="ib insignificant">
{CATEGORIES_INFO[category].description}
</small>
</span>
</label>
{category === 'violation' && !!rules?.length && (
<div
class="shazam-container no-animation"
hidden={!showRules}
>
<div class="shazam-container-inner">
<div class="report-rules" ref={rulesRef}>
{rules.map((rule, i) => (
<label class="report-rule" key={rule.id}>
<input
type="checkbox"
name={`rule_ids[${i}]`}
value={rule.id}
required={showRules && !hasRules}
disabled={uiState === 'loading'}
onChange={(e) => {
const { checked } = e.target;
if (checked) {
setHasRules(true);
} else {
const checkedInputs =
rulesRef.current.querySelectorAll(
'input:checked',
);
if (!checkedInputs.length) {
setHasRules(false);
}
}
}}
/>
<span>{rule.text}</span>
</label>
))}
</div>
</div>
</div>
)}
</Fragment>
),
)}
</section>
<section class="report-comment">
<p>
<label for="report-comment">Additional info</label>
</p>
<textarea
maxlength="1000"
rows="1"
name="comment"
id="report-comment"
disabled={uiState === 'loading'}
/>
</section>
<section>
{domain !== currentDomain && (
<p>
<label>
<input
type="checkbox"
switch
name="forward"
disabled={uiState === 'loading'}
/>{' '}
<span>
Forward to <i>{domain}</i>
</span>
</label>
</p>
)}
</section>
<footer>
<button type="submit" disabled={uiState === 'loading'}>
Send Report
</button>{' '}
<button
type="submit"
class="plain2"
disabled={uiState === 'loading'}
onClick={async () => {
try {
await masto.v1.accounts.$select(account.id).mute(); // Infinite duration
showToast(`Muted ${username}`);
} catch (e) {
console.error(e);
showToast(`Unable to mute ${username}`);
}
// onSubmit will still run
}}
>
Send Report <small class="ib">+ Mute profile</small>
</button>{' '}
<button
type="submit"
class="plain2"
disabled={uiState === 'loading'}
onClick={async () => {
try {
await masto.v1.accounts.$select(account.id).block();
showToast(`Blocked ${username}`);
} catch (e) {
console.error(e);
showToast(`Unable to block ${username}`);
}
// onSubmit will still run
}}
>
Send Report <small class="ib">+ Block profile</small>
</button>
<Loader hidden={uiState !== 'loading'} />
</footer>
</form>
</main>
</div>
);
}
export default ReportModal;

View file

@ -153,6 +153,15 @@
}
#import-export-container section p {
margin: 8px 0;
&.field-button {
display: flex;
gap: 8px;
button {
flex-shrink: 0;
}
}
}
#import-export-container section details > summary {
cursor: pointer;
@ -182,3 +191,14 @@
font-size: 90%;
flex-shrink: 0;
}
#import-export-container {
footer {
font-size: 90%;
color: var(--text-insignificant-color);
.icon {
vertical-align: text-bottom;
}
}
}

View file

@ -17,6 +17,7 @@ import { fetchFollowedTags } from '../utils/followed-tags';
import pmem from '../utils/pmem';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import store from '../utils/store';
import AsyncText from './AsyncText';
import Icon from './icon';
@ -391,7 +392,11 @@ function ShortcutsSettings({ onClose }) {
</>
) : (
<div class="ui-state insignificant">
<p>No shortcuts yet. Tap on the Add shortcut button.</p>
<p>
{snapStates.settings.shortcutsViewMode === 'multi-column'
? 'No columns yet. Tap on the Add column button.'
: 'No shortcuts yet. Tap on the Add shortcut button.'}
</p>
<p>
Not sure what to add?
<br />
@ -418,7 +423,9 @@ function ShortcutsSettings({ onClose }) {
)}
<p class="insignificant">
{shortcuts.length >= SHORTCUTS_LIMIT &&
`Max ${SHORTCUTS_LIMIT} shortcuts`}
(snapStates.settings.shortcutsViewMode === 'multi-column'
? `Max ${SHORTCUTS_LIMIT} columns`
: `Max ${SHORTCUTS_LIMIT} shortcuts`)}
</p>
<p
style={{
@ -450,7 +457,6 @@ function ShortcutsSettings({ onClose }) {
</main>
{showForm && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowForm(false);
@ -474,7 +480,6 @@ function ShortcutsSettings({ onClose }) {
)}
{showImportExport && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowImportExport(false);
@ -716,6 +721,7 @@ function ShortcutForm({
}
function ImportExport({ shortcuts, onClose }) {
const { masto } = api();
const shortcutsStr = useMemo(() => {
if (!shortcuts) return '';
if (!shortcuts.filter(Boolean).length) return '';
@ -754,6 +760,8 @@ function ImportExport({ shortcuts, onClose }) {
}, [importShortcutStr]);
const hasCurrentSettings = states.shortcuts.length > 0;
const shortcutsImportFieldRef = useRef();
return (
<div id="import-export-container" class="sheet">
{!!onClose && (
@ -772,8 +780,9 @@ function ImportExport({ shortcuts, onClose }) {
<Icon icon="arrow-down-circle" size="l" class="insignificant" />{' '}
<span>Import</span>
</h3>
<p>
<p class="field-button">
<input
ref={shortcutsImportFieldRef}
type="text"
name="import"
placeholder="Paste shortcuts here"
@ -782,6 +791,53 @@ function ImportExport({ shortcuts, onClose }) {
setImportShortcutStr(e.target.value);
}}
/>
{states.settings.shortcutSettingsCloudImportExport && (
<button
type="button"
class="plain2 small"
disabled={importUIState === 'cloud-downloading'}
onClick={async () => {
setImportUIState('cloud-downloading');
const currentAccount = store.session.get('currentAccount');
showToast(
'Downloading saved shortcuts from instance server…',
);
try {
const relationships =
await masto.v1.accounts.relationships.fetch({
id: [currentAccount],
});
const relationship = relationships[0];
if (relationship) {
const { note = '' } = relationship;
if (
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test(
note,
)
) {
const settings = note.match(
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/,
)[1];
const { v, dt, data } = JSON.parse(settings);
shortcutsImportFieldRef.current.value = data;
shortcutsImportFieldRef.current.dispatchEvent(
new Event('input'),
);
}
}
setImportUIState('default');
} catch (e) {
console.error(e);
setImportUIState('error');
showToast('Unable to download shortcuts');
}
}}
title="Download shortcuts from instance server"
>
<Icon icon="cloud" />
<Icon icon="arrow-down" />
</button>
)}
</p>
{!!parsedImportShortcutStr &&
Array.isArray(parsedImportShortcutStr) && (
@ -991,8 +1047,64 @@ function ImportExport({ shortcuts, onClose }) {
<Icon icon="share" /> <span>Share</span>
</button>
)}{' '}
{states.settings.shortcutSettingsCloudImportExport && (
<button
type="button"
class="plain2"
disabled={importUIState === 'cloud-uploading'}
onClick={async () => {
setImportUIState('cloud-uploading');
const currentAccount = store.session.get('currentAccount');
try {
const relationships =
await masto.v1.accounts.relationships.fetch({
id: [currentAccount],
});
const relationship = relationships[0];
if (relationship) {
const { note = '' } = relationship;
// const newNote = `${note}\n\n\n$<phanpy-shortcuts-settings>{shortcutsStr}</phanpy-shortcuts-settings>`;
let newNote = '';
if (
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test(
note,
)
) {
const settingsJSON = JSON.stringify({
v: '1', // version
dt: Date.now(), // datetime stamp
data: shortcutsStr, // shortcuts settings string
});
newNote = note.replace(
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/,
`<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`,
);
} else {
newNote = `${note}\n\n\n<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`;
}
showToast('Saving shortcuts to instance server…');
await masto.v1.accounts
.$select(currentAccount)
.note.create({
comment: newNote,
});
setImportUIState('default');
showToast('Shortcuts saved');
}
} catch (e) {
console.error(e);
setImportUIState('error');
showToast('Unable to save shortcuts');
}
}}
title="Sync to instance server"
>
<Icon icon="cloud" />
<Icon icon="arrow-up" />
</button>
)}{' '}
{shortcutsStr.length > 0 && (
<small class="insignificant">
<small class="insignificant ib">
{shortcutsStr.length} characters
</small>
)}
@ -1008,6 +1120,14 @@ function ImportExport({ shortcuts, onClose }) {
</details>
)}
</section>
{states.settings.shortcutSettingsCloudImportExport && (
<footer>
<p>
<Icon icon="cloud" /> Import/export settings from/to instance
server (Very experimental)
</p>
</footer>
)}
</main>
</div>
);

View file

@ -404,6 +404,24 @@
font-size: 90%;
line-height: var(--avatar-size);
}
.status-filtered-badge.badge-meta {
margin-top: 6px;
flex-direction: row;
gap: 0.5em;
color: var(--text-color);
border-color: var(--text-color);
background-color: var(--bg-blur-color);
max-width: 100%;
> span + span {
position: static;
&:empty {
display: none;
}
}
}
}
.status .container {
@ -661,7 +679,9 @@
animation: none !important; */
}
}
.status .content-container.has-spoiler:not(.show-media) .spoiler-media-button {
.status
.content-container.has-spoiler:not(.show-media)
:is(.spoiler-button, .spoiler-media-button) {
~ :is(.media-container, .media-figure-multiple) figcaption {
/* filter: blur(5px) invert(0.5);
image-rendering: crisp-edges;
@ -1591,6 +1611,11 @@ a.card:is(:hover, :focus):visited {
.card.video {
max-width: 320px;
max-height: 320px;
cursor: pointer;
lite\-youtube {
pointer-events: none;
}
}
.card.video iframe {
width: 100%;
@ -1971,6 +1996,7 @@ a.card:is(:hover, :focus):visited {
}
.status:focus &,
.status:focus-within &,
&.open {
opacity: 1;
pointer-events: auto;
@ -2112,6 +2138,97 @@ a.card:is(:hover, :focus):visited {
pointer-events: none;
}
/* EMBED */
#embed-post {
> main > section {
p {
margin-block: 0.5em;
}
ul {
margin: 0;
padding-inline: 1em;
}
p + ul {
margin-top: 0;
padding-top: 0;
}
}
.embed-code {
width: 100%;
resize: vertical;
min-height: 12em;
max-height: 40vh;
font-family: var(--monospace-font);
font-size: 0.8em;
border-color: var(--link-color);
/* background-color: var(--bg-faded-color); */
}
.links-list {
a {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.embed-preview {
display: block;
max-height: 40vh;
overflow: auto;
font-size: 0.9em;
border: 2px dashed var(--link-light-color);
border-radius: 8px;
box-shadow: 0 4px 8px -4px var(--drop-shadow-color),
0 8px 32px -8px var(--drop-shadow-color);
padding: 16px;
/* Interactive elements */
button,
a,
video,
audio,
input,
select,
textarea,
iframe,
object,
embed {
pointer-events: none;
}
blockquote {
margin: 0 0 1em;
border-inline-start: 4px solid var(--outline-color);
padding-inline-start: 1em;
> p:first-child {
margin-top: 0;
}
}
ul,
ol {
margin-inline: 0;
padding-inline: 1em;
}
figure {
margin-inline: 0;
img,
video,
audio {
max-width: 100%;
height: auto;
}
}
}
}
/* DELETED */
.status-deleted {

View file

@ -10,6 +10,7 @@ import {
} from '@szhsin/react-menu';
import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash';
import { shallowEqual } from 'fast-equals';
import prettify from 'html-prettify';
import { memo } from 'preact/compat';
import {
useCallback,
@ -451,6 +452,7 @@ function Status({
]);
const [showEdited, setShowEdited] = useState(false);
const [showEmbed, setShowEmbed] = useState(false);
const spoilerContentRef = useTruncated();
const contentRef = useTruncated();
@ -559,12 +561,11 @@ function Status({
if (reblogged) {
const newStatus = await masto.v1.statuses.$select(id).unreblog();
saveStatus(newStatus, instance);
return true;
} else {
const newStatus = await masto.v1.statuses.$select(id).reblog();
saveStatus(newStatus, instance);
return true;
}
return true;
} catch (e) {
console.error(e);
// Revert optimistism
@ -575,7 +576,8 @@ function Status({
const favouriteStatus = async () => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
alert(unauthInteractionErrorMessage);
return false;
}
try {
// Optimistic
@ -591,16 +593,31 @@ function Status({
const newStatus = await masto.v1.statuses.$select(id).favourite();
saveStatus(newStatus, instance);
}
return true;
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
return false;
}
};
const favouriteStatusNotify = async () => {
try {
const done = await favouriteStatus();
if (!isSizeLarge && done) {
showToast(
favourited
? `Unliked @${username || acct}'s post`
: `Liked @${username || acct}'s post`,
);
}
} catch (e) {}
};
const bookmarkStatus = async () => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
alert(unauthInteractionErrorMessage);
return false;
}
try {
// Optimistic
@ -615,12 +632,26 @@ function Status({
const newStatus = await masto.v1.statuses.$select(id).bookmark();
saveStatus(newStatus, instance);
}
return true;
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
return false;
}
};
const bookmarkStatusNotify = async () => {
try {
const done = await bookmarkStatus();
if (!isSizeLarge && done) {
showToast(
bookmarked
? `Unbookmarked @${username || acct}'s post`
: `Bookmarked @${username || acct}'s post`,
);
}
} catch (e) {}
};
const differentLanguage =
!!language &&
@ -680,6 +711,8 @@ function Status({
}
const actionsRef = useRef();
const isPublic = ['public', 'unlisted'].includes(visibility);
const isPinnable = ['public', 'unlisted', 'private'].includes(visibility);
const StatusMenuItems = (
<>
{isSizeLarge && (
@ -752,18 +785,7 @@ function Status({
</span>
</MenuConfirm>
<MenuItem
onClick={() => {
try {
favouriteStatus();
if (!isSizeLarge) {
showToast(
favourited
? `Unliked @${username || acct}'s post`
: `Liked @${username || acct}'s post`,
);
}
} catch (e) {}
}}
onClick={favouriteStatusNotify}
className={`menu-favourite ${favourited ? 'checked' : ''}`}
>
<Icon icon="heart" />
@ -776,18 +798,7 @@ function Status({
</span>
</MenuItem>
<MenuItem
onClick={() => {
try {
bookmarkStatus();
if (!isSizeLarge) {
showToast(
bookmarked
? `Unbookmarked @${username || acct}'s post`
: `Bookmarked @${username || acct}'s post`,
);
}
} catch (e) {}
}}
onClick={bookmarkStatusNotify}
className={`menu-bookmark ${bookmarked ? 'checked' : ''}`}
>
<Icon icon="bookmark" />
@ -907,7 +918,8 @@ function Status({
<Icon icon="link" />
<span>Copy</span>
</MenuItem>
{navigator?.share &&
{isPublic &&
navigator?.share &&
navigator?.canShare?.({
url,
}) && (
@ -928,6 +940,16 @@ function Status({
</MenuItem>
)}
</div>
{isPublic && isSizeLarge && (
<MenuItem
onClick={() => {
setShowEmbed(true);
}}
>
<Icon icon="code" />
<span>Embed</span>
</MenuItem>
)}
{(isSelf || mentionSelf) && <MenuDivider />}
{(isSelf || mentionSelf) && (
<MenuItem
@ -961,7 +983,7 @@ function Status({
)}
</MenuItem>
)}
{isSelf && /(public|unlisted|private)/i.test(visibility) && (
{isSelf && isPinnable && (
<MenuItem
onClick={async () => {
try {
@ -1040,6 +1062,23 @@ function Status({
)}
</div>
)}
{!isSelf && isSizeLarge && (
<>
<MenuDivider />
<MenuItem
className="danger"
onClick={() => {
states.showReportModal = {
account: status.account,
post: status,
};
}}
>
<Icon icon="flag" />
<span>Report post</span>
</MenuItem>
</>
)}
</>
);
@ -1061,7 +1100,7 @@ function Status({
const { clientX, clientY } = e.touches?.[0] || e;
// link detection copied from onContextMenu because here it works
const link = e.target.closest('a');
if (link && /^https?:\/\//.test(link.getAttribute('href'))) return;
if (link && statusRef.current.contains(link)) return;
e.preventDefault();
setContextMenuProps({
anchorPoint: {
@ -1085,42 +1124,12 @@ function Status({
const rRef = useHotkeys('r, shift+r', replyStatus, {
enabled: hotkeysEnabled,
});
const fRef = useHotkeys(
'f, l',
() => {
try {
favouriteStatus();
if (!isSizeLarge) {
showToast(
favourited
? `Unliked @${username || acct}'s post`
: `Liked @${username || acct}'s post`,
);
}
} catch (e) {}
},
{
enabled: hotkeysEnabled,
},
);
const dRef = useHotkeys(
'd',
() => {
try {
bookmarkStatus();
if (!isSizeLarge) {
showToast(
bookmarked
? `Unbookmarked @${username || acct}'s post`
: `Bookmarked @${username || acct}'s post`,
);
}
} catch (e) {}
},
{
enabled: hotkeysEnabled,
},
);
const fRef = useHotkeys('f, l', favouriteStatusNotify, {
enabled: hotkeysEnabled,
});
const dRef = useHotkeys('d', bookmarkStatusNotify, {
enabled: hotkeysEnabled,
});
const bRef = useHotkeys(
'shift+b',
() => {
@ -1337,7 +1346,7 @@ function Status({
if (e.metaKey) return;
// console.log('context menu', e);
const link = e.target.closest('a');
if (link && /^https?:\/\//.test(link.getAttribute('href'))) return;
if (link && statusRef.current.contains(link)) return;
// If there's selected text, don't show custom context menu
const selection = window.getSelection?.();
@ -1420,16 +1429,7 @@ function Status({
icon="heart"
iconSize="m"
count={favouritesCount}
onClick={() => {
try {
favouriteStatus();
showToast(
favourited
? `Unliked @${username || acct}'s post`
: `Liked @${username || acct}'s post`,
);
} catch (e) {}
}}
onClick={favouriteStatusNotify}
/>
<button
type="button"
@ -1798,6 +1798,7 @@ function Status({
media={media}
autoAnimate={isSizeLarge}
showCaption={mediaAttachments.length === 1}
allowLongerCaption={!content}
lang={language}
altIndex={
showMultipleMediaCaptions &&
@ -1987,7 +1988,6 @@ function Status({
</div>
{!!showEdited && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowEdited(false);
@ -2008,6 +2008,23 @@ function Status({
/>
</Modal>
)}
{!!showEmbed && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowEmbed(false);
}
}}
>
<EmbedModal
post={status}
instance={instance}
onClose={() => {
setShowEmbed(false);
}}
/>
</Modal>
)}
</article>
</>
);
@ -2195,7 +2212,11 @@ function Card({ card, selfReferential, instance }) {
// Get ID from e.g. https://www.youtube.com/watch?v=[VIDEO_ID]
const videoID = url.match(/watch\?v=([^&]+)/)?.[1];
if (videoID) {
return <lite-youtube videoid={videoID} nocookie></lite-youtube>;
return (
<a class="card video" onClick={handleClick}>
<lite-youtube videoid={videoID} nocookie></lite-youtube>
</a>
);
}
}
// return (
@ -2308,6 +2329,360 @@ function EditedAtModal({
);
}
function generateHTMLCode(post, instance, level = 0) {
const {
account: {
url: accountURL,
displayName,
acct,
username,
emojis: accountEmojis,
bot,
group,
},
id,
poll,
spoilerText,
language,
editedAt,
createdAt,
content,
mediaAttachments,
url,
emojis,
} = post;
const sKey = statusKey(id, instance);
const quotes = states.statusQuotes[sKey] || [];
const uniqueQuotes = quotes.filter(
(q, i, arr) => arr.findIndex((q2) => q2.url === q.url) === i,
);
const quoteStatusesHTML =
uniqueQuotes.length && level <= 2
? uniqueQuotes
.map((quote) => {
const { id, instance } = quote;
const sKey = statusKey(id, instance);
const s = states.statuses[sKey];
if (s) {
return generateHTMLCode(s, instance, ++level);
}
})
.join('')
: '';
const createdAtDate = new Date(createdAt);
// const editedAtDate = editedAt && new Date(editedAt);
const contentHTML =
emojifyText(content, emojis) +
'\n' +
quoteStatusesHTML +
'\n' +
(poll?.options?.length
? `
<p>📊:</p>
<ul>
${poll.options
.map(
(option) => `
<li>
${option.title}
${option.votesCount >= 0 ? ` (${option.votesCount})` : ''}
</li>
`,
)
.join('')}
</ul>`
: '') +
(mediaAttachments.length > 0
? '\n' +
mediaAttachments
.map((media) => {
const {
description,
meta,
previewRemoteUrl,
previewUrl,
remoteUrl,
url,
type,
} = media;
const { original = {}, small } = meta || {};
const width = small?.width || original?.width;
const height = small?.height || original?.height;
// Prefer remote over original
const sourceMediaURL = remoteUrl || url;
const previewMediaURL = previewRemoteUrl || previewUrl;
const mediaURL = previewMediaURL || sourceMediaURL;
const sourceMediaURLObj = sourceMediaURL
? new URL(sourceMediaURL)
: null;
const isVideoMaybe =
type === 'unknown' &&
sourceMediaURLObj &&
/\.(mp4|m4r|m4v|mov|webm)$/i.test(sourceMediaURLObj.pathname);
const isAudioMaybe =
type === 'unknown' &&
sourceMediaURLObj &&
/\.(mp3|ogg|wav|m4a|m4p|m4b)$/i.test(sourceMediaURLObj.pathname);
const isImage =
type === 'image' ||
(type === 'unknown' &&
previewMediaURL &&
!isVideoMaybe &&
!isAudioMaybe);
const isVideo = type === 'gifv' || type === 'video' || isVideoMaybe;
const isAudio = type === 'audio' || isAudioMaybe;
let mediaHTML = '';
if (isImage) {
mediaHTML = `<img src="${mediaURL}" width="${width}" height="${height}" alt="${description}" loading="lazy" />`;
} else if (isVideo) {
mediaHTML = `
<video src="${sourceMediaURL}" width="${width}" height="${height}" controls preload="auto" poster="${previewMediaURL}" loading="lazy"></video>
${description ? `<figcaption>${description}</figcaption>` : ''}
`;
} else if (isAudio) {
mediaHTML = `
<audio src="${sourceMediaURL}" controls preload="auto"></audio>
${description ? `<figcaption>${description}</figcaption>` : ''}
`;
} else {
mediaHTML = `
<a href="${sourceMediaURL}">📄 ${
description || sourceMediaURL
}</a>
`;
}
return `<figure>${mediaHTML}</figure>`;
})
.join('\n')
: '');
const htmlCode = `
<blockquote lang="${language}" cite="${url}">
${
spoilerText
? `
<details>
<summary>${spoilerText}</summary>
${contentHTML}
</details>
`
: contentHTML
}
<footer>
${emojifyText(
displayName,
accountEmojis,
)} (@${acct}) <a href="${url}"><time datetime="${createdAtDate.toISOString()}">${createdAtDate.toLocaleString()}</time></a>
</footer>
</blockquote>
`;
return prettify(htmlCode);
}
function EmbedModal({ post, instance, onClose }) {
const {
account: {
url: accountURL,
displayName,
username,
emojis: accountEmojis,
bot,
group,
},
id,
poll,
spoilerText,
language,
editedAt,
createdAt,
content,
mediaAttachments,
url,
emojis,
} = post;
const htmlCode = generateHTMLCode(post, instance);
return (
<div id="embed-post" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header>
<h2>Embed post</h2>
</header>
<main tabIndex="-1">
<h3>HTML Code</h3>
<textarea
class="embed-code"
readonly
onClick={(e) => {
e.target.select();
}}
>
{htmlCode}
</textarea>
<button
type="button"
onClick={() => {
try {
navigator.clipboard.writeText(htmlCode);
showToast('HTML code copied');
} catch (e) {
console.error(e);
showToast('Unable to copy HTML code');
}
}}
>
<Icon icon="clipboard" /> <span>Copy</span>
</button>
{!!mediaAttachments?.length && (
<section>
<p>Media attachments:</p>
<ol class="links-list">
{mediaAttachments.map((media) => {
return (
<li key={media.id}>
<a
href={media.remoteUrl || media.url}
target="_blank"
download
>
{media.remoteUrl || media.url}
</a>
</li>
);
})}
</ol>
</section>
)}
{!!accountEmojis?.length && (
<section>
<p>Account Emojis:</p>
<ul class="links-list">
{accountEmojis.map((emoji) => {
return (
<li key={emoji.shortcode}>
<picture>
<source
srcset={emoji.staticUrl}
media="(prefers-reduced-motion: reduce)"
></source>
<img
class="shortcode-emoji emoji"
src={emoji.url}
alt={`:${emoji.shortcode}:`}
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>{' '}
<code>:{emoji.shortcode}:</code> (
<a href={emoji.url} target="_blank" download>
url
</a>
)
{emoji.staticUrl ? (
<>
{' '}
(
<a href={emoji.staticUrl} target="_blank" download>
static
</a>
)
</>
) : null}
</li>
);
})}
</ul>
</section>
)}
{!!emojis?.length && (
<section>
<p>Emojis:</p>
<ul class="links-list">
{emojis.map((emoji) => {
return (
<li key={emoji.shortcode}>
<picture>
<source
srcset={emoji.staticUrl}
media="(prefers-reduced-motion: reduce)"
></source>
<img
class="shortcode-emoji emoji"
src={emoji.url}
alt={`:${emoji.shortcode}:`}
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>{' '}
<code>:{emoji.shortcode}:</code> (
<a href={emoji.url} target="_blank" download>
url
</a>
)
{emoji.staticUrl ? (
<>
{' '}
(
<a href={emoji.staticUrl} target="_blank" download>
static
</a>
)
</>
) : null}
</li>
);
})}
</ul>
</section>
)}
<section>
<small>
<p>Notes:</p>
<ul>
<li>
This is static, unstyled and scriptless. You may need to apply
your own styles and edit as needed.
</li>
<li>
Polls are not interactive, becomes a list with vote counts.
</li>
<li>
Media attachments can be images, videos, audios or any file
types.
</li>
<li>Post could be edited or deleted later.</li>
</ul>
</small>
</section>
<h3>Preview</h3>
<output
class="embed-preview"
dangerouslySetInnerHTML={{ __html: htmlCode }}
/>
<p>
<small>Note: This preview is lightly styled.</small>
</p>
</main>
</div>
);
}
function StatusButton({
checked,
count,
@ -2420,13 +2795,21 @@ function StatusCompact({ sKey }) {
visibility,
content,
language,
filtered,
} = status;
if (sensitive || spoilerText) return null;
if (!content) return null;
const srKey = statusKey(id, instance);
const statusPeekText = statusPeek(status);
const filterContext = useContext(FilterContext);
const filterInfo = isFiltered(filtered, filterContext);
if (filterInfo?.action === 'hide') return null;
const filterTitleStr = filterInfo?.titlesStr || '';
return (
<article
class={`status compact-reply ${
@ -2442,7 +2825,14 @@ function StatusCompact({ sKey }) {
lang={language}
dir="auto"
>
{statusPeekText}
{filterInfo ? (
<b class="status-filtered-badge badge-meta" title={filterTitleStr}>
<span>Filtered</span>
<span>{filterTitleStr}</span>
</b>
) : (
<span>{statusPeekText}</span>
)}
</div>
</article>
);
@ -2564,7 +2954,6 @@ function FilteredStatus({
</article>
{!!showPeek && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowPeek(false);

View file

@ -69,7 +69,7 @@
--outline-color: rgba(128, 128, 128, 0.2);
--outline-hover-color: rgba(128, 128, 128, 0.7);
--divider-color: rgba(0, 0, 0, 0.1);
--backdrop-color: rgba(0, 0, 0, 0.05);
--backdrop-color: rgba(0, 0, 0, 0.1);
--backdrop-darker-color: rgba(0, 0, 0, 0.25);
--backdrop-solid-color: #eee;
--img-bg-color: rgba(128, 128, 128, 0.2);
@ -267,6 +267,14 @@ button[hidden] {
:is(button, .button).plain5:not(:disabled, .disabled):is(:hover, :focus) {
text-decoration: underline;
}
:is(button, .button).plain6 {
background-color: var(--bg-blur-color);
color: var(--link-color);
border: 1px solid var(--link-color);
}
:is(button, .button).plain6:not(:disabled, .disabled):is(:hover, :focus) {
background-color: var(--link-bg-color);
}
:is(button, .button).light {
background-color: var(--bg-faded-color);
color: var(--text-color);

View file

@ -2,6 +2,9 @@ import './index.css';
import './cloak-mode.css';
// Polyfill needed for Firefox < 122
// https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
import '@formatjs/intl-segmenter/polyfill';
import { render } from 'preact';
import { HashRouter } from 'react-router-dom';

View file

@ -259,27 +259,21 @@ function AccountStatuses() {
const { displayName, acct, emojis } = account || {};
const accountInfoMemo = useMemo(() => {
const cachedAccount = snapStates.accounts[`${id}@${instance}`];
return (
<AccountInfo
instance={instance}
account={cachedAccount || id}
fetchAccount={fetchAccount}
authenticated={authenticated}
standalone
/>
);
}, [id, instance, authenticated, fetchAccount]);
const filterBarRef = useRef();
const TimelineStart = useMemo(() => {
const filtered =
!excludeReplies || excludeBoosts || tagged || media || !!month;
const cachedAccount = snapStates.accounts[`${id}@${instance}`];
return (
<>
{accountInfoMemo}
<AccountInfo
instance={instance}
account={cachedAccount || id}
fetchAccount={fetchAccount}
authenticated={authenticated}
standalone
/>
<div
class="filter-bar"
ref={filterBarRef}
@ -418,6 +412,7 @@ function AccountStatuses() {
instance,
authenticated,
featuredTags,
fetchAccount,
searchEnabled,
...allSearchParams,
]);

1082
src/pages/catchup.css Normal file

File diff suppressed because it is too large Load diff

1704
src/pages/catchup.jsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -143,7 +143,6 @@ function List(props) {
/>
{showListAddEditModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowListAddEditModal(false);
@ -167,7 +166,6 @@ function List(props) {
)}
{showManageMembersModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowManageMembersModal(false);

View file

@ -108,7 +108,6 @@ function Lists() {
</div>
{showListAddEditModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowListAddEditModal(false);

View file

@ -57,13 +57,14 @@
width: fit-content;
margin: -0.25em auto 0;
line-height: 1;
z-index: 1;
position: relative;
background-color: var(--bg-blur-color);
/* background-image: linear-gradient(
to bottom,
var(--bg-color),
var(--bg-blur-color)
); */
backdrop-filter: blur(16px) saturate(3);
padding: 2px 4px;
border-radius: 999px;
overflow: hidden;

View file

@ -433,7 +433,7 @@ function Settings({ onClose }) {
</div>
</div>
</li>
{!!IMG_ALT_API_URL && (
{!!IMG_ALT_API_URL && authenticated && (
<li>
<label>
<input
@ -464,6 +464,39 @@ function Settings({ onClose }) {
</div>
</li>
)}
{authenticated && (
<li>
<label>
<input
type="checkbox"
checked={
snapStates.settings.shortcutSettingsCloudImportExport
}
onChange={(e) => {
states.settings.shortcutSettingsCloudImportExport =
e.target.checked;
}}
/>{' '}
"Cloud" import/export for shortcuts settings{' '}
<Icon icon="cloud" class="more-insignificant" />
</label>
<div class="sub-section insignificant">
<small>
Very experimental.
<br />
Stored in your own profiles notes. Profile (private) notes
are mainly used for other profiles, and hidden for own
profile.
</small>
</div>
<div class="sub-section insignificant">
<small>
Note: This feature uses currently-logged-in instance server
API.
</small>
</div>
</li>
)}
<li>
<label>
<input

View file

@ -906,7 +906,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
!!heroStatus?.repliesCount &&
!hasDescendants && (
<div class="status-loading">
<Loader />
<Loader abrupt={heroStatus.repliesCount >= 3} />
</div>
)}
{uiState === 'error' &&

View file

@ -1,4 +1,4 @@
import './trending.css';
import '../components/links-bar.css';
import { MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash';
@ -67,7 +67,7 @@ function Trending({ columnMode, ...props }) {
// Get links
try {
const { value } = await fetchLinks(masto);
const { value } = await fetchLinks(masto, instance);
// 4 types available: link, photo, video, rich
// Only want links for now
const links = value?.filter?.((link) => link.type === 'link');

View file

@ -9,20 +9,20 @@ import {
set,
} from 'idb-keyval';
const draftsStore = createStore('drafts-db', 'drafts-store');
// Add additonal `draftsStore` parameter to all methods
const drafts = {
set: (key, val) => set(key, val, draftsStore),
get: (key) => get(key, draftsStore),
getMany: (keys) => getMany(keys, draftsStore),
del: (key) => del(key, draftsStore),
delMany: (keys) => delMany(keys, draftsStore),
clear: () => clear(draftsStore),
keys: () => keys(draftsStore),
};
function initDB(dbName, storeName) {
const store = createStore(dbName, storeName);
return {
set: (key, val) => set(key, val, store),
get: (key) => get(key, store),
getMany: (keys) => getMany(keys, store),
del: (key) => del(key, store),
delMany: (keys) => delMany(keys, store),
clear: () => clear(store),
keys: () => keys(store),
};
}
export default {
drafts,
drafts: initDB('drafts-db', 'drafts-store'),
catchup: initDB('catchup-db', 'catchup-store'),
};

View file

@ -52,6 +52,7 @@ const states = proxy({
showGenericAccounts: false,
showMediaAlt: false,
showEmbedModal: false,
showReportModal: false,
// Shortcuts
shortcuts: [],
// Settings
@ -64,6 +65,7 @@ const states = proxy({
contentTranslationTargetLanguage: null,
contentTranslationHideLanguages: [],
contentTranslationAutoInline: false,
shortcutSettingsCloudImportExport: false,
mediaAltGenerator: false,
cloakMode: false,
},
@ -93,6 +95,8 @@ export function initStates() {
store.account.get('settings-contentTranslationHideLanguages') || [];
states.settings.contentTranslationAutoInline =
store.account.get('settings-contentTranslationAutoInline') ?? false;
states.settings.shortcutSettingsCloudImportExport =
store.account.get('settings-shortcutSettingsCloudImportExport') ?? false;
states.settings.mediaAltGenerator =
store.account.get('settings-mediaAltGenerator') ?? false;
states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false;
@ -120,6 +124,9 @@ subscribe(states, (changes) => {
if (path.join('.') === 'settings.contentTranslationAutoInline') {
store.account.set('settings-contentTranslationAutoInline', !!value);
}
if (path.join('.') === 'settings.shortcutSettingsCloudImportExport') {
store.account.set('settings-shortcutSettingsCloudImportExport', !!value);
}
if (path.join('.') === 'settings.contentTranslationTargetLanguage') {
console.log('SET', value);
store.account.set('settings-contentTranslationTargetLanguage', value);

View file

@ -37,6 +37,7 @@ const rollbarCode = fs.readFileSync(
export default defineConfig({
base: './',
envPrefix: allowedEnvPrefixes,
appType: 'mpa',
mode: NODE_ENV,
define: {
__BUILD_TIME__: JSON.stringify(now),
@ -93,6 +94,7 @@ export default defineConfig({
purpose: 'maskable',
},
],
categories: ['social', 'news'],
},
strategies: 'injectManifest',
injectRegister: 'inline',
@ -115,6 +117,9 @@ export default defineConfig({
compose: resolve(__dirname, 'compose/index.html'),
},
output: {
manualChunks: {
'intl-segmenter-polyfill': ['@formatjs/intl-segmenter/polyfill'],
},
chunkFileNames: (chunkInfo) => {
const { facadeModuleId } = chunkInfo;
if (facadeModuleId && facadeModuleId.includes('icon')) {