Compare commits
119 commits
8cc8e03d66
...
9d09e32f24
Author | SHA1 | Date | |
---|---|---|---|
Alexander Yakovlev | 9d09e32f24 | ||
5695b3ca1e | |||
15c113ecb1 | |||
4a75d6f172 | |||
8f43099840 | |||
a2743f9940 | |||
4c2210c68b | |||
da909e4084 | |||
552ad249e5 | |||
9a5704ee95 | |||
c7f68c8971 | |||
e8219e458d | |||
6157ee105c | |||
4718ef36b0 | |||
2723ef4593 | |||
d1965a84b5 | |||
c7762cc56f | |||
cf05568e0c | |||
69c47489e3 | |||
861ad83423 | |||
cd3ed64e48 | |||
2e28c147b9 | |||
fef033b282 | |||
3dbbba0be2 | |||
0b8cbbef51 | |||
f72ec0aba5 | |||
d63e6c87c4 | |||
f5ea96a093 | |||
0e1be5dbdc | |||
4843970e1b | |||
a0367f4860 | |||
687a08b2a4 | |||
ac07479edd | |||
306a96eec3 | |||
061d769901 | |||
cf1c10b338 | |||
7f6ef4ff96 | |||
ce190cbc50 | |||
e7e4f15234 | |||
c005745ad0 | |||
0b81b5bfd2 | |||
b48d32e503 | |||
ed309b289f | |||
ecc5fc5bbe | |||
7eb77f5d1b | |||
3f4832965d | |||
b7ed27ef70 | |||
c9a48cf482 | |||
c0ad216227 | |||
8a9f1a3c25 | |||
375c4b5d00 | |||
f522d8e932 | |||
bd46af6166 | |||
29e9e15d3f | |||
42dac0720f | |||
d348c458b3 | |||
427207ae5a | |||
531147cbc3 | |||
e0c2570875 | |||
2b2f6c28a9 | |||
4a9cae9cb6 | |||
c578b41105 | |||
cfdbecc608 | |||
7c81548320 | |||
8cab77415e | |||
8b36cef510 | |||
4e67edac5e | |||
0bf5ef52ac | |||
7a7d51f56e | |||
48e1a0753a | |||
195c2e2960 | |||
60c0d1cca0 | |||
6292557bc9 | |||
b79ce92aef | |||
6bb6b9c350 | |||
0b4c720153 | |||
02d1339b29 | |||
Alexander Yakovlev | 2197dac514 | ||
93c871353a | |||
641d22a7cc | |||
0fd378811f | |||
afb1f6d520 | |||
fcb0074f49 | |||
8108151fb6 | |||
d8b0adfe97 | |||
cef4e6373e | |||
4d138f5773 | |||
0db10bf7d0 | |||
7ab6da5e9b | |||
beed3ca18c | |||
abd5031602 | |||
346dba9ed7 | |||
0ceb6ffd06 | |||
488aece050 | |||
ecde88d6a1 | |||
94dcd1606a | |||
b479fa1f35 | |||
ab0472de02 | |||
1bf8616957 | |||
631333ba9e | |||
69d77c368e | |||
bb3621e424 | |||
e1447053b3 | |||
aaf64bbc34 | |||
52b60fa38b | |||
3acfc00ec0 | |||
f8b5e9563c | |||
6f3f83a620 | |||
315ce98511 | |||
3cfc35898b | |||
ffc216cfed | |||
35e34c0bc6 | |||
b023a43fee | |||
44f6d9cda0 | |||
c466e0c279 | |||
fa99debabd | |||
58778aba45 | |||
b913c8817d | |||
ffb7ce1c63 |
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -10,6 +10,7 @@ assignees: ''
|
|||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
- Which site: [e.g. dev.phanpy.social OR phanpy.social]
|
||||
- Which site version: [On Phanpy, go to Settings -> About]
|
||||
- Which instance: [e.g. mastodon.social]
|
||||
|
||||
**To Reproduce**
|
||||
|
|
|
@ -200,6 +200,7 @@ These are self-hosted by other wonderful folks.
|
|||
- [phanpy.bauxite.tech](https://phanpy.bauxite.tech) by [@b4ux1t3@hachyderm.io](https://hachyderm.io/@b4ux1t3)
|
||||
- [phanpy.hear-me.social](https://phanpy.hear-me.social) by [@admin@hear-me.social](https://hear-me.social/@admin)
|
||||
- [phanpy.fulda.social](https://phanpy.fulda.social) by [@Ganneff@fulda.social](https://fulda.social/@Ganneff)
|
||||
- [phanpy.crmbl.uk](https://phanpy.crmbl.uk) by [@snail@crmbl.uk](https://mstdn.crmbl.uk/@snail)
|
||||
|
||||
> Note: Add yours by creating a pull request.
|
||||
|
||||
|
|
571
package-lock.json
generated
571
package-lock.json
generated
File diff suppressed because it is too large
Load diff
17
package.json
17
package.json
|
@ -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,27 +36,26 @@
|
|||
"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",
|
||||
"@preact/preset-vite": "~2.8.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "~4.3.0",
|
||||
"postcss": "~8.4.35",
|
||||
"postcss-dark-theme-class": "~1.2.1",
|
||||
"postcss-preset-env": "~9.4.0",
|
||||
"postcss-preset-env": "~9.5.1",
|
||||
"twitter-text": "~3.1.0",
|
||||
"vite": "~5.1.4",
|
||||
"vite": "~5.1.6",
|
||||
"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.4",
|
||||
"vite-plugin-remove-console": "~2.2.0",
|
||||
"workbox-cacheable-response": "~7.0.0",
|
||||
"workbox-expiration": "~7.0.0",
|
||||
|
|
32
public/404.html
Normal file
32
public/404.html
Normal 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>
|
|
@ -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',
|
||||
|
|
13
src/app.css
13
src/app.css
|
@ -34,6 +34,8 @@ a.mention span {
|
|||
text-decoration-color: inherit;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 2px;
|
||||
font-variant-numeric: slashed-zero;
|
||||
font-feature-settings: 'ss01';
|
||||
}
|
||||
/* a.mention:has(span).hashtag {
|
||||
color: var(--link-light-color);
|
||||
|
@ -1757,7 +1759,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);
|
||||
|
@ -1789,6 +1791,7 @@ body > .szh-menu-container {
|
|||
animation-duration: 0.3s;
|
||||
animation-timing-function: ease-in-out;
|
||||
width: auto;
|
||||
min-width: min(12em, 90vw);
|
||||
}
|
||||
.szh-menu .footer {
|
||||
margin: 8px 0 -8px;
|
||||
|
@ -1925,6 +1928,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
|
||||
|
@ -2669,6 +2676,10 @@ ul.link-list li a .icon {
|
|||
box-shadow: 0px 1px var(--bg-blur-color);
|
||||
transition: transform 0.4s var(--timing-function);
|
||||
--back-transition: transform 0.4s ease-out;
|
||||
|
||||
&:is(:empty, :has(> a:empty)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.timeline:not(.flat) > li > a {
|
||||
border-radius: inherit;
|
||||
|
|
21
src/app.jsx
21
src/app.jsx
|
@ -1,6 +1,7 @@
|
|||
import './app.css';
|
||||
|
||||
import debounce from 'just-debounce-it';
|
||||
import { lazy, Suspense } from 'preact/compat';
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
|
@ -17,14 +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 Catchup from './pages/catchup';
|
||||
import Favourites from './pages/favourites';
|
||||
import FollowedHashtags from './pages/followed-hashtags';
|
||||
import Following from './pages/following';
|
||||
|
@ -55,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 = [
|
||||
|
@ -382,7 +386,9 @@ function App() {
|
|||
)}
|
||||
{isLoggedIn && <ComposeButton />}
|
||||
{isLoggedIn && <Shortcuts />}
|
||||
<Modals />
|
||||
<Suspense>
|
||||
<Modals />
|
||||
</Suspense>
|
||||
{isLoggedIn && <NotificationService />}
|
||||
<BackgroundService isLoggedIn={isLoggedIn} />
|
||||
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
|
||||
|
@ -458,7 +464,14 @@ function SecondaryRoutes({ isLoggedIn }) {
|
|||
<Route path=":id" element={<List />} />
|
||||
</Route>
|
||||
<Route path="/ft" element={<FollowedHashtags />} />
|
||||
<Route path="/catchup" element={<Catchup />} />
|
||||
<Route
|
||||
path="/catchup"
|
||||
element={
|
||||
<Suspense>
|
||||
<Catchup />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
|
||||
|
|
BIN
src/assets/features/catch-up.png
Normal file
BIN
src/assets/features/catch-up.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
|
@ -8,12 +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,
|
||||
.post-peek-html * {
|
||||
.catchup-filters .filter-author *,
|
||||
.post-peek-html *,
|
||||
.post-peek-content > * {
|
||||
text-decoration-thickness: 1.1em;
|
||||
text-decoration-line: line-through;
|
||||
/* text-rendering: optimizeSpeed; */
|
||||
|
@ -21,7 +23,8 @@ 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;
|
||||
}
|
||||
|
||||
|
@ -39,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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -98,7 +98,11 @@ 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'),
|
||||
quote: () => import('@iconify-icons/mingcute/quote-left-line'),
|
||||
settings: () => import('@iconify-icons/mingcute/settings-6-line'),
|
||||
};
|
||||
|
|
|
@ -33,7 +33,7 @@ function AccountBlock({
|
|||
<span>
|
||||
<b>████████</b>
|
||||
<br />
|
||||
<span class="account-block-acct">@██████</span>
|
||||
<span class="account-block-acct">██████</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
@ -62,6 +62,7 @@ function AccountBlock({
|
|||
group,
|
||||
followersCount,
|
||||
createdAt,
|
||||
locked,
|
||||
} = account;
|
||||
let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
|
||||
if (accountInstance) {
|
||||
|
@ -86,7 +87,7 @@ function AccountBlock({
|
|||
class="account-block"
|
||||
href={url}
|
||||
target={external ? '_blank' : null}
|
||||
title={`@${acct}`}
|
||||
title={acct2 ? acct : `@${acct}`}
|
||||
onClick={(e) => {
|
||||
if (external) return;
|
||||
e.preventDefault();
|
||||
|
@ -120,9 +121,16 @@ function AccountBlock({
|
|||
</>
|
||||
)}{' '}
|
||||
<span class="account-block-acct">
|
||||
@{acct1}
|
||||
{acct2 ? '' : '@'}
|
||||
{acct1}
|
||||
<wbr />
|
||||
{acct2}
|
||||
{locked && (
|
||||
<>
|
||||
{' '}
|
||||
<Icon icon="lock" size="s" alt="Locked" />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{showActivity && (
|
||||
<>
|
||||
|
|
|
@ -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],
|
||||
|
@ -453,12 +453,15 @@ function AccountInfo({
|
|||
e.target.classList.add('loaded');
|
||||
try {
|
||||
// Get color from four corners of image
|
||||
const canvas = document.createElement('canvas');
|
||||
const canvas = window.OffscreenCanvas
|
||||
? new OffscreenCanvas(1, 1)
|
||||
: document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
canvas.width = e.target.width;
|
||||
canvas.height = e.target.height;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(e.target, 0, 0);
|
||||
// const colors = [
|
||||
// ctx.getImageData(0, 0, 1, 1).data,
|
||||
|
@ -1053,6 +1056,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 +1148,7 @@ function RelatedActions({
|
|||
</MenuItem>
|
||||
) : (
|
||||
<SubMenu
|
||||
menuClassName="menu-blur"
|
||||
openTrigger="clickOnly"
|
||||
direction="bottom"
|
||||
overflow="auto"
|
||||
|
@ -1352,7 +1377,6 @@ function RelatedActions({
|
|||
</div>
|
||||
{!!showTranslatedBio && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClose={() => {
|
||||
setShowTranslatedBio(false);
|
||||
}}
|
||||
|
@ -1366,7 +1390,6 @@ function RelatedActions({
|
|||
)}
|
||||
{!!showAddRemoveLists && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClose={() => {
|
||||
setShowAddRemoveLists(false);
|
||||
}}
|
||||
|
@ -1379,7 +1402,6 @@ function RelatedActions({
|
|||
)}
|
||||
{!!showPrivateNoteModal && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClose={() => {
|
||||
setShowPrivateNoteModal(false);
|
||||
}}
|
||||
|
@ -1571,7 +1593,6 @@ function AddRemoveListsSheet({ accountID, onClose }) {
|
|||
</main>
|
||||
{showListAddEditModal && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowListAddEditModal(false);
|
||||
|
|
|
@ -21,6 +21,7 @@ const canvas = window.OffscreenCanvas
|
|||
const ctx = canvas.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
function Avatar({ url, size, alt = '', squircle, ...props }) {
|
||||
size = SIZES[size] || size || SIZES.m;
|
||||
|
|
|
@ -95,6 +95,10 @@
|
|||
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
|
||||
0 1px 10px var(--bg-color);
|
||||
z-index: 2;
|
||||
|
||||
strong {
|
||||
color: var(--red-color);
|
||||
}
|
||||
}
|
||||
#_compose-container .status-preview-legend.reply-to {
|
||||
color: var(--reply-to-color);
|
||||
|
|
|
@ -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
|
||||
|
@ -168,6 +174,8 @@ function highlightText(text, { maxCharacters = Infinity }) {
|
|||
); // Emoji shortcodes
|
||||
}
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat();
|
||||
|
||||
function Compose({
|
||||
onClose,
|
||||
replyToStatus,
|
||||
|
@ -229,6 +237,12 @@ function Compose({
|
|||
};
|
||||
const focusTextarea = () => {
|
||||
setTimeout(() => {
|
||||
if (!textareaRef.current) return;
|
||||
// status starts with newline, focus on first position
|
||||
if (draftStatus?.status?.startsWith?.('\n')) {
|
||||
textareaRef.current.selectionStart = 0;
|
||||
textareaRef.current.selectionEnd = 0;
|
||||
}
|
||||
console.debug('FOCUS textarea');
|
||||
textareaRef.current?.focus();
|
||||
}, 300);
|
||||
|
@ -625,6 +639,16 @@ function Compose({
|
|||
return [topLanguages, restLanguages];
|
||||
}, [language]);
|
||||
|
||||
const replyToStatusMonthsAgo = useMemo(
|
||||
() =>
|
||||
!!replyToStatus?.createdAt &&
|
||||
Math.floor(
|
||||
(Date.now() - new Date(replyToStatus.createdAt)) /
|
||||
(1000 * 60 * 60 * 24 * 30),
|
||||
),
|
||||
[replyToStatus],
|
||||
);
|
||||
|
||||
return (
|
||||
<div id="compose-container-outer">
|
||||
<div id="compose-container" class={standalone ? 'standalone' : ''}>
|
||||
|
@ -774,6 +798,16 @@ function Compose({
|
|||
Replying to @
|
||||
{replyToStatus.account.acct || replyToStatus.account.username}
|
||||
’s post
|
||||
{replyToStatusMonthsAgo >= 3 && (
|
||||
<>
|
||||
{' '}
|
||||
(
|
||||
<strong>
|
||||
{rtf.format(-replyToStatusMonthsAgo, 'month')}
|
||||
</strong>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -1254,7 +1288,6 @@ function Compose({
|
|||
</div>
|
||||
{showEmoji2Picker && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowEmoji2Picker(false);
|
||||
|
@ -1768,7 +1801,6 @@ function MediaAttachment({
|
|||
</div>
|
||||
{showModal && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowModal(false);
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Menu, MenuItem, SubMenu } from '@szhsin/react-menu';
|
||||
import { MenuItem, SubMenu } from '@szhsin/react-menu';
|
||||
import { cloneElement } from 'preact';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
|
@ -10,6 +10,7 @@ function MenuConfirm({
|
|||
confirmLabel,
|
||||
menuItemClassName,
|
||||
menuFooter,
|
||||
menuExtras,
|
||||
...props
|
||||
}) {
|
||||
const { children, onClick, ...restProps } = props;
|
||||
|
@ -53,6 +54,7 @@ function MenuConfirm({
|
|||
<MenuItem className={menuItemClassName} onClick={onClick}>
|
||||
{confirmLabel}
|
||||
</MenuItem>
|
||||
{menuExtras}
|
||||
{menuFooter}
|
||||
</Parent>
|
||||
);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,6 +61,7 @@ function Modal({ children, onClose, onClick, class: className }) {
|
|||
const focusElement =
|
||||
modalRef.current?.querySelector('[tabindex="-1"]');
|
||||
const isFocusable =
|
||||
!!focusElement &&
|
||||
getComputedStyle(focusElement)?.pointerEvents !== 'none';
|
||||
if (focusElement && isFocusable) {
|
||||
focusElement.focus();
|
||||
|
|
|
@ -35,7 +35,7 @@ export default function Modals() {
|
|||
return (
|
||||
<>
|
||||
{!!snapStates.showCompose && (
|
||||
<Modal>
|
||||
<Modal class="solid">
|
||||
<Compose
|
||||
replyToStatus={
|
||||
typeof snapStates.showCompose !== 'boolean'
|
||||
|
@ -109,7 +109,6 @@ export default function Modals() {
|
|||
)}
|
||||
{!!snapStates.showAccount && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClose={() => {
|
||||
states.showAccount = false;
|
||||
}}
|
||||
|
@ -160,7 +159,6 @@ export default function Modals() {
|
|||
)}
|
||||
{!!snapStates.showShortcutsSettings && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClose={() => {
|
||||
states.showShortcutsSettings = false;
|
||||
}}
|
||||
|
@ -172,7 +170,6 @@ export default function Modals() {
|
|||
)}
|
||||
{!!snapStates.showGenericAccounts && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClose={() => {
|
||||
states.showGenericAccounts = false;
|
||||
}}
|
||||
|
@ -188,7 +185,6 @@ export default function Modals() {
|
|||
)}
|
||||
{!!snapStates.showMediaAlt && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClose={(e) => {
|
||||
states.showMediaAlt = false;
|
||||
}}
|
||||
|
@ -204,6 +200,7 @@ export default function Modals() {
|
|||
)}
|
||||
{!!snapStates.showEmbedModal && (
|
||||
<Modal
|
||||
class="solid"
|
||||
onClose={() => {
|
||||
states.showEmbedModal = false;
|
||||
}}
|
||||
|
|
|
@ -8,6 +8,11 @@
|
|||
font-weight: 500;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
i {
|
||||
font-variant-numeric: slashed-zero;
|
||||
font-feature-settings: 'ss01';
|
||||
}
|
||||
}
|
||||
.name-text.show-acct {
|
||||
display: inline-block;
|
||||
|
|
|
@ -50,7 +50,11 @@ function NameText({
|
|||
class={`name-text ${showAcct ? 'show-acct' : ''} ${short ? 'short' : ''}`}
|
||||
href={url}
|
||||
target={external ? '_blank' : null}
|
||||
title={`${displayName ? `${displayName} ` : ''}@${acct}`}
|
||||
title={
|
||||
displayName
|
||||
? `${displayName} (${acct2 ? '' : '@'}${acct})`
|
||||
: `${acct2 ? '' : '@'}${acct}`
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (external) return;
|
||||
e.preventDefault();
|
||||
|
@ -88,8 +92,9 @@ function NameText({
|
|||
<>
|
||||
<br />
|
||||
<i>
|
||||
@{acct1}
|
||||
<span class="ib">{acct2}</span>
|
||||
{acct2 ? '' : '@'}
|
||||
{acct1}
|
||||
{!!acct2 && <span class="ib">{acct2}</span>}
|
||||
</i>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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,7 +174,7 @@ function NavMenu(props) {
|
|||
<MenuLink to="/">
|
||||
<Icon icon="home" size="l" /> <span>Home</span>
|
||||
</MenuLink>
|
||||
{authenticated && (
|
||||
{authenticated ? (
|
||||
<>
|
||||
{showFollowing && (
|
||||
<MenuLink to="/following">
|
||||
|
@ -177,7 +182,7 @@ function NavMenu(props) {
|
|||
</MenuLink>
|
||||
)}
|
||||
<MenuLink to="/catchup">
|
||||
<Icon icon="history" />
|
||||
<Icon icon="history2" size="l" />
|
||||
<span>Catch-up</span>
|
||||
</MenuLink>
|
||||
<MenuLink to="/mentions">
|
||||
|
@ -192,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…
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showGenericAccounts = {
|
||||
id: 'block',
|
||||
heading: 'Blocked users',
|
||||
fetchAccounts: fetchBlocks,
|
||||
excludeRelationshipAttrs: ['blocking'],
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="block" size="l" />
|
||||
Blocked users…
|
||||
</MenuItem>{' '}
|
||||
</SubMenu>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showAccounts = true;
|
||||
|
@ -237,31 +262,32 @@ function NavMenu(props) {
|
|||
>
|
||||
<Icon icon="group" size="l" /> <span>Accounts…</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showGenericAccounts = {
|
||||
id: 'mute',
|
||||
heading: 'Muted users',
|
||||
fetchAccounts: fetchMutes,
|
||||
excludeRelationshipAttrs: ['muting'],
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="mute" size="l" /> Muted users…
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showGenericAccounts = {
|
||||
id: 'block',
|
||||
heading: 'Blocked users',
|
||||
fetchAccounts: fetchBlocks,
|
||||
excludeRelationshipAttrs: ['blocking'],
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="block" size="l" />
|
||||
Blocked users…
|
||||
</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={() => {
|
||||
|
@ -290,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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -26,6 +26,8 @@
|
|||
background-color: var(--bg-blur-color);
|
||||
backdrop-filter: blur(16px);
|
||||
padding: 16px;
|
||||
padding: calc(var(--sai-top, 0) + 16px) calc(var(--sai-right, 0) + 16px)
|
||||
16px calc(var(--sai-left, 0) + 16px);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
|
@ -41,6 +43,8 @@
|
|||
|
||||
main {
|
||||
padding: 0 16px 16px;
|
||||
padding: 0 calc(var(--sai-right, 0) + 16px)
|
||||
calc(var(--sai-bottom, 0) + 16px) calc(var(--sai-left, 0) + 16px);
|
||||
/* display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px; */
|
||||
|
|
|
@ -232,8 +232,8 @@ function ReportModal({ account, post, onClose }) {
|
|||
disabled={uiState === 'loading'}
|
||||
/>
|
||||
</section>
|
||||
<section>
|
||||
{domain !== currentDomain && (
|
||||
{!!domain && domain !== currentDomain && (
|
||||
<section>
|
||||
<p>
|
||||
<label>
|
||||
<input
|
||||
|
@ -247,8 +247,8 @@ function ReportModal({ account, post, onClose }) {
|
|||
</span>
|
||||
</label>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
<footer>
|
||||
<button type="submit" disabled={uiState === 'loading'}>
|
||||
Send Report
|
||||
|
|
|
@ -73,7 +73,7 @@ const SearchForm = forwardRef((props, ref) => {
|
|||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
spellCheck="false"
|
||||
onSearch={(e) => {
|
||||
if (!e.target.value) {
|
||||
setSearchParams({});
|
||||
|
|
|
@ -392,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 />
|
||||
|
@ -419,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={{
|
||||
|
@ -451,7 +457,6 @@ function ShortcutsSettings({ onClose }) {
|
|||
</main>
|
||||
{showForm && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowForm(false);
|
||||
|
@ -475,7 +480,6 @@ function ShortcutsSettings({ onClose }) {
|
|||
)}
|
||||
{showImportExport && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowImportExport(false);
|
||||
|
@ -667,7 +671,7 @@ function ShortcutForm({
|
|||
}
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck={false}
|
||||
spellCheck={false}
|
||||
pattern={pattern}
|
||||
/>
|
||||
{currentType === 'hashtag' &&
|
||||
|
|
|
@ -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 {
|
||||
li > 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 {
|
||||
|
|
|
@ -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,
|
||||
|
@ -84,6 +85,8 @@ const isIOS =
|
|||
window.ontouchstart !== undefined &&
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat();
|
||||
|
||||
const REACTIONS_LIMIT = 80;
|
||||
|
||||
function getPollText(poll) {
|
||||
|
@ -451,6 +454,7 @@ function Status({
|
|||
]);
|
||||
|
||||
const [showEdited, setShowEdited] = useState(false);
|
||||
const [showEmbed, setShowEmbed] = useState(false);
|
||||
|
||||
const spoilerContentRef = useTruncated();
|
||||
const contentRef = useTruncated();
|
||||
|
@ -506,6 +510,13 @@ function Status({
|
|||
(attachment) => !attachment.description?.trim?.(),
|
||||
);
|
||||
}, [mediaAttachments]);
|
||||
|
||||
const statusMonthsAgo = useMemo(() => {
|
||||
return Math.floor(
|
||||
(new Date() - createdAtDate) / (1000 * 60 * 60 * 24 * 30),
|
||||
);
|
||||
}, [createdAtDate]);
|
||||
|
||||
const boostStatus = async () => {
|
||||
if (!sameInstance || !authenticated) {
|
||||
alert(unauthInteractionErrorMessage);
|
||||
|
@ -709,6 +720,8 @@ function Status({
|
|||
}
|
||||
|
||||
const actionsRef = useRef();
|
||||
const isPublic = ['public', 'unlisted'].includes(visibility);
|
||||
const isPinnable = ['public', 'unlisted', 'private'].includes(visibility);
|
||||
const StatusMenuItems = (
|
||||
<>
|
||||
{isSizeLarge && (
|
||||
|
@ -744,17 +757,41 @@ function Status({
|
|||
confirmLabel={
|
||||
<>
|
||||
<Icon icon="rocket" />
|
||||
<span>{reblogged ? 'Unboost?' : 'Boost to everyone?'}</span>
|
||||
<span>{reblogged ? 'Unboost' : 'Boost'}</span>
|
||||
</>
|
||||
}
|
||||
className={`menu-reblog ${reblogged ? 'checked' : ''}`}
|
||||
menuExtras={
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showCompose = {
|
||||
draftStatus: {
|
||||
status: `\n${url}`,
|
||||
},
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="quote" />
|
||||
<span>Quote</span>
|
||||
</MenuItem>
|
||||
}
|
||||
menuFooter={
|
||||
mediaNoDesc &&
|
||||
!reblogged && (
|
||||
mediaNoDesc && !reblogged ? (
|
||||
<div class="footer">
|
||||
<Icon icon="alert" />
|
||||
Some media have no descriptions.
|
||||
</div>
|
||||
) : (
|
||||
statusMonthsAgo >= 3 && (
|
||||
<div class="footer">
|
||||
<Icon icon="info" />
|
||||
<span>
|
||||
Old post (
|
||||
<strong>{rtf.format(-statusMonthsAgo, 'month')}</strong>
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={!canBoost}
|
||||
|
@ -854,13 +891,12 @@ function Status({
|
|||
</div>
|
||||
)
|
||||
)}
|
||||
{!isSizeLarge ||
|
||||
((enableTranslate || !language || differentLanguage) && (
|
||||
<MenuDivider />
|
||||
))}
|
||||
{((!isSizeLarge && sameInstance) ||
|
||||
enableTranslate ||
|
||||
!language ||
|
||||
differentLanguage) && <MenuDivider />}
|
||||
{!isSizeLarge && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<MenuLink
|
||||
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
|
||||
onClick={(e) => {
|
||||
|
@ -914,7 +950,8 @@ function Status({
|
|||
<Icon icon="link" />
|
||||
<span>Copy</span>
|
||||
</MenuItem>
|
||||
{navigator?.share &&
|
||||
{isPublic &&
|
||||
navigator?.share &&
|
||||
navigator?.canShare?.({
|
||||
url,
|
||||
}) && (
|
||||
|
@ -935,6 +972,16 @@ function Status({
|
|||
</MenuItem>
|
||||
)}
|
||||
</div>
|
||||
{isPublic && isSizeLarge && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setShowEmbed(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="code" />
|
||||
<span>Embed post</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{(isSelf || mentionSelf) && <MenuDivider />}
|
||||
{(isSelf || mentionSelf) && (
|
||||
<MenuItem
|
||||
|
@ -968,7 +1015,7 @@ function Status({
|
|||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isSelf && /(public|unlisted|private)/i.test(visibility) && (
|
||||
{isSelf && isPinnable && (
|
||||
<MenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
@ -1085,7 +1132,12 @@ 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) &&
|
||||
!link.getAttribute('href').startsWith('#')
|
||||
)
|
||||
return;
|
||||
e.preventDefault();
|
||||
setContextMenuProps({
|
||||
anchorPoint: {
|
||||
|
@ -1331,7 +1383,12 @@ 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) &&
|
||||
!link.getAttribute('href').startsWith('#')
|
||||
)
|
||||
return;
|
||||
|
||||
// If there's selected text, don't show custom context menu
|
||||
const selection = window.getSelection?.();
|
||||
|
@ -1783,6 +1840,9 @@ function Status({
|
|||
media={media}
|
||||
autoAnimate={isSizeLarge}
|
||||
showCaption={mediaAttachments.length === 1}
|
||||
allowLongerCaption={
|
||||
!content && mediaAttachments.length === 1
|
||||
}
|
||||
lang={language}
|
||||
altIndex={
|
||||
showMultipleMediaCaptions &&
|
||||
|
@ -1894,11 +1954,23 @@ function Status({
|
|||
confirmLabel={
|
||||
<>
|
||||
<Icon icon="rocket" />
|
||||
<span>
|
||||
{reblogged ? 'Unboost?' : 'Boost to everyone?'}
|
||||
</span>
|
||||
<span>{reblogged ? 'Unboost' : 'Boost'}</span>
|
||||
</>
|
||||
}
|
||||
menuExtras={
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showCompose = {
|
||||
draftStatus: {
|
||||
status: `\n${url}`,
|
||||
},
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="quote" />
|
||||
<span>Quote</span>
|
||||
</MenuItem>
|
||||
}
|
||||
menuFooter={
|
||||
mediaNoDesc &&
|
||||
!reblogged && (
|
||||
|
@ -1972,7 +2044,6 @@ function Status({
|
|||
</div>
|
||||
{!!showEdited && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowEdited(false);
|
||||
|
@ -1993,6 +2064,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>
|
||||
</>
|
||||
);
|
||||
|
@ -2099,10 +2187,13 @@ function Card({ card, selfReferential, instance }) {
|
|||
const w = 44;
|
||||
const h = 44;
|
||||
const blurhashPixels = decodeBlurHash(blurhash, w, h);
|
||||
const canvas = document.createElement('canvas');
|
||||
const canvas = window.OffscreenCanvas
|
||||
? new OffscreenCanvas(1, 1)
|
||||
: document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
const imageData = ctx.createImageData(w, h);
|
||||
imageData.data.set(blurhashPixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
@ -2140,10 +2231,10 @@ function Card({ card, selfReferential, instance }) {
|
|||
<p class="meta domain" dir="auto">
|
||||
{domain}
|
||||
</p>
|
||||
<p class="title" dir="auto">
|
||||
<p class="title" dir="auto" title={title}>
|
||||
{title}
|
||||
</p>
|
||||
<p class="meta" dir="auto">
|
||||
<p class="meta" dir="auto" title={description}>
|
||||
{description ||
|
||||
(!!publishedAt && (
|
||||
<RelativeTime datetime={publishedAt} format="micro" />
|
||||
|
@ -2180,7 +2271,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 (
|
||||
|
@ -2208,8 +2303,12 @@ function Card({ card, selfReferential, instance }) {
|
|||
<p class="meta domain">
|
||||
<Icon icon="link" size="s" /> <span>{domain}</span>
|
||||
</p>
|
||||
<p class="title">{title}</p>
|
||||
<p class="meta">{description || providerName || authorName}</p>
|
||||
<p class="title" title={title}>
|
||||
{title}
|
||||
</p>
|
||||
<p class="meta" title={description || providerName || authorName}>
|
||||
{description || providerName || authorName}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
@ -2293,6 +2392,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>
|
||||
{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>
|
||||
{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,
|
||||
|
@ -2405,13 +2858,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 ${
|
||||
|
@ -2427,7 +2888,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>
|
||||
);
|
||||
|
@ -2549,7 +3017,6 @@ function FilteredStatus({
|
|||
</article>
|
||||
{!!showPeek && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowPeek(false);
|
||||
|
|
|
@ -535,15 +535,15 @@ const TimelineItem = memo(
|
|||
const url = instance
|
||||
? `/${instance}/s/${actualStatusID}`
|
||||
: `/s/${actualStatusID}`;
|
||||
let title = '';
|
||||
if (type === 'boosts') {
|
||||
title = `${items.length} Boosts`;
|
||||
} else if (type === 'pinned') {
|
||||
title = 'Pinned posts';
|
||||
}
|
||||
const isCarousel = type === 'boosts' || type === 'pinned';
|
||||
if (items) {
|
||||
const fItems = filteredItems(items, filterContext);
|
||||
let title = '';
|
||||
if (type === 'boosts') {
|
||||
title = `${fItems.length} Boosts`;
|
||||
} else if (type === 'pinned') {
|
||||
title = 'Pinned posts';
|
||||
}
|
||||
const isCarousel = type === 'boosts' || type === 'pinned';
|
||||
if (isCarousel) {
|
||||
// Here, we don't hide filtered posts, but we sort them last
|
||||
fItems.sort((a, b) => {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"@mastodon/edit-media-attributes": ">=4.1",
|
||||
"@mastodon/list-exclusive": ">=4.2"
|
||||
"@mastodon/list-exclusive": ">=4.2",
|
||||
"@mastodon/filtered-notifications": "~4.3 || >=4.3"
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
@ -227,7 +227,7 @@ button[hidden] {
|
|||
}
|
||||
:is(button, .button):not(:disabled, .disabled):is(:hover, :focus) {
|
||||
cursor: pointer;
|
||||
filter: brightness(1.2);
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
:is(button, .button):not(:disabled, .disabled):active {
|
||||
filter: brightness(0.8);
|
||||
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -206,8 +206,12 @@ function AccountStatuses() {
|
|||
const [featuredTags, setFeaturedTags] = useState([]);
|
||||
useTitle(
|
||||
account?.acct
|
||||
? `${account?.displayName ? account.displayName + ' ' : ''}@${
|
||||
account.acct
|
||||
? `${
|
||||
account?.displayName
|
||||
? `${account.displayName} (${/@/.test(account.acct) ? '' : '@'}${
|
||||
account.acct
|
||||
})`
|
||||
: `${/@/.test(account.acct) ? '' : '@'}${account.acct}`
|
||||
}${
|
||||
!excludeReplies
|
||||
? ' (+ Replies)'
|
||||
|
@ -259,27 +263,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 +416,7 @@ function AccountStatuses() {
|
|||
instance,
|
||||
authenticated,
|
||||
featuredTags,
|
||||
fetchAccount,
|
||||
searchEnabled,
|
||||
...allSearchParams,
|
||||
]);
|
||||
|
|
|
@ -32,6 +32,57 @@
|
|||
max-width: 40em;
|
||||
margin-inline: auto;
|
||||
|
||||
details {
|
||||
border-radius: 16px;
|
||||
text-wrap: balance;
|
||||
color: var(--text-insignificant-color);
|
||||
padding: 1em;
|
||||
margin: -1em 0;
|
||||
transition: all 0.3s var(--timing-function);
|
||||
line-height: 1.4;
|
||||
|
||||
&[open] {
|
||||
transform: translateY(-10vh);
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
background-image: radial-gradient(
|
||||
farthest-corner at 25% 0,
|
||||
transparent 80%,
|
||||
var(--bg-faded-color) 95%,
|
||||
var(--bg-color)
|
||||
),
|
||||
radial-gradient(
|
||||
farthest-corner at 100% 100%,
|
||||
transparent 80%,
|
||||
var(--bg-faded-blur-color)
|
||||
);
|
||||
outline: 1px solid var(--bg-color);
|
||||
box-shadow: 0 16px 32px -16px var(--drop-shadow-color);
|
||||
|
||||
~ * {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 480px;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--outline-color);
|
||||
}
|
||||
}
|
||||
|
||||
summary {
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.catchup-info {
|
||||
animation: appear 0.3s ease-out;
|
||||
display: flex;
|
||||
|
@ -68,6 +119,13 @@
|
|||
gap: 0.25em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
> span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -117,14 +175,14 @@
|
|||
border-radius: 3px;
|
||||
border: 1px solid var(--bg-color);
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
gap: var(--hairline-width);
|
||||
pointer-events: none;
|
||||
justify-content: stretch;
|
||||
height: 3px;
|
||||
|
||||
&:has(.post-dot:nth-child(320)) {
|
||||
/* &:has(.post-dot:nth-child(320)) {
|
||||
gap: 0;
|
||||
}
|
||||
} */
|
||||
|
||||
.post-dot {
|
||||
display: block;
|
||||
|
@ -147,6 +205,42 @@
|
|||
}
|
||||
}
|
||||
|
||||
.catchup-posts-viz-time-bar {
|
||||
margin: 0 16px;
|
||||
padding: 1px;
|
||||
display: flex;
|
||||
row-gap: var(--hairline-width);
|
||||
pointer-events: none;
|
||||
justify-content: stretch;
|
||||
background-image: linear-gradient(to bottom, transparent, var(--bg-color));
|
||||
|
||||
@media (min-width: 640px) {
|
||||
column-gap: var(--hairline-width);
|
||||
}
|
||||
|
||||
.posts-bin {
|
||||
display: flex;
|
||||
gap: var(--hairline-width);
|
||||
flex-direction: column-reverse;
|
||||
width: 100%;
|
||||
|
||||
.post-dot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
opacity: 0.2;
|
||||
background-color: var(--link-color);
|
||||
transition: 0.25s ease-in-out;
|
||||
transition-property: opacity, transform;
|
||||
contain: none;
|
||||
|
||||
&.post-dot-highlight {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.catchup-filters {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
|
@ -179,6 +273,10 @@
|
|||
color: var(--text-insignificant-color);
|
||||
position: relative;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
select {
|
||||
/* appearance: none;
|
||||
background-color: var(--bg-faded-color);
|
||||
|
@ -262,6 +360,7 @@
|
|||
|
||||
img {
|
||||
transition: filter 0.15s ease;
|
||||
will-change: filter;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -315,11 +414,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
&:has(.filter-author :checked)
|
||||
.filter-author:not(:has(:checked)):not(:is(:hover, :focus)) {
|
||||
&:has(.filter-author :checked) .filter-author:not(:has(:checked)) {
|
||||
.avatar img {
|
||||
filter: grayscale(1) contrast(2) opacity(0.5);
|
||||
}
|
||||
|
||||
&:is(:hover, :focus) {
|
||||
.avatar img {
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.radio-field-group {
|
||||
|
@ -360,6 +464,7 @@
|
|||
}
|
||||
|
||||
.catchup-list {
|
||||
min-height: 85vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
@ -385,18 +490,14 @@
|
|||
}
|
||||
|
||||
> li {
|
||||
margin: 0 0 1px;
|
||||
margin: 0 0 var(--hairline-width);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
/* border-bottom: var(--hairline-width) solid var(--outline-color); */
|
||||
|
||||
&.separator {
|
||||
height: 16px;
|
||||
height: 32px;
|
||||
pointer-events: none;
|
||||
|
||||
@media (min-width: 40em) {
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 40em) {
|
||||
|
@ -424,10 +525,13 @@
|
|||
background-color: var(--bg-faded-color);
|
||||
box-shadow: 0 8px 16px -8px var(--drop-shadow-color),
|
||||
inset 0 1px var(--bg-color);
|
||||
outline: 1px solid var(--outline-color);
|
||||
text-shadow: 0 1px var(--bg-color);
|
||||
}
|
||||
|
||||
&:hover:not(:focus-visible) {
|
||||
outline: 1px solid var(--outline-color);
|
||||
}
|
||||
|
||||
&:active {
|
||||
filter: brightness(0.95);
|
||||
box-shadow: none;
|
||||
|
@ -450,6 +554,7 @@
|
|||
}
|
||||
|
||||
.post-line {
|
||||
font-size: 0.95em;
|
||||
border-radius: inherit;
|
||||
animation: appear-smooth 0.3s ease-in-out both;
|
||||
--pad: 16px;
|
||||
|
@ -468,9 +573,9 @@
|
|||
'content content';
|
||||
/* align-items: center; */
|
||||
background-image: linear-gradient(
|
||||
160deg,
|
||||
140deg,
|
||||
var(--post-bg-color),
|
||||
transparent min(80px, 50%)
|
||||
transparent min(160px, 50%)
|
||||
);
|
||||
/* background-image: linear-gradient(
|
||||
90deg,
|
||||
|
@ -524,10 +629,24 @@
|
|||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
min-height: 24px;
|
||||
|
||||
.icon {
|
||||
> .avatar {
|
||||
outline: 1px solid var(--bg-blur-color);
|
||||
}
|
||||
|
||||
> .avatar ~ .avatar {
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
color: var(--reblog-color);
|
||||
}
|
||||
|
||||
> .name-text {
|
||||
opacity: 0.75;
|
||||
filter: grayscale(0.75);
|
||||
}
|
||||
}
|
||||
|
||||
.post-author {
|
||||
|
@ -559,10 +678,38 @@
|
|||
}
|
||||
}
|
||||
|
||||
> li:first-child .post-line {
|
||||
animation-duration: 0.1s;
|
||||
}
|
||||
> li:nth-child(2) .post-line {
|
||||
animation-duration: 0.2s;
|
||||
}
|
||||
> li:nth-child(10) ~ li .post-line {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&:is(.catchup-group-account, .catchup-selected-author):is(
|
||||
.catchup-filter-original,
|
||||
.catchup-filter-reply
|
||||
)
|
||||
> li {
|
||||
margin-bottom: 0;
|
||||
|
||||
&:first-child ~ li {
|
||||
.post-author:not(:has(.post-reblog-avatar)) {
|
||||
opacity: 0.25;
|
||||
|
||||
@media (min-width: 40em) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.separator + li .post-author {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-peek {
|
||||
grid-area: content;
|
||||
display: flex;
|
||||
|
@ -573,7 +720,7 @@
|
|||
/* align-items: center; */
|
||||
/* margin-left: 24px; */
|
||||
flex-direction: row-reverse;
|
||||
flex-wrap: wrap;
|
||||
/* flex-wrap: wrap; */
|
||||
justify-content: flex-end;
|
||||
|
||||
/* CLOAK - uncomment when taking screenshots */
|
||||
|
@ -597,7 +744,6 @@
|
|||
overflow: hidden;
|
||||
line-height: 1.3;
|
||||
opacity: 0.9;
|
||||
/* font-size: 0.9em; */
|
||||
text-wrap: balance;
|
||||
|
||||
&:empty {
|
||||
|
@ -647,7 +793,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
br:after {
|
||||
br:after,
|
||||
:not(br, span, a) + :is(p, div, blockquote, ul, ol, pre):before {
|
||||
font-size: 0.75em;
|
||||
content: ' ↵ ';
|
||||
opacity: 0.35;
|
||||
|
@ -670,6 +817,7 @@
|
|||
}
|
||||
|
||||
.post-peek-spoiler {
|
||||
display: inline-block;
|
||||
line-height: 1.5;
|
||||
border-radius: 1em;
|
||||
padding-inline: 0.5em;
|
||||
|
@ -737,6 +885,47 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.post-peek-media:not(:last-child) {
|
||||
margin-right: -24px;
|
||||
box-shadow: 0 0 0 2px var(--bg-blur-color);
|
||||
}
|
||||
/* Max 10, I'm not going to code more than this */
|
||||
.post-peek-media:nth-child(1) {
|
||||
z-index: 10;
|
||||
}
|
||||
.post-peek-media:nth-child(2) {
|
||||
z-index: 9;
|
||||
}
|
||||
.post-peek-media:nth-child(3) {
|
||||
z-index: 8;
|
||||
}
|
||||
.post-peek-media:nth-child(4) {
|
||||
z-index: 7;
|
||||
}
|
||||
.post-peek-media:nth-child(5) {
|
||||
z-index: 6;
|
||||
}
|
||||
.post-peek-media:nth-child(6) {
|
||||
z-index: 5;
|
||||
}
|
||||
.post-peek-media:nth-child(7) {
|
||||
z-index: 4;
|
||||
}
|
||||
.post-peek-media:nth-child(8) {
|
||||
z-index: 3;
|
||||
}
|
||||
.post-peek-media:nth-child(9) {
|
||||
z-index: 2;
|
||||
}
|
||||
.post-peek-media:nth-child(10) {
|
||||
z-index: 1;
|
||||
}
|
||||
.post-peek-media:hover {
|
||||
z-index: 11;
|
||||
}
|
||||
}
|
||||
|
||||
.post-peek-faux-media {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
|
@ -807,20 +996,86 @@
|
|||
}
|
||||
|
||||
.post-stats {
|
||||
opacity: 0;
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
transform: translateX(4px);
|
||||
/* transition: all 0.25s ease-out; */
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> span {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
.post-line:hover .post-stats {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
@media (hover: hover) {
|
||||
.post-stats {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
.post-line:hover .post-stats {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
&.catchup-sort-repliesCount {
|
||||
.post-stats {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
.post-stat-replies {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
> *:not(.post-stat-replies) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.post-line:hover .post-stats > * {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.catchup-sort-favouritesCount {
|
||||
.post-stats {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
.post-stat-likes {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
> *:not(.post-stat-likes) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.post-line:hover .post-stats > * {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.catchup-sort-reblogsCount {
|
||||
.post-stats {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
.post-stat-boosts {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
> *:not(.post-stat-boosts) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.post-line:hover .post-stats > * {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+ footer {
|
||||
|
@ -830,3 +1085,29 @@
|
|||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#catchup-help-sheet {
|
||||
dl {
|
||||
dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
dd {
|
||||
margin-block-end: 1em;
|
||||
margin-inline: 1em;
|
||||
|
||||
+ dd {
|
||||
margin-block-start: -0.9em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kbd {
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
padding: 0.2em 0.3em;
|
||||
margin: 1px 0;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--outline-color);
|
||||
background-color: var(--bg-faded-color);
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -285,7 +285,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
|||
required
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck={false}
|
||||
spellCheck={false}
|
||||
// no spaces, no hashtags
|
||||
pattern="[^#][^\s#]+[^#]"
|
||||
disabled={reachLimit}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -108,7 +108,6 @@ function Lists() {
|
|||
</div>
|
||||
{showListAddEditModal && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowListAddEditModal(false);
|
||||
|
|
|
@ -160,7 +160,7 @@ function Login() {
|
|||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
spellCheck={false}
|
||||
placeholder="instance domain"
|
||||
onInput={(e) => {
|
||||
setInstanceText(e.target.value);
|
||||
|
|
|
@ -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;
|
||||
|
@ -142,6 +143,7 @@
|
|||
border-color: var(--reply-to-color);
|
||||
box-shadow: 0 0 0 3px var(--reply-to-faded-color);
|
||||
}
|
||||
.notification:focus-visible .status-link,
|
||||
.notification .status-link:is(:hover, :focus) {
|
||||
background-color: var(--bg-blur-color);
|
||||
filter: saturate(1);
|
||||
|
@ -419,3 +421,145 @@
|
|||
color: var(--text-color);
|
||||
background-color: var(--link-faded-color);
|
||||
}
|
||||
|
||||
/* FILTERED NOTIFICATIONS */
|
||||
|
||||
.filtered-notifications {
|
||||
padding-block-end: 16px;
|
||||
|
||||
summary {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
margin: 16px 0 0;
|
||||
color: var(--text-insignificant-color);
|
||||
|
||||
&::marker,
|
||||
&::-webkit-details-marker {
|
||||
color: var(--text-insignificant-color);
|
||||
}
|
||||
}
|
||||
details[open] summary {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
summary + ul {
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 50vh;
|
||||
max-height: 50dvh;
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--outline-color);
|
||||
border-bottom: 1px solid var(--outline-color);
|
||||
background-color: var(--bg-faded-color);
|
||||
|
||||
@media (min-width: 40em) {
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 16px;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
row-gap: 8px;
|
||||
column-gap: 16px;
|
||||
border-bottom: 1px solid var(--outline-color);
|
||||
}
|
||||
li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.request-notifcations {
|
||||
min-width: 0;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.last-post {
|
||||
max-width: 100%;
|
||||
|
||||
> .status-link {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
--max-height: 160px;
|
||||
max-height: var(--max-height);
|
||||
border: 1px solid var(--outline-color);
|
||||
|
||||
&:is(:hover, :focus-visible) {
|
||||
border-color: var(--outline-hover-color);
|
||||
}
|
||||
|
||||
.status {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black calc(var(--max-height) / 2),
|
||||
transparent calc(var(--max-height) - 8px)
|
||||
);
|
||||
font-size: calc(var(--text-size) * 0.9);
|
||||
|
||||
.content-container {
|
||||
pointer-events: none;
|
||||
filter: saturate(0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.request-notifications-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-request-buttons {
|
||||
grid-area: buttons;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
button {
|
||||
max-width: 30vw;
|
||||
}
|
||||
|
||||
.notification-request-states {
|
||||
min-height: 32px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
|
||||
.icon {
|
||||
margin-inline: 8px;
|
||||
|
||||
&.notification-accepted {
|
||||
color: var(--green-color);
|
||||
}
|
||||
|
||||
&.notification-dismissed {
|
||||
color: var(--red-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#notifications-settings {
|
||||
label {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
input[type='checkbox'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import './notifications.css';
|
|||
import { Fragment } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
@ -13,8 +14,10 @@ import FollowRequestButtons from '../components/follow-request-buttons';
|
|||
import Icon from '../components/icon';
|
||||
import Link from '../components/link';
|
||||
import Loader from '../components/loader';
|
||||
import Modal from '../components/modal';
|
||||
import NavMenu from '../components/nav-menu';
|
||||
import Notification from '../components/notification';
|
||||
import Status from '../components/status';
|
||||
import { api } from '../utils/api';
|
||||
import enhanceContent from '../utils/enhance-content';
|
||||
import groupNotifications from '../utils/group-notifications';
|
||||
|
@ -22,8 +25,10 @@ import handleContentLinks from '../utils/handle-content-links';
|
|||
import niceDateTime from '../utils/nice-date-time';
|
||||
import { getRegistration } from '../utils/push-notifications';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import showToast from '../utils/show-toast';
|
||||
import states, { saveStatus } from '../utils/states';
|
||||
import { getCurrentInstance } from '../utils/store-utils';
|
||||
import supports from '../utils/supports';
|
||||
import usePageVisibility from '../utils/usePageVisibility';
|
||||
import useScroll from '../utils/useScroll';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
@ -31,6 +36,12 @@ import useTitle from '../utils/useTitle';
|
|||
const LIMIT = 30; // 30 is the maximum limit :(
|
||||
const emptySearchParams = new URLSearchParams();
|
||||
|
||||
const scrollIntoViewOptions = {
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
behavior: 'smooth',
|
||||
};
|
||||
|
||||
function Notifications({ columnMode }) {
|
||||
useTitle('Notifications', '/notifications');
|
||||
const { masto, instance } = api();
|
||||
|
@ -129,6 +140,28 @@ function Notifications({ columnMode }) {
|
|||
}
|
||||
}
|
||||
|
||||
const supportsFilteredNotifications = supports(
|
||||
'@mastodon/filtered-notifications',
|
||||
);
|
||||
const [showNotificationsSettings, setShowNotificationsSettings] =
|
||||
useState(false);
|
||||
const [notificationsPolicy, setNotificationsPolicy] = useState({});
|
||||
function fetchNotificationsPolicy() {
|
||||
return masto.v1.notifications.policy.fetch().catch(() => {});
|
||||
}
|
||||
function loadNotificationsPolicy() {
|
||||
fetchNotificationsPolicy()
|
||||
.then((policy) => {
|
||||
console.log('✨ Notifications policy', policy);
|
||||
setNotificationsPolicy(policy);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
const [notificationsRequests, setNotificationsRequests] = useState(null);
|
||||
function fetchNotificationsRequest() {
|
||||
return masto.v1.notifications.requests.list();
|
||||
}
|
||||
|
||||
const loadNotifications = (firstLoad) => {
|
||||
setShowNew(false);
|
||||
setUIState('loading');
|
||||
|
@ -154,6 +187,10 @@ function Notifications({ columnMode }) {
|
|||
setFollowRequests(requests);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
if (supportsFilteredNotifications) {
|
||||
loadNotificationsPolicy();
|
||||
}
|
||||
}
|
||||
|
||||
const { done } = await fetchNotificationsPromise;
|
||||
|
@ -221,6 +258,9 @@ function Notifications({ columnMode }) {
|
|||
lastHiddenTime.current = Date.now();
|
||||
}
|
||||
unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
|
||||
if (uiState === 'loading') {
|
||||
return;
|
||||
}
|
||||
if (v) {
|
||||
loadUpdates();
|
||||
}
|
||||
|
@ -270,11 +310,84 @@ function Notifications({ columnMode }) {
|
|||
// }
|
||||
// }, [uiState]);
|
||||
|
||||
const itemsSelector = '.notification';
|
||||
const jRef = useHotkeys('j', () => {
|
||||
const activeItem = document.activeElement.closest(itemsSelector);
|
||||
const activeItemRect = activeItem?.getBoundingClientRect();
|
||||
const allItems = Array.from(
|
||||
scrollableRef.current.querySelectorAll(itemsSelector),
|
||||
);
|
||||
if (
|
||||
activeItem &&
|
||||
activeItemRect.top < scrollableRef.current.clientHeight &&
|
||||
activeItemRect.bottom > 0
|
||||
) {
|
||||
const activeItemIndex = allItems.indexOf(activeItem);
|
||||
let nextItem = allItems[activeItemIndex + 1];
|
||||
if (nextItem) {
|
||||
nextItem.focus();
|
||||
nextItem.scrollIntoView(scrollIntoViewOptions);
|
||||
}
|
||||
} else {
|
||||
const topmostItem = allItems.find((item) => {
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
return itemRect.top >= 44 && itemRect.left >= 0;
|
||||
});
|
||||
if (topmostItem) {
|
||||
topmostItem.focus();
|
||||
topmostItem.scrollIntoView(scrollIntoViewOptions);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const kRef = useHotkeys('k', () => {
|
||||
// focus on previous status after active item
|
||||
const activeItem = document.activeElement.closest(itemsSelector);
|
||||
const activeItemRect = activeItem?.getBoundingClientRect();
|
||||
const allItems = Array.from(
|
||||
scrollableRef.current.querySelectorAll(itemsSelector),
|
||||
);
|
||||
if (
|
||||
activeItem &&
|
||||
activeItemRect.top < scrollableRef.current.clientHeight &&
|
||||
activeItemRect.bottom > 0
|
||||
) {
|
||||
const activeItemIndex = allItems.indexOf(activeItem);
|
||||
let prevItem = allItems[activeItemIndex - 1];
|
||||
if (prevItem) {
|
||||
prevItem.focus();
|
||||
prevItem.scrollIntoView(scrollIntoViewOptions);
|
||||
}
|
||||
} else {
|
||||
const topmostItem = allItems.find((item) => {
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
return itemRect.top >= 44 && itemRect.left >= 0;
|
||||
});
|
||||
if (topmostItem) {
|
||||
topmostItem.focus();
|
||||
topmostItem.scrollIntoView(scrollIntoViewOptions);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const oRef = useHotkeys(['enter', 'o'], () => {
|
||||
const activeItem = document.activeElement.closest(itemsSelector);
|
||||
const statusLink = activeItem?.querySelector('.status-link');
|
||||
if (statusLink) {
|
||||
statusLink.click();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
id="notifications-page"
|
||||
class="deck-container"
|
||||
ref={scrollableRef}
|
||||
ref={(node) => {
|
||||
scrollableRef.current = node;
|
||||
jRef.current = node;
|
||||
kRef.current = node;
|
||||
oRef.current = node;
|
||||
}}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
|
||||
|
@ -301,7 +414,17 @@ function Notifications({ columnMode }) {
|
|||
</div>
|
||||
<h1>Notifications</h1>
|
||||
<div class="header-side">
|
||||
{/* <Loader hidden={uiState !== 'loading'} /> */}
|
||||
{supportsFilteredNotifications && (
|
||||
<button
|
||||
type="button"
|
||||
class="button plain"
|
||||
onClick={() => {
|
||||
setShowNotificationsSettings(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="settings" size="l" alt="Notifications settings" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showNew && uiState !== 'loading' && (
|
||||
|
@ -406,6 +529,70 @@ function Notifications({ columnMode }) {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{supportsFilteredNotifications &&
|
||||
notificationsPolicy?.summary?.pendingRequestsCount > 0 && (
|
||||
<div class="filtered-notifications">
|
||||
<details
|
||||
onToggle={async (e) => {
|
||||
const { open } = e.target;
|
||||
if (open) {
|
||||
const requests = await fetchNotificationsRequest();
|
||||
setNotificationsRequests(requests);
|
||||
console.log({ open, requests });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<summary>
|
||||
Filtered notifications from{' '}
|
||||
{notificationsPolicy.summary.pendingRequestsCount} people
|
||||
</summary>
|
||||
{!notificationsRequests ? (
|
||||
<p class="ui-state">
|
||||
<Loader abrupt />
|
||||
</p>
|
||||
) : (
|
||||
notificationsRequests?.length > 0 && (
|
||||
<ul>
|
||||
{notificationsRequests.map((request) => (
|
||||
<li key={request.id}>
|
||||
<div class="request-notifcations">
|
||||
{!request.lastStatus?.id && (
|
||||
<AccountBlock
|
||||
useAvatarStatic
|
||||
showStats
|
||||
account={request.account}
|
||||
/>
|
||||
)}
|
||||
{request.lastStatus?.id && (
|
||||
<div class="last-post">
|
||||
<Link
|
||||
class="status-link"
|
||||
to={`/${instance}/s/${request.lastStatus.id}`}
|
||||
>
|
||||
<Status
|
||||
status={request.lastStatus}
|
||||
size="s"
|
||||
readOnly
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<NotificationRequestModalButton request={request} />
|
||||
</div>
|
||||
<NotificationRequestButtons
|
||||
request={request}
|
||||
onChange={() => {
|
||||
loadNotifications(true);
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
<div id="mentions-option">
|
||||
<label>
|
||||
<input
|
||||
|
@ -514,6 +701,109 @@ function Notifications({ columnMode }) {
|
|||
</InView>
|
||||
)}
|
||||
</div>
|
||||
{supportsFilteredNotifications && showNotificationsSettings && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowNotificationsSettings(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="sheet" id="notifications-settings" tabIndex="-1">
|
||||
<button
|
||||
type="button"
|
||||
class="sheet-close"
|
||||
onClick={() => setShowNotificationsSettings(false)}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
<header>
|
||||
<h2>Notifications settings</h2>
|
||||
</header>
|
||||
<main>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const {
|
||||
filterNotFollowing,
|
||||
filterNotFollowers,
|
||||
filterNewAccounts,
|
||||
filterPrivateMentions,
|
||||
} = e.target;
|
||||
const allFilters = {
|
||||
filterNotFollowing: filterNotFollowing.checked,
|
||||
filterNotFollowers: filterNotFollowers.checked,
|
||||
filterNewAccounts: filterNewAccounts.checked,
|
||||
filterPrivateMentions: filterPrivateMentions.checked,
|
||||
};
|
||||
setNotificationsPolicy({
|
||||
...notificationsPolicy,
|
||||
...allFilters,
|
||||
});
|
||||
setShowNotificationsSettings(false);
|
||||
(async () => {
|
||||
try {
|
||||
await masto.v1.notifications.policy.update(allFilters);
|
||||
showToast('Notifications settings updated');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<p>Filter out notifications from people:</p>
|
||||
<p>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
switch
|
||||
defaultChecked={notificationsPolicy.filterNotFollowing}
|
||||
name="filterNotFollowing"
|
||||
/>{' '}
|
||||
You don't follow
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
switch
|
||||
defaultChecked={notificationsPolicy.filterNotFollowers}
|
||||
name="filterNotFollowers"
|
||||
/>{' '}
|
||||
Who don't follow you
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
switch
|
||||
defaultChecked={notificationsPolicy.filterNewAccounts}
|
||||
name="filterNewAccounts"
|
||||
/>{' '}
|
||||
With a new account
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
switch
|
||||
defaultChecked={notificationsPolicy.filterPrivateMentions}
|
||||
name="filterPrivateMentions"
|
||||
/>{' '}
|
||||
Who unsolicitedly private mention you
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<button type="submit">Save</button>
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -596,4 +886,186 @@ function AnnouncementBlock({ announcement }) {
|
|||
);
|
||||
}
|
||||
|
||||
function fetchNotficationsByAccount(accountID) {
|
||||
const { masto } = api();
|
||||
return masto.v1.notifications.list({
|
||||
accountID,
|
||||
});
|
||||
}
|
||||
function NotificationRequestModalButton({ request }) {
|
||||
const { instance } = api();
|
||||
const [uiState, setUIState] = useState('loading');
|
||||
const { account, lastStatus } = request;
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
|
||||
function onClose() {
|
||||
setShowModal(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!request?.account?.id) return;
|
||||
if (!showModal) return;
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
const notifs = await fetchNotficationsByAccount(request.account.id);
|
||||
setNotifications(notifs || []);
|
||||
setUIState('default');
|
||||
})();
|
||||
}, [showModal, request?.account?.id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class="plain4 request-notifications-account"
|
||||
onClick={() => {
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="notification" class="more-insignificant" />{' '}
|
||||
<small>View notifications from @{account.username}</small>{' '}
|
||||
<Icon icon="chevron-down" />
|
||||
</button>
|
||||
{showModal && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="sheet" tabIndex="-1">
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
<header>
|
||||
<b>Notifications from @{account.username}</b>
|
||||
</header>
|
||||
<main>
|
||||
{uiState === 'loading' ? (
|
||||
<p class="ui-state">
|
||||
<Loader abrupt />
|
||||
</p>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
class="notification-peek"
|
||||
onClick={(e) => {
|
||||
const { target } = e;
|
||||
// If button or links
|
||||
if (
|
||||
e.target.tagName === 'BUTTON' ||
|
||||
e.target.tagName === 'A'
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Notification
|
||||
instance={instance}
|
||||
notification={notification}
|
||||
isStatic
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationRequestButtons({ request, onChange }) {
|
||||
const { masto } = api();
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [requestState, setRequestState] = useState(null); // accept, dismiss
|
||||
const hasRequestState = requestState !== null;
|
||||
|
||||
return (
|
||||
<p class="notification-request-buttons">
|
||||
<button
|
||||
type="button"
|
||||
disabled={uiState === 'loading' || hasRequestState}
|
||||
onClick={() => {
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
await masto.v1.notifications.requests
|
||||
.$select(request.id)
|
||||
.accept();
|
||||
setRequestState('accept');
|
||||
setUIState('default');
|
||||
onChange({
|
||||
request,
|
||||
state: 'accept',
|
||||
});
|
||||
showToast(
|
||||
`Notifications from @${request.account.username} will not be filtered from now on.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setUIState('error');
|
||||
console.error(error);
|
||||
showToast(`Unable to accept notification request`);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Allow
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
disabled={uiState === 'loading' || hasRequestState}
|
||||
class="light danger"
|
||||
onClick={() => {
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
await masto.v1.notifications.requests
|
||||
.$select(request.id)
|
||||
.dismiss();
|
||||
setRequestState('dismiss');
|
||||
setUIState('default');
|
||||
onChange({
|
||||
request,
|
||||
state: 'dismiss',
|
||||
});
|
||||
showToast(
|
||||
`Notifications from @${request.account.username} will not show up in Filtered notifications from now on.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setUIState('error');
|
||||
console.error(error);
|
||||
showToast(`Unable to dismiss notification request`);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<span class="notification-request-states">
|
||||
{uiState === 'loading' ? (
|
||||
<Loader abrupt />
|
||||
) : requestState === 'accept' ? (
|
||||
<Icon
|
||||
icon="check-circle"
|
||||
alt="Accepted"
|
||||
class="notification-accepted"
|
||||
/>
|
||||
) : (
|
||||
requestState === 'dismiss' && (
|
||||
<Icon
|
||||
icon="x-circle"
|
||||
alt="Dismissed"
|
||||
class="notification-dismissed"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Notifications);
|
||||
|
|
|
@ -433,7 +433,7 @@ function Settings({ onClose }) {
|
|||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{!!IMG_ALT_API_URL && (
|
||||
{!!IMG_ALT_API_URL && authenticated && (
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
|
|
|
@ -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' &&
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -16,7 +16,9 @@ function handleContentLinks(opts) {
|
|||
const textBeforeLinkIsAt = prevText?.endsWith('@');
|
||||
const textStartsWithAt = target.innerText.startsWith('@');
|
||||
if (
|
||||
(target.classList.contains('u-url') && textStartsWithAt) ||
|
||||
((target.classList.contains('u-url') ||
|
||||
target.classList.contains('mention')) &&
|
||||
textStartsWithAt) ||
|
||||
(textBeforeLinkIsAt && !textStartsWithAt)
|
||||
) {
|
||||
const targetText = (
|
||||
|
@ -24,12 +26,14 @@ function handleContentLinks(opts) {
|
|||
).innerText.trim();
|
||||
const username = targetText.replace(/^@/, '');
|
||||
const url = target.getAttribute('href');
|
||||
const mention = mentions.find(
|
||||
(mention) =>
|
||||
mention.username === username ||
|
||||
mention.acct === username ||
|
||||
mention.url === url,
|
||||
);
|
||||
// Only fallback to acct/username check if url doesn't match
|
||||
const mention =
|
||||
mentions.find((mention) => mention.url === url) ||
|
||||
mentions.find(
|
||||
(mention) =>
|
||||
mention.acct === username || mention.username === username,
|
||||
);
|
||||
console.warn('MENTION', mention, url);
|
||||
if (mention) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
|
@ -9,8 +9,10 @@ function statusPeek(status) {
|
|||
text += getHTMLText(content);
|
||||
}
|
||||
text = text.trim();
|
||||
if (poll) {
|
||||
text += ' 📊';
|
||||
if (poll?.options?.length) {
|
||||
text += `\n\n📊:\n${poll.options
|
||||
.map((o) => `${poll.multiple ? '▪️' : '•'} ${o.title}`)
|
||||
.join('\n')}`;
|
||||
}
|
||||
if (mediaAttachments?.length) {
|
||||
text +=
|
||||
|
|
|
@ -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')) {
|
||||
|
|
Loading…
Reference in a new issue