1
0
Fork 0

Merge remote-tracking branch 'origin/main'

This commit is contained in:
Alexander Yakovlev 2024-04-12 14:18:52 +06:00
commit 2e3bac2e1e
65 changed files with 4455 additions and 1083 deletions

View file

@ -138,7 +138,7 @@ Download or `git clone` this repository. Use `production` branch for *stable* re
Customization can be done by passing environment variables to the build command. Examples: Customization can be done by passing environment variables to the build command. Examples:
```bash ```bash
PHANPY_APP_TITLE="Phanpy Dev" \ PHANPY_CLIENT_NAME="Phanpy Dev" \
PHANPY_WEBSITE="https://dev.phanpy.social" \ PHANPY_WEBSITE="https://dev.phanpy.social" \
npm run build npm run build
``` ```
@ -179,6 +179,10 @@ Available variables:
- May specify a self-hosted Lingva instance, powered by either [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api) - May specify a self-hosted Lingva instance, powered by either [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api)
- List of fallback instances hard-coded in `/.env` - List of fallback instances hard-coded in `/.env`
- [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances) - [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances)
- `PHANPY_GIPHY_API_KEY` (optional, no defaults):
- API key for [GIPHY](https://developers.giphy.com/). See [API docs](https://developers.giphy.com/docs/api/).
- If provided, a setting will appear for users to enable the GIF picker in the composer. Disabled by default.
- This is not self-hosted.
### Static site hosting ### Static site hosting
@ -251,6 +255,8 @@ And here I am. Building a Mastodon web client.
- [Statuzer](https://statuzer.com/) - [Statuzer](https://statuzer.com/)
- [Tusked](https://tusked.app/) - [Tusked](https://tusked.app/)
- [Mastodon Glitch Edition (standalone frontend)](https://iceshrimp.dev/iceshrimp/masto-fe-standalone) - [Mastodon Glitch Edition (standalone frontend)](https://iceshrimp.dev/iceshrimp/masto-fe-standalone)
- [Mangane](https://github.com/BDX-town/Mangane)
- [TheDesk](https://github.com/cutls/TheDesk)
- [More...](https://github.com/hueyy/awesome-mastodon/#clients) - [More...](https://github.com/hueyy/awesome-mastodon/#clients)
## 💁‍♂️ Notice to all other social media client developers ## 💁‍♂️ Notice to all other social media client developers

723
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -27,11 +27,12 @@
"idb-keyval": "~6.2.1", "idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0", "just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0", "lz-string": "~1.5.0",
"masto": "~6.6.4", "masto": "~6.7.0",
"moize": "~6.1.6", "moize": "~6.1.6",
"p-retry": "~6.2.0", "p-retry": "~6.2.0",
"p-throttle": "~6.1.0", "p-throttle": "~6.1.0",
"preact": "~10.19.6", "preact": "~10.20.1",
"punycode": "~2.3.1",
"react-hotkeys-hook": "~4.5.0", "react-hotkeys-hook": "~4.5.0",
"react-intersection-observer": "~9.8.1", "react-intersection-observer": "~9.8.1",
"react-quick-pinch-zoom": "~5.1.0", "react-quick-pinch-zoom": "~5.1.0",
@ -48,14 +49,14 @@
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "~2.8.2", "@preact/preset-vite": "~2.8.2",
"@trivago/prettier-plugin-sort-imports": "~4.3.0", "@trivago/prettier-plugin-sort-imports": "~4.3.0",
"postcss": "~8.4.35", "postcss": "~8.4.38",
"postcss-dark-theme-class": "~1.2.1", "postcss-dark-theme-class": "~1.2.1",
"postcss-preset-env": "~9.5.1", "postcss-preset-env": "~9.5.4",
"twitter-text": "~3.1.0", "twitter-text": "~3.1.0",
"vite": "~5.1.6", "vite": "~5.2.8",
"vite-plugin-generate-file": "~0.1.1", "vite-plugin-generate-file": "~0.1.1",
"vite-plugin-html-config": "~1.0.11", "vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.19.4", "vite-plugin-pwa": "~0.19.7",
"vite-plugin-remove-console": "~2.2.0", "vite-plugin-remove-console": "~2.2.0",
"workbox-cacheable-response": "~7.0.0", "workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.0.0", "workbox-expiration": "~7.0.0",

View file

@ -295,12 +295,40 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
video, video,
img, img,
audio { audio {
min-height: 88px; /* for extreme dimensions */ min-height: var(--min-dimension); /* for extreme dimensions */
} }
} }
} }
} }
} }
.deck-container-media-first {
.timeline {
> li:not(.timeline-item-carousel, .timeline-item-container) {
&:has(.status-media-first) {
width: fit-content;
background-color: transparent !important;
border: 0 !important;
box-shadow: none !important;
max-width: min(480px, 100%);
margin-inline: auto !important;
&:has(.skeleton) {
width: 100%;
}
}
&:has(.media[data-orientation='landscape']) {
max-width: 100%;
}
}
.status-link:has(.status-media-first):hover {
background-color: transparent;
}
}
}
.timeline.grow { .timeline.grow {
/* min-height: 100vh; /* min-height: 100vh;
min-height: 100dvh; */ min-height: 100dvh; */
@ -1926,11 +1954,11 @@ body > .szh-menu-container {
.szh-menu__item:not(.szh-menu__item--disabled):not( .szh-menu__item:not(.szh-menu__item--disabled):not(
.szh-menu__item--hover .szh-menu__item--hover
).danger { ).danger {
color: var(--red-color); color: var(--red-text-color);
} }
.szh-menu .szh-menu
.szh-menu__item.danger:not(.szh-menu__item--disabled).szh-menu__item--hover { .szh-menu__item.danger:not(.szh-menu__item--disabled).szh-menu__item--hover {
background-color: var(--red-color); background-color: var(--red-text-color);
} }
.szh-menu .szh-menu
.szh-menu__item:not(.szh-menu__item--disabled):not( .szh-menu__item:not(.szh-menu__item--disabled):not(
@ -2027,71 +2055,86 @@ body > .szh-menu-container {
text-shadow: none; text-shadow: none;
} }
/* DONUT METER */ /* CHAR COUNTER */
meter.donut { .char-counter {
appearance: none;
}
meter.donut::-webkit-meter-inner-element,
meter.donut::-webkit-meter-bar,
meter.donut::-webkit-meter-optimum-value,
meter.donut::-webkit-meter-suboptimum-value,
meter.donut::-webkit-meter-even-less-good-value {
display: none;
}
meter.donut::-moz-meter-bar {
background: transparent;
}
meter.donut {
position: relative;
--dimension: 24px; --dimension: 24px;
--border-width: 2px; min-width: var(--dimension);
--middle-circle-radius: calc(var(--dimension) / 2 - var(--border-width)); min-height: var(--dimension);
width: var(--dimension); position: relative;
height: var(--dimension);
border-radius: 50%;
--fill: calc(var(--percentage) * 1%);
--color: var(--link-color);
--middle-circle: radial-gradient(
circle at 50% 50%,
var(--bg-color) var(--middle-circle-radius),
transparent var(--middle-circle-radius)
);
background-image: var(--middle-circle),
conic-gradient(var(--color) var(--fill), var(--outline-color) 0);
transform: scale(0.7);
transition: transform 0.2s ease-in-out;
}
meter.donut.warning {
--color: var(--orange-color);
transform: scale(1);
}
meter.donut.danger {
--color: var(--red-color);
transform: scale(1);
}
meter.donut.explode {
background-image: none;
transform: scale(1);
}
meter.donut:is(.warning, .danger, .explode):after {
content: attr(data-left);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
color: var(--text-insignificant-color);
}
meter.donut:is(.danger, .explode):after {
color: var(--red-color);
}
meter.donut[hidden] {
display: inline-block; display: inline-block;
visibility: hidden;
&[hidden] {
visibility: hidden;
}
* {
pointer-events: none;
}
meter {
appearance: none;
position: relative;
--border-width: 2px;
--middle-circle-radius: calc(var(--dimension) / 2 - var(--border-width));
width: var(--dimension);
height: var(--dimension);
border-radius: 50%;
--fill: calc(var(--percentage) * 1%);
--color: var(--link-color);
--middle-circle: radial-gradient(
circle at 50% 50%,
var(--bg-color) var(--middle-circle-radius),
transparent var(--middle-circle-radius)
);
background-image: var(--middle-circle),
conic-gradient(var(--color) var(--fill), var(--outline-color) 0);
transform: scale(0.7);
transition: transform 0.2s ease-in-out;
&::-webkit-meter-inner-element,
&::-webkit-meter-bar,
&::-webkit-meter-optimum-value,
&::-webkit-meter-suboptimum-value,
&::-webkit-meter-even-less-good-value {
display: none;
}
&::-moz-meter-bar {
background: transparent;
}
&.warning {
--color: var(--orange-color);
transform: scale(1);
}
&.danger {
--color: var(--red-color);
transform: scale(1);
}
&.explode {
background-image: none;
transform: scale(1);
}
&:is(.warning, .danger, .explode) + .counter {
opacity: 1;
color: var(--text-insignificant-color);
}
&:is(.danger, .explode) + .counter {
opacity: 1;
color: var(--red-color);
}
}
.counter {
line-height: 1;
opacity: 0;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
}
} }
/* SHINY PILL */ /* SHINY PILL */
@ -2289,10 +2332,10 @@ ul.link-list li a .icon {
filter: none !important; filter: none !important;
} }
.nav-menu-button .avatar { .nav-menu-button .avatar {
transition: box-shadow 0.3s ease-out; box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-light-color) !important;
} }
.nav-menu-button:is(:hover, :focus, .active) .avatar { .nav-menu-button:is(:hover, :focus, .active) .avatar {
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-light-color); box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-color) !important;
} }
.nav-menu-button.with-avatar .icon { .nav-menu-button.with-avatar .icon {
position: absolute; position: absolute;

View file

@ -1,7 +1,6 @@
import './app.css'; import './app.css';
import debounce from 'just-debounce-it'; import debounce from 'just-debounce-it';
import { lazy, Suspense } from 'preact/compat';
import { import {
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
@ -18,15 +17,16 @@ import ComposeButton from './components/compose-button';
import { ICONS } from './components/ICONS'; import { ICONS } from './components/ICONS';
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help'; import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
import Loader from './components/loader'; import Loader from './components/loader';
// import Modals from './components/modals'; import Modals from './components/modals';
import NotificationService from './components/notification-service'; import NotificationService from './components/notification-service';
import SearchCommand from './components/search-command'; import SearchCommand from './components/search-command';
import Shortcuts from './components/shortcuts'; import Shortcuts from './components/shortcuts';
import NotFound from './pages/404'; import NotFound from './pages/404';
import AccountStatuses from './pages/account-statuses'; import AccountStatuses from './pages/account-statuses';
import Bookmarks from './pages/bookmarks'; import Bookmarks from './pages/bookmarks';
// import Catchup from './pages/catchup'; import Catchup from './pages/catchup';
import Favourites from './pages/favourites'; import Favourites from './pages/favourites';
import Filters from './pages/filters';
import FollowedHashtags from './pages/followed-hashtags'; import FollowedHashtags from './pages/followed-hashtags';
import Following from './pages/following'; import Following from './pages/following';
import Hashtag from './pages/hashtag'; import Hashtag from './pages/hashtag';
@ -56,9 +56,6 @@ import store from './utils/store';
import { getCurrentAccount } from './utils/store-utils'; import { getCurrentAccount } from './utils/store-utils';
import './utils/toast-alert'; import './utils/toast-alert';
const Catchup = lazy(() => import('./pages/catchup'));
const Modals = lazy(() => import('./components/modals'));
window.__STATES__ = states; window.__STATES__ = states;
window.__STATES_STATS__ = () => { window.__STATES_STATS__ = () => {
const keys = [ const keys = [
@ -386,9 +383,7 @@ function App() {
)} )}
{isLoggedIn && <ComposeButton />} {isLoggedIn && <ComposeButton />}
{isLoggedIn && <Shortcuts />} {isLoggedIn && <Shortcuts />}
<Suspense> <Modals />
<Modals />
</Suspense>
{isLoggedIn && <NotificationService />} {isLoggedIn && <NotificationService />}
<BackgroundService isLoggedIn={isLoggedIn} /> <BackgroundService isLoggedIn={isLoggedIn} />
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />} {uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
@ -463,15 +458,9 @@ function SecondaryRoutes({ isLoggedIn }) {
<Route index element={<Lists />} /> <Route index element={<Lists />} />
<Route path=":id" element={<List />} /> <Route path=":id" element={<List />} />
</Route> </Route>
<Route path="/ft" element={<FollowedHashtags />} /> <Route path="/fh" element={<FollowedHashtags />} />
<Route <Route path="/ft" element={<Filters />} />
path="/catchup" <Route path="/catchup" element={<Catchup />} />
element={
<Suspense>
<Catchup />
</Suspense>
}
/>
</> </>
)} )}
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} /> <Route path="/:instance?/t/:hashtag" element={<Hashtag />} />

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 641 223">
<path fill="#aaa" d="M86 214c-9-1-17-4-24-8l-6-3-5-5-5-4-4-6-4-6-3-8-2-8v-27l2-9 3-9 4-6 4-6 5-5 5-5 7-3 6-4 7-2 7-2 12-1h12l7 1 8 2 7 4 7 3 5 5 5 4-10 10-10 9-4-3-10-5-5-1H88l-5 2-6 3-3 4-4 4-2 5-2 6v6l-1 7 1 7 2 7 3 5 2 4 4 3 4 3 5 2 6 2h9l10-1 5-2 6-3v-16H91v-27h59v54l-1 3-2 3-5 4-4 4-5 3-5 2-8 2-8 2-10 1H92l-6-1zm266-62V91h34v46h44V91h34v121h-34v-46h-44v46h-34v-61zm-182-1V90h34v121h-34v-60zm59-1V90h35l36 1 5 2c3 0 8 2 10 4l5 2 4 5 5 4 3 7 3 7 1 13v13l-4 6-3 7-4 4-5 5-5 2-5 3-6 2-5 1-18 1h-18v32h-34v-61zm67-2 3-2 2-4 2-5v-5l-2-4-2-4-3-2-3-3h-30v31h30l3-2zm226 39v-24l-8-12-18-28a1751 1751 0 0 0-20-31v-2h39l7 12 12 21 6 9 13-21 13-21h38v2l-41 61-7 10v48h-34v-24zM109 66l-4-1-5-5-5-4-1-5-3-9v-5l1-5c2-7 3-10 8-15l4-4 7-2 7-2h7l6 1 5 2 5 2 3 4 4 3 2 6 2 5v13l-2 5-2 6-4 4-3 3-5 2-4 2-9 1h-9l-5-2zm22-11 4-2 3-4 2-5V34l-2-4-2-4-3-2-4-3-5-1h-6l-4 2-5 2-2 4-3 5-1 3v4l1 5 2 5 2 2 5 3 4 2h10l4-2zM37 39V11h33l3 1 3 2 4 3 3 3 1 5 1 4v5l-1 4-3 4-3 5-4 1-3 2-11 1H49v16H37V39zm31 0 3-2 1-2 1-2v-4l-1-3-3-2-2-2H49v18h15l4-1zm107 25a512 512 0 0 0-19-53h14l4 14 6 19 1 4 1-1 7-19 5-17h9l6 19 7 18v-1l2-6 5-17 4-13h14v1l-4 12-16 41v2h-5l-5-1-6-15-6-15-1 1-3 7-6 15-2 8h-11l-1-3zm74-25V11h42v11h-29v2l-1 5v4h29v11h-28v11h2l15 1h13v11h-43V39zm55 0V11h33l5 3 5 2 2 4 2 5v10l-2 3-1 4-5 3-5 3 5 5 8 10 3 4h-14l-7-9-8-10h-9v19h-12V39zm33-3 2-3v-6l-3-3-2-3h-18v16h1v1h17l2-2zm26 3V11h42v11h-29l-1 6v5h29v11h-28v5l-1 5 1 1v1h30v11h-43V39zm54 0V11h17l18 1 4 2 5 3 2 4 3 4 2 6 1 6v5c-1 6-3 12-6 15l-3 4-5 3-5 2-17 1h-16V39zm33 14 5-5 2-3v-6l-1-6-1-3-1-3-4-3-3-2h-5l-6-1-3 1h-3v34h9l8-1 3-2zm50-14V11h34l5 2 4 2 2 3 2 3v9l-2 2-3 4-1 1 3 3 3 4 1 3 1 4-1 4-1 4-3 3-3 3-5 1-5 1h-31V39zm34 15 2-1v-6l-2-2-2-2h-20v13h20l2-2zm-3-22 4-2v-6l-2-1-2-2h-19v12h16l4-1zm42 24V45l-6-9-11-17-5-8h15l4 8 7 11 2 3 7-11 7-11h14l-11 16-11 17v23h-12V56z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -15,7 +15,8 @@ body.cloak,
.account-block, .account-block,
.catchup-filters .filter-author *, .catchup-filters .filter-author *,
.post-peek-html *, .post-peek-html *,
.post-peek-content > * { .post-peek-content > *,
.request-notifications-account * {
text-decoration-thickness: 1.1em; text-decoration-thickness: 1.1em;
text-decoration-line: line-through; text-decoration-line: line-through;
/* text-rendering: optimizeSpeed; */ /* text-rendering: optimizeSpeed; */
@ -51,7 +52,8 @@ body.cloak,
.cloak { .cloak {
.media-container figcaption, .media-container figcaption,
.media-container figcaption > *, .media-container figcaption > *,
.catchup-filters .filter-author * { .catchup-filters .filter-author *,
.request-notifications-account * {
color: var(--text-color) !important; color: var(--text-color) !important;
} }
} }

View file

@ -78,6 +78,7 @@ export const ICONS = {
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'), refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'), emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
filter: () => import('@iconify-icons/mingcute/filter-2-line'), filter: () => import('@iconify-icons/mingcute/filter-2-line'),
filters: () => import('@iconify-icons/mingcute/filter-line'),
chart: () => import('@iconify-icons/mingcute/chart-line-line'), chart: () => import('@iconify-icons/mingcute/chart-line-line'),
react: () => import('@iconify-icons/mingcute/react-line'), react: () => import('@iconify-icons/mingcute/react-line'),
layout4: () => import('@iconify-icons/mingcute/layout-4-line'), layout4: () => import('@iconify-icons/mingcute/layout-4-line'),
@ -105,4 +106,6 @@ export const ICONS = {
copy: () => import('@iconify-icons/mingcute/copy-2-line'), copy: () => import('@iconify-icons/mingcute/copy-2-line'),
quote: () => import('@iconify-icons/mingcute/quote-left-line'), quote: () => import('@iconify-icons/mingcute/quote-left-line'),
settings: () => import('@iconify-icons/mingcute/settings-6-line'), settings: () => import('@iconify-icons/mingcute/settings-6-line'),
'heart-break': () => import('@iconify-icons/mingcute/heart-crack-line'),
'user-x': () => import('@iconify-icons/mingcute/user-x-line'),
}; };

View file

@ -781,3 +781,108 @@
} }
} }
} }
#edit-profile-container {
p {
margin-block: 8px;
}
label {
input,
textarea {
display: block;
width: 100%;
}
textarea {
resize: vertical;
min-height: 5em;
max-height: 50vh;
}
}
table {
width: 100%;
th {
text-align: left;
color: var(--text-insignificant-color);
font-weight: normal;
font-size: 0.8em;
text-transform: uppercase;
}
tbody tr td:first-child {
width: 40%;
}
input {
width: 100%;
}
}
footer {
display: flex;
justify-content: space-between;
padding: 8px 0;
* {
vertical-align: middle;
}
}
}
.handle-info {
.handle-handle {
display: inline-block;
margin-block: 5px;
b {
font-weight: 600;
padding: 2px 4px;
border-radius: 4px;
display: inline-block;
box-shadow: 0 0 0 5px var(--bg-blur-color);
&.handle-username {
color: var(--orange-fg-color);
background-color: var(--orange-bg-color);
}
&.handle-server {
color: var(--purple-fg-color);
background-color: var(--purple-bg-color);
}
}
}
.handle-at {
display: inline-block;
margin-inline: -3px;
position: relative;
z-index: 1;
}
.handle-legend {
margin-top: 0.25em;
}
.handle-legend-icon {
overflow: hidden;
display: inline-block;
width: 14px;
height: 14px;
border: 4px solid transparent;
border-radius: 8px;
background-clip: padding-box;
&.username {
background-color: var(--orange-fg-color);
border-color: var(--orange-bg-color);
}
&.server {
background-color: var(--purple-fg-color);
border-color: var(--purple-bg-color);
}
}
}

View file

@ -1,6 +1,6 @@
import './account-info.css'; import './account-info.css';
import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu'; import { MenuDivider, MenuItem } from '@szhsin/react-menu';
import { import {
useCallback, useCallback,
useEffect, useEffect,
@ -9,11 +9,13 @@ import {
useRef, useRef,
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import punycode from 'punycode';
import { api } from '../utils/api'; import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content'; import enhanceContent from '../utils/enhance-content';
import getHTMLText from '../utils/getHTMLText'; import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links'; import handleContentLinks from '../utils/handle-content-links';
import { getLists } from '../utils/lists';
import niceDateTime from '../utils/nice-date-time'; import niceDateTime from '../utils/nice-date-time';
import pmem from '../utils/pmem'; import pmem from '../utils/pmem';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
@ -31,7 +33,9 @@ import ListAddEdit from './list-add-edit';
import Loader from './loader'; import Loader from './loader';
import Menu2 from './menu2'; import Menu2 from './menu2';
import MenuConfirm from './menu-confirm'; import MenuConfirm from './menu-confirm';
import MenuLink from './menu-link';
import Modal from './modal'; import Modal from './modal';
import SubMenu2 from './submenu2';
import TranslationBlock from './translation-block'; import TranslationBlock from './translation-block';
const MUTE_DURATIONS = [ const MUTE_DURATIONS = [
@ -227,7 +231,7 @@ function AccountInfo({
const accountInstance = useMemo(() => { const accountInstance = useMemo(() => {
if (!url) return null; if (!url) return null;
const domain = new URL(url).hostname; const domain = punycode.toUnicode(new URL(url).hostname);
return domain; return domain;
}, [url]); }, [url]);
@ -250,12 +254,13 @@ function AccountInfo({
// On first load, fetch familiar followers, merge to top of results' `value` // On first load, fetch familiar followers, merge to top of results' `value`
// Remove dups on every fetch // Remove dups on every fetch
if (firstLoad) { if (firstLoad) {
const familiarFollowers = await masto.v1.accounts.familiarFollowers.fetch( let familiarFollowers = [];
{ try {
familiarFollowers = await masto.v1.accounts.familiarFollowers.fetch({
id: [id], id: [id],
}, });
); } catch (e) {}
familiarFollowersCache.current = familiarFollowers[0].accounts; familiarFollowersCache.current = familiarFollowers?.[0]?.accounts || [];
newValue = [ newValue = [
...familiarFollowersCache.current, ...familiarFollowersCache.current,
...value.filter( ...value.filter(
@ -340,6 +345,17 @@ function AccountInfo({
[standalone, id, statusesCount], [standalone, id, statusesCount],
); );
const onProfileUpdate = useCallback(
(newAccount) => {
if (newAccount.id === id) {
console.log('Updated account info', newAccount);
setInfo(newAccount);
states.accounts[`${newAccount.id}@${instance}`] = newAccount;
}
},
[id, instance],
);
return ( return (
<div <div
tabIndex="-1" tabIndex="-1"
@ -529,13 +545,64 @@ function AccountInfo({
/> />
)} )}
<header> <header>
<AccountBlock {standalone ? (
account={info} <Menu2
instance={instance} shift={
avatarSize="xxxl" window.matchMedia('(min-width: calc(40em))').matches
external={standalone} ? 114
internal={!standalone} : 64
/> }
menuButton={
<div>
<AccountBlock
account={info}
instance={instance}
avatarSize="xxxl"
onClick={() => {}}
/>
</div>
}
>
<div class="szh-menu__header">
<AccountHandleInfo acct={acct} instance={instance} />
</div>
<MenuItem
onClick={() => {
const handle = `@${acct}`;
try {
navigator.clipboard.writeText(handle);
showToast('Handle copied');
} catch (e) {
console.error(e);
showToast('Unable to copy handle');
}
}}
>
<Icon icon="link" />
<span>Copy handle</span>
</MenuItem>
<MenuItem href={url} target="_blank">
<Icon icon="external" />
<span>Go to original profile page</span>
</MenuItem>
<MenuDivider />
<MenuLink href={info.avatar} target="_blank">
<Icon icon="user" />
<span>View profile image</span>
</MenuLink>
<MenuLink href={info.header} target="_blank">
<Icon icon="media" />
<span>View profile header</span>
</MenuLink>
</Menu2>
) : (
<AccountBlock
account={info}
instance={instance}
avatarSize="xxxl"
internal
/>
)}
</header> </header>
<div class="faux-header-bg" aria-hidden="true" /> <div class="faux-header-bg" aria-hidden="true" />
<main> <main>
@ -605,6 +672,7 @@ function AccountInfo({
// states.showAccount = false; // states.showAccount = false;
setTimeout(() => { setTimeout(() => {
states.showGenericAccounts = { states.showGenericAccounts = {
id: 'followers',
heading: 'Followers', heading: 'Followers',
fetchAccounts: fetchFollowers, fetchAccounts: fetchFollowers,
instance, instance,
@ -755,45 +823,49 @@ function AccountInfo({
</div> </div>
</LinkOrDiv> </LinkOrDiv>
)} )}
<div class="account-metadata-box"> {!moved && (
<div <div class="account-metadata-box">
class="shazam-container no-animation" <div
hidden={!!postingStats} class="shazam-container no-animation"
> hidden={!!postingStats}
<div class="shazam-container-inner"> >
<button <div class="shazam-container-inner">
type="button" <button
class="posting-stats-button" type="button"
disabled={postingStatsUIState === 'loading'} class="posting-stats-button"
onClick={() => { disabled={postingStatsUIState === 'loading'}
renderPostingStats(); onClick={() => {
}} renderPostingStats();
>
<div
class={`posting-stats-bar posting-stats-icon ${
postingStatsUIState === 'loading' ? 'loading' : ''
}`}
style={{
'--originals-percentage': '33%',
'--replies-percentage': '66%',
}} }}
/> >
View post stats{' '} <div
{/* <Loader class={`posting-stats-bar posting-stats-icon ${
postingStatsUIState === 'loading' ? 'loading' : ''
}`}
style={{
'--originals-percentage': '33%',
'--replies-percentage': '66%',
}}
/>
View post stats{' '}
{/* <Loader
abrupt abrupt
hidden={postingStatsUIState !== 'loading'} hidden={postingStatsUIState !== 'loading'}
/> */} /> */}
</button> </button>
</div>
</div> </div>
</div> </div>
</div> )}
</main> </main>
<footer> <footer>
<RelatedActions <RelatedActions
info={info} info={info}
instance={instance} instance={instance}
standalone={standalone}
authenticated={authenticated} authenticated={authenticated}
onRelationshipChange={onRelationshipChange} onRelationshipChange={onRelationshipChange}
onProfileUpdate={onProfileUpdate}
/> />
</footer> </footer>
</> </>
@ -808,8 +880,10 @@ const FAMILIAR_FOLLOWERS_LIMIT = 3;
function RelatedActions({ function RelatedActions({
info, info,
instance, instance,
standalone,
authenticated, authenticated,
onRelationshipChange = () => {}, onRelationshipChange = () => {},
onProfileUpdate = () => {},
}) { }) {
if (!info) return null; if (!info) return null;
const { const {
@ -881,7 +955,7 @@ function RelatedActions({
accountID.current = currentID; accountID.current = currentID;
if (moved) return; // if (moved) return;
setRelationshipUIState('loading'); setRelationshipUIState('loading');
@ -920,6 +994,7 @@ function RelatedActions({
const [showTranslatedBio, setShowTranslatedBio] = useState(false); const [showTranslatedBio, setShowTranslatedBio] = useState(false);
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false); const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
const [showPrivateNoteModal, setShowPrivateNoteModal] = useState(false); const [showPrivateNoteModal, setShowPrivateNoteModal] = useState(false);
const [showEditProfile, setShowEditProfile] = useState(false);
const [lists, setLists] = useState([]); const [lists, setLists] = useState([]);
return ( return (
@ -1029,6 +1104,70 @@ function RelatedActions({
{privateNote ? 'Edit private note' : 'Add private note'} {privateNote ? 'Edit private note' : 'Add private note'}
</span> </span>
</MenuItem> </MenuItem>
{following && !!relationship && (
<>
<MenuItem
onClick={() => {
setRelationshipUIState('loading');
(async () => {
try {
const rel = await currentMasto.v1.accounts
.$select(accountID.current)
.follow({
notify: !notifying,
});
if (rel) setRelationship(rel);
setRelationshipUIState('default');
showToast(
rel.notifying
? `Notifications enabled for @${username}'s posts.`
: ` Notifications disabled for @${username}'s posts.`,
);
} catch (e) {
alert(e);
setRelationshipUIState('error');
}
})();
}}
>
<Icon icon="notification" />
<span>
{notifying
? 'Disable notifications'
: 'Enable notifications'}
</span>
</MenuItem>
<MenuItem
onClick={() => {
setRelationshipUIState('loading');
(async () => {
try {
const rel = await currentMasto.v1.accounts
.$select(accountID.current)
.follow({
reblogs: !showingReblogs,
});
if (rel) setRelationship(rel);
setRelationshipUIState('default');
showToast(
rel.showingReblogs
? `Boosts from @${username} disabled.`
: `Boosts from @${username} enabled.`,
);
} catch (e) {
alert(e);
setRelationshipUIState('error');
}
})();
}}
>
<Icon icon="rocket" />
<span>
{showingReblogs ? 'Disable boosts' : 'Enable boosts'}
</span>
</MenuItem>
</>
)}
{/* Add/remove from lists is only possible if following the account */} {/* Add/remove from lists is only possible if following the account */}
{following && ( {following && (
<MenuItem <MenuItem
@ -1147,7 +1286,7 @@ function RelatedActions({
<span>Unmute @{username}</span> <span>Unmute @{username}</span>
</MenuItem> </MenuItem>
) : ( ) : (
<SubMenu <SubMenu2
menuClassName="menu-blur" menuClassName="menu-blur"
openTrigger="clickOnly" openTrigger="clickOnly"
direction="bottom" direction="bottom"
@ -1201,7 +1340,44 @@ function RelatedActions({
</MenuItem> </MenuItem>
))} ))}
</div> </div>
</SubMenu> </SubMenu2>
)}
{followedBy && (
<MenuConfirm
subMenu
menuItemClassName="danger"
confirmLabel={
<>
<Icon icon="user-x" />
<span>Remove @{username} from followers?</span>
</>
}
onClick={() => {
setRelationshipUIState('loading');
(async () => {
try {
const newRelationship = await currentMasto.v1.accounts
.$select(currentInfo?.id || id)
.removeFromFollowers();
console.log(
'removing from followers',
newRelationship,
);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`@${username} removed from followers`);
states.reloadGenericAccounts.id = 'followers';
states.reloadGenericAccounts.counter++;
} catch (e) {
console.error(e);
setRelationshipUIState('error');
}
})();
}}
>
<Icon icon="user-x" />
<span>Remove follower</span>
</MenuConfirm>
)} )}
<MenuConfirm <MenuConfirm
subMenu subMenu
@ -1276,6 +1452,19 @@ function RelatedActions({
</MenuItem> </MenuItem>
</> </>
)} )}
{currentAuthenticated && isSelf && standalone && (
<>
<MenuDivider />
<MenuItem
onClick={() => {
setShowEditProfile(true);
}}
>
<Icon icon="pencil" />
<span>Edit profile</span>
</MenuItem>
</>
)}
{import.meta.env.DEV && currentAuthenticated && isSelf && ( {import.meta.env.DEV && currentAuthenticated && isSelf && (
<> <>
<MenuDivider /> <MenuDivider />
@ -1301,7 +1490,7 @@ function RelatedActions({
{!relationship && relationshipUIState === 'loading' && ( {!relationship && relationshipUIState === 'loading' && (
<Loader abrupt /> <Loader abrupt />
)} )}
{!!relationship && ( {!!relationship && !moved && (
<MenuConfirm <MenuConfirm
confirm={following || requested} confirm={following || requested}
confirmLabel={ confirmLabel={
@ -1417,6 +1606,22 @@ function RelatedActions({
/> />
</Modal> </Modal>
)} )}
{!!showEditProfile && (
<Modal
onClose={() => {
setShowEditProfile(false);
}}
>
<EditProfileSheet
onClose={({ state, account } = {}) => {
setShowEditProfile(false);
if (state === 'success' && account) {
onProfileUpdate(account);
}
}}
/>
</Modal>
)}
</> </>
); );
} }
@ -1444,7 +1649,7 @@ function niceAccountURL(url) {
const path = pathname.replace(/\/$/, '').replace(/^\//, ''); const path = pathname.replace(/\/$/, '').replace(/^\//, '');
return ( return (
<> <>
<span class="more-insignificant">{host}/</span> <span class="more-insignificant">{punycode.toUnicode(host)}/</span>
<wbr /> <wbr />
<span>{path}</span> <span>{path}</span>
</> </>
@ -1494,13 +1699,12 @@ function AddRemoveListsSheet({ accountID, onClose }) {
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
const lists = await masto.v1.lists.list(); const lists = await getLists();
lists.sort((a, b) => a.title.localeCompare(b.title)); setLists(lists);
const listsContainingAccount = await masto.v1.accounts const listsContainingAccount = await masto.v1.accounts
.$select(accountID) .$select(accountID)
.lists.list(); .lists.list();
console.log({ lists, listsContainingAccount }); console.log({ lists, listsContainingAccount });
setLists(lists);
setListsContainingAccount(listsContainingAccount); setListsContainingAccount(listsContainingAccount);
setUIState('default'); setUIState('default');
} catch (e) { } catch (e) {
@ -1705,4 +1909,213 @@ function PrivateNoteSheet({
); );
} }
function EditProfileSheet({ onClose = () => {} }) {
const { masto } = api();
const [uiState, setUIState] = useState('loading');
const [account, setAccount] = useState(null);
useEffect(() => {
(async () => {
try {
const acc = await masto.v1.accounts.verifyCredentials();
setAccount(acc);
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}, []);
console.log('EditProfileSheet', account);
const { displayName, source } = account || {};
const { note, fields } = source || {};
const fieldsAttributesRef = useRef(null);
return (
<div class="sheet" id="edit-profile-container">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header>
<b>Edit profile</b>
</header>
<main>
{uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
const displayName = formData.get('display_name');
const note = formData.get('note');
const fieldsAttributesFields =
fieldsAttributesRef.current.querySelectorAll(
'input[name^="fields_attributes"]',
);
const fieldsAttributes = [];
fieldsAttributesFields.forEach((field) => {
const name = field.name;
const [_, index, key] =
name.match(/fields_attributes\[(\d+)\]\[(.+)\]/) || [];
const value = field.value ? field.value.trim() : '';
if (index && key && value) {
if (!fieldsAttributes[index]) fieldsAttributes[index] = {};
fieldsAttributes[index][key] = value;
}
});
// Fill in the blanks
fieldsAttributes.forEach((field) => {
if (field.name && !field.value) {
field.value = '';
}
});
(async () => {
try {
const newAccount = await masto.v1.accounts.updateCredentials({
displayName,
note,
fieldsAttributes,
});
console.log('updated account', newAccount);
onClose?.({
state: 'success',
account: newAccount,
});
} catch (e) {
console.error(e);
alert(e?.message || 'Unable to update profile.');
}
})();
}}
>
<p>
<label>
Name{' '}
<input
type="text"
name="display_name"
defaultValue={displayName}
maxLength={30}
disabled={uiState === 'loading'}
/>
</label>
</p>
<p>
<label>
Bio
<textarea
defaultValue={note}
name="note"
maxLength={500}
rows="5"
disabled={uiState === 'loading'}
/>
</label>
</p>
{/* Table for fields; name and values are in fields, min 4 rows */}
<p>Extra fields</p>
<table ref={fieldsAttributesRef}>
<thead>
<tr>
<th>Label</th>
<th>Content</th>
</tr>
</thead>
<tbody>
{Array.from({ length: Math.max(4, fields.length) }).map(
(_, i) => {
const { name = '', value = '' } = fields[i] || {};
return (
<FieldsAttributesRow
key={i}
name={name}
value={value}
index={i}
disabled={uiState === 'loading'}
/>
);
},
)}
</tbody>
</table>
<footer>
<button
type="button"
class="light"
disabled={uiState === 'loading'}
onClick={() => {
onClose?.();
}}
>
Cancel
</button>
<button type="submit" disabled={uiState === 'loading'}>
Save
</button>
</footer>
</form>
)}
</main>
</div>
);
}
function FieldsAttributesRow({ name, value, disabled, index: i }) {
const [hasValue, setHasValue] = useState(!!value);
return (
<tr>
<td>
<input
type="text"
name={`fields_attributes[${i}][name]`}
defaultValue={name}
disabled={disabled}
maxLength={255}
required={hasValue}
/>
</td>
<td>
<input
type="text"
name={`fields_attributes[${i}][value]`}
defaultValue={value}
disabled={disabled}
maxLength={255}
onChange={(e) => setHasValue(!!e.currentTarget.value)}
/>
</td>
</tr>
);
}
function AccountHandleInfo({ acct, instance }) {
// acct = username or username@server
let [username, server] = acct.split('@');
if (!server) server = instance;
return (
<div class="handle-info">
<span class="handle-handle">
<b class="handle-username">{username}</b>
<span class="handle-at">@</span>
<b class="handle-server">{server}</b>
</span>
<div class="handle-legend">
<span class="ib">
<span class="handle-legend-icon username" /> username
</span>{' '}
<span class="ib">
<span class="handle-legend-icon server" /> server domain name
</span>
</div>
</div>
);
}
export default AccountInfo; export default AccountInfo;

View file

@ -39,6 +39,8 @@ function Columns() {
if (!Component) return null; if (!Component) return null;
// Don't show Search column with no query, for now // Don't show Search column with no query, for now
if (type === 'search' && !params.query) return null; if (type === 'search' && !params.query) return null;
// Don't show List column with no list, for now
if (type === 'list' && !params.id) return null;
return ( return (
<Component key={type + JSON.stringify(params)} {...params} columnMode /> <Component key={type + JSON.stringify(params)} {...params} columnMode />
); );

View file

@ -1,6 +1,7 @@
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import openCompose from '../utils/open-compose'; import openCompose from '../utils/open-compose';
import openOSK from '../utils/open-osk';
import states from '../utils/states'; import states from '../utils/states';
import Icon from './icon'; import Icon from './icon';
@ -14,6 +15,7 @@ export default function ComposeButton() {
states.showCompose = true; states.showCompose = true;
} }
} else { } else {
openOSK();
states.showCompose = true; states.showCompose = true;
} }
} }

View file

@ -727,3 +727,165 @@
} }
} }
} }
@keyframes gif-shake {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(5deg);
}
50% {
transform: rotate(0deg);
}
75% {
transform: rotate(-5deg);
}
100% {
transform: rotate(0deg);
}
}
.gif-picker-button {
span {
font-weight: bold;
font-size: 11.5px;
display: block;
}
&:is(:hover, :focus) {
span {
animation: gif-shake 0.3s 3;
}
}
}
#gif-picker-sheet {
height: 50vh;
form {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
input[type='search'] {
flex-grow: 1;
min-width: 0;
}
}
main {
overflow-x: auto;
overflow-y: hidden;
mask-image: linear-gradient(
to right,
transparent 2px,
black 16px,
black calc(100% - 16px),
transparent calc(100% - 2px)
);
@media (min-height: 480px) {
overflow-y: auto;
max-height: 50vh;
}
&.loading {
opacity: 0.25;
}
.ui-state {
min-height: 100px;
}
ul {
min-height: 100px;
display: flex;
gap: 4px;
list-style: none;
padding: 8px 2px;
margin: 0;
@media (min-height: 480px) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
grid-auto-rows: 1fr;
}
li {
list-style: none;
padding: 0;
margin: 0;
max-width: 100%;
display: flex;
button {
padding: 4px;
margin: 0;
border: none;
background-color: transparent;
color: inherit;
cursor: pointer;
border-radius: 8px;
background-color: var(--bg-faded-color);
@media (min-height: 480px) {
width: 100%;
text-align: center;
}
&:is(:hover, :focus) {
background-color: var(--link-bg-color);
box-shadow: 0 0 0 2px var(--link-light-color);
filter: none;
}
}
figure {
margin: 0;
padding: 0;
width: var(--figure-width);
max-width: 100%;
@media (min-height: 480px) {
width: 100%;
text-align: center;
}
figcaption {
font-size: 0.8em;
padding: 2px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--text-insignificant-color);
}
}
img {
background-color: var(--img-bg-color);
border-radius: 4px;
vertical-align: top;
object-fit: contain;
}
}
}
.pagination {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 0;
margin: 0;
position: sticky;
bottom: 0;
left: 0;
right: 0;
@media (min-height: 480px) {
position: static;
}
}
}
}

View file

@ -11,6 +11,8 @@ import { uid } from 'uid/single';
import { useDebouncedCallback, useThrottledCallback } from 'use-debounce'; import { useDebouncedCallback, useThrottledCallback } from 'use-debounce';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import poweredByGiphyURL from '../assets/powered-by-giphy.svg';
import Menu2 from '../components/menu2'; import Menu2 from '../components/menu2';
import supportedLanguages from '../data/status-supported-languages'; import supportedLanguages from '../data/status-supported-languages';
import urlRegex from '../data/url-regex'; import urlRegex from '../data/url-regex';
@ -41,7 +43,10 @@ import Loader from './loader';
import Modal from './modal'; import Modal from './modal';
import Status from './status'; import Status from './status';
const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env; const {
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
PHANPY_GIPHY_API_KEY: GIPHY_API_KEY,
} = import.meta.env;
const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => { const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => {
const [code, common, native] = l; const [code, common, native] = l;
@ -299,7 +304,7 @@ function Compose({
setVisibility(visibility); setVisibility(visibility);
setLanguage(language || presf.postingDefaultLanguage || DEFAULT_LANG); setLanguage(language || presf.postingDefaultLanguage || DEFAULT_LANG);
setSensitive(sensitive); setSensitive(sensitive);
setPoll(composablePoll); if (composablePoll) setPoll(composablePoll);
setMediaAttachments(mediaAttachments); setMediaAttachments(mediaAttachments);
setUIState('default'); setUIState('default');
} catch (e) { } catch (e) {
@ -610,6 +615,7 @@ function Compose({
}, [mediaAttachments]); }, [mediaAttachments]);
const [showEmoji2Picker, setShowEmoji2Picker] = useState(false); const [showEmoji2Picker, setShowEmoji2Picker] = useState(false);
const [showGIFPicker, setShowGIFPicker] = useState(false);
const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => { const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => {
const topLanguages = []; const topLanguages = [];
@ -1235,6 +1241,18 @@ function Compose({
> >
<Icon icon="emoji2" /> <Icon icon="emoji2" />
</button> </button>
{!!states.settings.composerGIFPicker && (
<button
type="button"
class="toolbar-button gif-picker-button"
disabled={uiState === 'loading'}
onClick={() => {
setShowGIFPicker(true);
}}
>
<span>GIF</span>
</button>
)}
</span> </span>
<div class="spacer" /> <div class="spacer" />
{uiState === 'loading' ? ( {uiState === 'loading' ? (
@ -1319,6 +1337,64 @@ function Compose({
/> />
</Modal> </Modal>
)} )}
{showGIFPicker && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowGIFPicker(false);
}
}}
>
<GIFPickerModal
onClose={() => setShowGIFPicker(false)}
onSelect={({ url, type, alt_text }) => {
console.log('GIF URL', url);
if (mediaAttachments.length >= maxMediaAttachments) {
alert(
`You can only attach up to ${maxMediaAttachments} files.`,
);
return;
}
// Download the GIF and insert it as media attachment
(async () => {
let theToast;
try {
theToast = showToast({
text: 'Downloading GIF…',
duration: -1,
});
const blob = await fetch(url, {
referrerPolicy: 'no-referrer',
}).then((res) => res.blob());
const file = new File(
[blob],
type === 'video/mp4' ? 'video.mp4' : 'image.gif',
{
type,
},
);
const newMediaAttachments = [
...mediaAttachments,
{
file,
type,
size: file.size,
id: null,
description: alt_text || '',
},
];
setMediaAttachments(newMediaAttachments);
theToast?.hideToast?.();
} catch (err) {
console.error(err);
theToast?.hideToast?.();
showToast('Failed to download GIF');
}
})();
}}
/>
</Modal>
)}
</div> </div>
); );
} }
@ -1662,27 +1738,31 @@ function CharCountMeter({ maxCharacters = 500, hidden }) {
const charCount = snapStates.composerCharacterCount; const charCount = snapStates.composerCharacterCount;
const leftChars = maxCharacters - charCount; const leftChars = maxCharacters - charCount;
if (hidden) { if (hidden) {
return <meter class="donut" hidden />; return <span class="char-counter" hidden />;
} }
return ( return (
<meter <span
class={`donut ${ class="char-counter"
leftChars <= -10
? 'explode'
: leftChars <= 0
? 'danger'
: leftChars <= 20
? 'warning'
: ''
}`}
value={charCount}
max={maxCharacters}
data-left={leftChars}
title={`${leftChars}/${maxCharacters}`} title={`${leftChars}/${maxCharacters}`}
style={{ style={{
'--percentage': (charCount / maxCharacters) * 100, '--percentage': (charCount / maxCharacters) * 100,
}} }}
/> >
<meter
class={`${
leftChars <= -10
? 'explode'
: leftChars <= 0
? 'danger'
: leftChars <= 20
? 'warning'
: ''
}`}
value={charCount}
max={maxCharacters}
/>
<span class="counter">{leftChars}</span>
</span>
); );
} }
@ -1707,6 +1787,9 @@ function MediaAttachment({
onDescriptionChange, onDescriptionChange,
250, 250,
); );
useEffect(() => {
debouncedOnDescriptionChange(description);
}, [description, debouncedOnDescriptionChange]);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const textareaRef = useRef(null); const textareaRef = useRef(null);
@ -1755,7 +1838,7 @@ function MediaAttachment({
onInput={(e) => { onInput={(e) => {
const { value } = e.target; const { value } = e.target;
setDescription(value); setDescription(value);
debouncedOnDescriptionChange(value); // debouncedOnDescriptionChange(value);
}} }}
></textarea> ></textarea>
)} )}
@ -2239,4 +2322,225 @@ function CustomEmojisModal({
); );
} }
const GIFS_PER_PAGE = 20;
function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
const [uiState, setUIState] = useState('default');
const [results, setResults] = useState([]);
const formRef = useRef(null);
const qRef = useRef(null);
const currentOffset = useRef(0);
const scrollableRef = useRef(null);
function fetchGIFs({ offset }) {
console.log('fetchGIFs', { offset });
if (!qRef.current?.value) return;
setUIState('loading');
scrollableRef.current?.scrollTo?.({
top: 0,
left: 0,
behavior: 'smooth',
});
(async () => {
try {
const query = {
api_key: GIPHY_API_KEY,
q: qRef.current.value,
rating: 'g',
limit: GIFS_PER_PAGE,
bundle: 'messaging_non_clips',
offset,
};
const response = await fetch(
'https://api.giphy.com/v1/gifs/search?' + new URLSearchParams(query),
{
referrerPolicy: 'no-referrer',
},
).then((r) => r.json());
currentOffset.current = response.pagination?.offset || 0;
setResults(response);
setUIState('results');
} catch (e) {
setUIState('error');
console.error(e);
}
})();
}
useEffect(() => {
qRef.current?.focus();
}, []);
return (
<div id="gif-picker-sheet" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header>
<form
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
fetchGIFs({ offset: 0 });
}}
>
<input
ref={qRef}
type="search"
name="q"
placeholder="Search GIFs"
required
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellCheck="false"
dir="auto"
/>
<input
type="image"
class="powered-button"
src={poweredByGiphyURL}
width="86"
height="30"
/>
</form>
</header>
<main ref={scrollableRef} class={uiState === 'loading' ? 'loading' : ''}>
{uiState === 'default' && (
<div class="ui-state">
<p class="insignificant">Type to search GIFs</p>
</div>
)}
{uiState === 'loading' && !results?.data?.length && (
<div class="ui-state">
<Loader abrupt />
</div>
)}
{results?.data?.length > 0 ? (
<>
<ul>
{results.data.map((gif) => {
const { id, images, title, alt_text } = gif;
const {
fixed_height_small,
fixed_height_downsampled,
fixed_height,
original,
} = images;
const theImage = fixed_height_small?.url
? fixed_height_small
: fixed_height_downsampled?.url
? fixed_height_downsampled
: fixed_height;
let { url, webp, width, height } = theImage;
if (+height > 100) {
width = (width / height) * 100;
height = 100;
}
const urlObj = new URL(url);
const strippedURL = urlObj.origin + urlObj.pathname;
let strippedWebP;
if (webp) {
const webpObj = new URL(webp);
strippedWebP = webpObj.origin + webpObj.pathname;
}
return (
<li key={id}>
<button
type="button"
onClick={() => {
const { mp4, url } = original;
const theURL = mp4 || url;
const urlObj = new URL(theURL);
const strippedURL = urlObj.origin + urlObj.pathname;
onClose();
onSelect({
url: strippedURL,
type: mp4 ? 'video/mp4' : 'image/gif',
alt_text: alt_text || title,
});
}}
>
<figure
style={{
'--figure-width': width + 'px',
// width: width + 'px'
}}
>
<picture>
{strippedWebP && (
<source srcset={strippedWebP} type="image/webp" />
)}
<img
src={strippedURL}
width={width}
height={height}
loading="lazy"
decoding="async"
alt={alt_text}
referrerpolicy="no-referrer"
onLoad={(e) => {
e.target.style.backgroundColor = 'transparent';
}}
/>
</picture>
<figcaption>{alt_text || title}</figcaption>
</figure>
</button>
</li>
);
})}
</ul>
<p class="pagination">
{results.pagination?.offset > 0 && (
<button
type="button"
class="light small"
disabled={uiState === 'loading'}
onClick={() => {
fetchGIFs({
offset: results.pagination?.offset - GIFS_PER_PAGE,
});
}}
>
<Icon icon="chevron-left" />
<span>Previous</span>
</button>
)}
<span />
{results.pagination?.offset + results.pagination?.count <
results.pagination?.total_count && (
<button
type="button"
class="light small"
disabled={uiState === 'loading'}
onClick={() => {
fetchGIFs({
offset: results.pagination?.offset + GIFS_PER_PAGE,
});
}}
>
<span>Next</span> <Icon icon="chevron-right" />
</button>
)}
</p>
</>
) : (
uiState === 'results' && (
<div class="ui-state">
<p>No results</p>
</div>
)
)}
{uiState === 'error' && (
<div class="ui-state">
<p>Error loading GIFs</p>
</div>
)}
</main>
</div>
);
}
export default Compose; export default Compose;

View file

@ -0,0 +1,19 @@
export default function CustomEmoji({ staticUrl, alt, url }) {
return (
<picture>
{staticUrl && (
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
)}
<img
key={alt || url}
src={url}
alt={alt}
class="shortcode-emoji emoji"
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>
);
}

View file

@ -1,5 +1,7 @@
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import CustomEmoji from './custom-emoji';
function EmojiText({ text, emojis }) { function EmojiText({ text, emojis }) {
if (!text) return ''; if (!text) return '';
if (!emojis?.length) return text; if (!emojis?.length) return text;
@ -12,21 +14,7 @@ function EmojiText({ text, emojis }) {
const emoji = emojis.find((e) => e.shortcode === word); const emoji = emojis.find((e) => e.shortcode === word);
if (emoji) { if (emoji) {
const { url, staticUrl } = emoji; const { url, staticUrl } = emoji;
return ( return <CustomEmoji staticUrl={staticUrl} alt={word} url={url} />;
<picture>
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
<img
key={word}
src={url}
alt={word}
class="shortcode-emoji emoji"
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>
);
} }
return word; return word;
}); });

View file

@ -1,4 +1,24 @@
#generic-accounts-container { #generic-accounts-container {
.post-preview {
--max-height: 120px;
max-height: var(--max-height);
overflow: hidden;
margin-block: 8px;
border: 1px solid var(--outline-color);
border-radius: 8px;
pointer-events: none;
.status {
font-size: calc(var(--text-size) * 0.9);
mask-image: linear-gradient(
to bottom,
black calc(var(--max-height) / 2),
transparent calc(var(--max-height) - 8px)
);
filter: saturate(0.5);
}
}
.accounts-list { .accounts-list {
--list-gap: 16px; --list-gap: 16px;
list-style: none; list-style: none;

View file

@ -12,10 +12,12 @@ import useLocationChange from '../utils/useLocationChange';
import AccountBlock from './account-block'; import AccountBlock from './account-block';
import Icon from './icon'; import Icon from './icon';
import Loader from './loader'; import Loader from './loader';
import Status from './status';
export default function GenericAccounts({ export default function GenericAccounts({
instance, instance,
excludeRelationshipAttrs = [], excludeRelationshipAttrs = [],
postID,
onClose = () => {}, onClose = () => {},
}) { }) {
const { masto, instance: currentInstance } = api(); const { masto, instance: currentInstance } = api();
@ -129,6 +131,8 @@ export default function GenericAccounts({
} }
}, [snapStates.reloadGenericAccounts.counter]); }, [snapStates.reloadGenericAccounts.counter]);
const post = states.statuses[postID];
return ( return (
<div id="generic-accounts-container" class="sheet" tabindex="-1"> <div id="generic-accounts-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}> <button type="button" class="sheet-close" onClick={onClose}>
@ -138,6 +142,11 @@ export default function GenericAccounts({
<h2>{heading || 'Accounts'}</h2> <h2>{heading || 'Accounts'}</h2>
</header> </header>
<main> <main>
{post && (
<div class="post-preview">
<Status status={post} size="s" readOnly />
</div>
)}
{accounts.length > 0 ? ( {accounts.length > 0 ? (
<> <>
<ul class="accounts-list"> <ul class="accounts-list">

View file

@ -0,0 +1,36 @@
import { shouldPolyfill } from '@formatjs/intl-segmenter/should-polyfill';
import { Suspense } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks';
import Loader from './loader';
const supportsIntlSegmenter = !shouldPolyfill();
// Preload IntlSegmenter
setTimeout(() => {
queueMicrotask(() => {
if (!supportsIntlSegmenter) {
import('@formatjs/intl-segmenter/polyfill-force').catch(() => {});
}
});
}, 1000);
export default function IntlSegmenterSuspense({ children }) {
if (supportsIntlSegmenter) {
return <Suspense fallback={<Loader />}>{children}</Suspense>;
}
const [polyfillLoaded, setPolyfillLoaded] = useState(false);
useEffect(() => {
(async () => {
await import('@formatjs/intl-segmenter/polyfill-force');
setPolyfillLoaded(true);
})();
}, []);
return polyfillLoaded ? (
<Suspense fallback={<Loader />}>{children}</Suspense>
) : (
<Loader />
);
}

View file

@ -0,0 +1,54 @@
/*
Rendered but hidden. Only show when visible
*/
import { useEffect, useRef, useState } from 'preact/hooks';
import { useInView } from 'react-intersection-observer';
// The sticky header, usually at the top
const TOP = 48;
export default function LazyShazam({ children }) {
const containerRef = useRef();
const [visible, setVisible] = useState(false);
const [visibleStart, setVisibleStart] = useState(false);
const { ref } = useInView({
root: null,
rootMargin: `-${TOP}px 0px 0px 0px`,
trackVisibility: true,
delay: 1000,
onChange: (inView) => {
if (inView) {
setVisible(true);
}
},
triggerOnce: true,
skip: visibleStart || visible,
});
useEffect(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
if (rect.bottom > TOP) {
if (rect.top < window.innerHeight) {
setVisible(true);
} else {
setVisibleStart(true);
}
}
}, []);
if (visibleStart) return children;
return (
<div
ref={containerRef}
class="shazam-container no-animation"
hidden={!visible}
>
<div ref={ref} class="shazam-container-inner">
{children}
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { addListStore, deleteListStore, updateListStore } from '../utils/lists';
import supports from '../utils/supports'; import supports from '../utils/supports';
import Icon from './icon'; import Icon from './icon';
@ -75,6 +76,14 @@ function ListAddEdit({ list, onClose }) {
state: 'success', state: 'success',
list: listResult, list: listResult,
}); });
setTimeout(() => {
if (editMode) {
updateListStore(listResult);
} else {
addListStore(listResult);
}
}, 1);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setUIState('error'); setUIState('error');
@ -146,6 +155,9 @@ function ListAddEdit({ list, onClose }) {
onClose?.({ onClose?.({
state: 'deleted', state: 'deleted',
}); });
setTimeout(() => {
deleteListStore(list.id);
}, 1);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setUIState('error'); setUIState('error');

View file

@ -9,12 +9,12 @@ import {
} from 'preact/hooks'; } from 'preact/hooks';
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom'; import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
import formatDuration from '../utils/format-duration';
import mem from '../utils/mem'; import mem from '../utils/mem';
import states from '../utils/states'; import states from '../utils/states';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
import { formatDuration } from './status';
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755 const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
@ -74,7 +74,7 @@ function Media({
altIndex, altIndex,
onClick = () => {}, onClick = () => {},
}) { }) {
const { let {
blurhash, blurhash,
description, description,
meta, meta,
@ -84,15 +84,27 @@ function Media({
url, url,
type, type,
} = media; } = media;
if (/no\-preview\./i.test(previewUrl)) {
previewUrl = null;
}
const { original = {}, small, focus } = meta || {}; const { original = {}, small, focus } = meta || {};
const width = showOriginal ? original?.width : small?.width; const width = showOriginal
const height = showOriginal ? original?.height : small?.height; ? original?.width
: small?.width || original?.width;
const height = showOriginal
? original?.height
: small?.height || original?.height;
const mediaURL = showOriginal ? url : previewUrl || url; const mediaURL = showOriginal ? url : previewUrl || url;
const remoteMediaURL = showOriginal const remoteMediaURL = showOriginal
? remoteUrl ? remoteUrl
: previewRemoteUrl || remoteUrl; : previewRemoteUrl || remoteUrl;
const orientation = width >= height ? 'landscape' : 'portrait'; const hasDimensions = width && height;
const orientation = hasDimensions
? width > height
? 'landscape'
: 'portrait'
: null;
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null; const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
@ -133,7 +145,8 @@ function Media({
enabled: pinchZoomEnabled, enabled: pinchZoomEnabled,
draggableUnZoomed: false, draggableUnZoomed: false,
inertiaFriction: 0.9, inertiaFriction: 0.9,
doubleTapZoomOutOnMaxScale: true, tapZoomFactor: 2,
doubleTapToggleZoom: true,
containerProps: { containerProps: {
className: 'media-zoom', className: 'media-zoom',
style: { style: {
@ -290,7 +303,11 @@ function Media({
}} }}
onError={(e) => { onError={(e) => {
const { src } = e.target; const { src } = e.target;
if (src === mediaURL && mediaURL !== remoteMediaURL) { if (
src === mediaURL &&
remoteMediaURL &&
mediaURL !== remoteMediaURL
) {
e.target.src = remoteMediaURL; e.target.src = remoteMediaURL;
} }
}} }}
@ -321,6 +338,18 @@ function Media({
onLoad={(e) => { onLoad={(e) => {
// e.target.closest('.media-image').style.backgroundImage = ''; // e.target.closest('.media-image').style.backgroundImage = '';
e.target.dataset.loaded = true; e.target.dataset.loaded = true;
if (!hasDimensions) {
const $media = e.target.closest('.media');
if ($media) {
$media.dataset.orientation =
e.target.naturalWidth > e.target.naturalHeight
? 'landscape'
: 'portrait';
$media.style['--width'] = `${e.target.naturalWidth}px`;
$media.style['--height'] = `${e.target.naturalHeight}px`;
$media.style.aspectRatio = `${e.target.naturalWidth}/${e.target.naturalHeight}`;
}
}
}} }}
onError={(e) => { onError={(e) => {
const { src } = e.target; const { src } = e.target;
@ -338,6 +367,7 @@ function Media({
</Figure> </Figure>
); );
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) { } else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
const hasDuration = original.duration > 0;
const shortDuration = original.duration < 31; const shortDuration = original.duration < 31;
const isGIF = type === 'gifv' && shortDuration; const isGIF = type === 'gifv' && shortDuration;
// If GIF is too long, treat it as a video // If GIF is too long, treat it as a video
@ -473,14 +503,55 @@ function Media({
/> />
) : ( ) : (
<> <>
<img {previewUrl ? (
src={previewUrl} <img
alt={showInlineDesc ? '' : description} src={previewUrl}
width={width} alt={showInlineDesc ? '' : description}
height={height} width={width}
data-orientation={orientation} height={height}
loading="lazy" data-orientation={orientation}
/> loading="lazy"
onLoad={(e) => {
if (!hasDimensions) {
const $media = e.target.closest('.media');
if ($media) {
$media.dataset.orientation =
e.target.naturalWidth > e.target.naturalHeight
? 'landscape'
: 'portrait';
$media.style['--width'] = `${e.target.naturalWidth}px`;
$media.style[
'--height'
] = `${e.target.naturalHeight}px`;
$media.style.aspectRatio = `${e.target.naturalWidth}/${e.target.naturalHeight}`;
}
}
}}
/>
) : (
<video
src={url + '#t=0.1'} // Make Safari show 1st-frame preview
width={width}
height={height}
data-orientation={orientation}
preload="metadata"
muted
disablePictureInPicture
onLoadedMetadata={(e) => {
if (!hasDuration) {
const { duration } = e.target;
if (duration) {
const formattedDuration = formatDuration(duration);
const container = e.target.closest('.media-video');
if (container) {
container.dataset.formattedDuration =
formattedDuration;
}
}
}
}}
/>
)}
<div class="media-play"> <div class="media-play">
<Icon icon="play" size="xl" /> <Icon icon="play" size="xl" />
</div> </div>

View file

@ -1,8 +1,8 @@
import { MenuItem, SubMenu } from '@szhsin/react-menu'; import { MenuItem } from '@szhsin/react-menu';
import { cloneElement } from 'preact'; import { cloneElement } from 'preact';
import { useRef } from 'preact/hooks';
import Menu2 from './menu2'; import Menu2 from './menu2';
import SubMenu2 from './submenu2';
function MenuConfirm({ function MenuConfirm({
subMenu = false, subMenu = false,
@ -23,11 +23,9 @@ function MenuConfirm({
} }
return children; return children;
} }
const Parent = subMenu ? SubMenu : Menu2; const Parent = subMenu ? SubMenu2 : Menu2;
const menuRef = useRef();
return ( return (
<Parent <Parent
instanceRef={menuRef}
openTrigger="clickOnly" openTrigger="clickOnly"
direction="bottom" direction="bottom"
overflow="auto" overflow="auto"
@ -37,19 +35,6 @@ function MenuConfirm({
{...restProps} {...restProps}
menuButton={subMenu ? undefined : children} menuButton={subMenu ? undefined : children}
label={subMenu ? children : undefined} label={subMenu ? children : undefined}
// Test fix for bug; submenus not opening on Android
itemProps={{
onPointerMove: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
onPointerLeave: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
}}
> >
<MenuItem className={menuItemClassName} onClick={onClick}> <MenuItem className={menuItemClassName} onClick={onClick}>
{confirmLabel} {confirmLabel}

View file

@ -1,3 +1,4 @@
import { lazy } from 'preact/compat';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { subscribe, useSnapshot } from 'valtio'; import { subscribe, useSnapshot } from 'valtio';
@ -8,16 +9,19 @@ import showToast from '../utils/show-toast';
import states from '../utils/states'; import states from '../utils/states';
import AccountSheet from './account-sheet'; import AccountSheet from './account-sheet';
import Compose from './compose'; // import Compose from './compose';
import Drafts from './drafts'; import Drafts from './drafts';
import EmbedModal from './embed-modal'; import EmbedModal from './embed-modal';
import GenericAccounts from './generic-accounts'; import GenericAccounts from './generic-accounts';
import IntlSegmenterSuspense from './intl-segmenter-suspense';
import MediaAltModal from './media-alt-modal'; import MediaAltModal from './media-alt-modal';
import MediaModal from './media-modal'; import MediaModal from './media-modal';
import Modal from './modal'; import Modal from './modal';
import ReportModal from './report-modal'; import ReportModal from './report-modal';
import ShortcutsSettings from './shortcuts-settings'; import ShortcutsSettings from './shortcuts-settings';
const Compose = lazy(() => import('./compose'));
subscribe(states, (changes) => { subscribe(states, (changes) => {
for (const [action, path, value, prevValue] of changes) { for (const [action, path, value, prevValue] of changes) {
// When closing modal, focus on deck // When closing modal, focus on deck
@ -36,49 +40,51 @@ export default function Modals() {
<> <>
{!!snapStates.showCompose && ( {!!snapStates.showCompose && (
<Modal class="solid"> <Modal class="solid">
<Compose <IntlSegmenterSuspense>
replyToStatus={ <Compose
typeof snapStates.showCompose !== 'boolean' replyToStatus={
? snapStates.showCompose.replyToStatus typeof snapStates.showCompose !== 'boolean'
: window.__COMPOSE__?.replyToStatus || null ? snapStates.showCompose.replyToStatus
} : window.__COMPOSE__?.replyToStatus || null
editStatus={
states.showCompose?.editStatus ||
window.__COMPOSE__?.editStatus ||
null
}
draftStatus={
states.showCompose?.draftStatus ||
window.__COMPOSE__?.draftStatus ||
null
}
onClose={(results) => {
const { newStatus, instance, type } = results || {};
states.showCompose = false;
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
showToast({
text: {
post: 'Post published. Check it out.',
reply: 'Reply posted. Check it out.',
edit: 'Post updated. Check it out.',
}[type || 'post'],
delay: 1000,
duration: 10_000, // 10 seconds
onClick: (toast) => {
toast.hideToast();
states.prevLocation = location;
navigate(
instance
? `/${instance}/s/${newStatus.id}`
: `/s/${newStatus.id}`,
);
},
});
} }
}} editStatus={
/> states.showCompose?.editStatus ||
window.__COMPOSE__?.editStatus ||
null
}
draftStatus={
states.showCompose?.draftStatus ||
window.__COMPOSE__?.draftStatus ||
null
}
onClose={(results) => {
const { newStatus, instance, type } = results || {};
states.showCompose = false;
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
showToast({
text: {
post: 'Post published. Check it out.',
reply: 'Reply posted. Check it out.',
edit: 'Post updated. Check it out.',
}[type || 'post'],
delay: 1000,
duration: 10_000, // 10 seconds
onClick: (toast) => {
toast.hideToast();
states.prevLocation = location;
navigate(
instance
? `/${instance}/s/${newStatus.id}`
: `/s/${newStatus.id}`,
);
},
});
}
}}
/>
</IntlSegmenterSuspense>
</Modal> </Modal>
)} )}
{!!snapStates.showSettings && ( {!!snapStates.showSettings && (
@ -179,6 +185,7 @@ export default function Modals() {
excludeRelationshipAttrs={ excludeRelationshipAttrs={
snapStates.showGenericAccounts.excludeRelationshipAttrs snapStates.showGenericAccounts.excludeRelationshipAttrs
} }
postID={snapStates.showGenericAccounts.postID}
onClose={() => (states.showGenericAccounts = false)} onClose={() => (states.showGenericAccounts = false)}
/> />
</Modal> </Modal>

View file

@ -88,3 +88,7 @@
.sparkle-icon { .sparkle-icon {
animation: sparkle-icon 0.3s ease-in-out infinite alternate; animation: sparkle-icon 0.3s ease-in-out infinite alternate;
} }
.nav-submenu {
max-width: 14em;
}

View file

@ -1,17 +1,13 @@
import './nav-menu.css'; import './nav-menu.css';
import { import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
ControlledMenu,
MenuDivider,
MenuItem,
SubMenu,
} from '@szhsin/react-menu';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useLongPress } from 'use-long-press'; import { useLongPress } from 'use-long-press';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { getLists } from '../utils/lists';
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
import states from '../utils/states'; import states from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
@ -19,21 +15,19 @@ import store from '../utils/store';
import Avatar from './avatar'; import Avatar from './avatar';
import Icon from './icon'; import Icon from './icon';
import MenuLink from './menu-link'; import MenuLink from './menu-link';
import SubMenu2 from './submenu2';
function NavMenu(props) { function NavMenu(props) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { masto, instance, authenticated } = api(); const { masto, instance, authenticated } = api();
const [currentAccount, setCurrentAccount] = useState(); const [currentAccount, moreThanOneAccount] = useMemo(() => {
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
useEffect(() => {
const accounts = store.local.getJSON('accounts') || []; const accounts = store.local.getJSON('accounts') || [];
const acc = accounts.find( const acc =
(account) => account.info.id === store.session.get('currentAccount'), accounts.find(
); (account) => account.info.id === store.session.get('currentAccount'),
if (acc) setCurrentAccount(acc); ) || accounts[0];
setMoreThanOneAccount(accounts.length > 1); return [acc, accounts.length > 1];
}, []); }, []);
// Home = Following // Home = Following
@ -89,6 +83,13 @@ function NavMenu(props) {
return results; return results;
} }
const [lists, setLists] = useState([]);
useEffect(() => {
if (menuState === 'open') {
getLists().then(setLists);
}
}, [menuState === 'open']);
const buttonClickTS = useRef(); const buttonClickTS = useRef();
return ( return (
<> <>
@ -97,7 +98,7 @@ function NavMenu(props) {
type="button" type="button"
class={`button plain nav-menu-button ${ class={`button plain nav-menu-button ${
moreThanOneAccount ? 'with-avatar' : '' moreThanOneAccount ? 'with-avatar' : ''
} ${open ? 'active' : ''}`} } ${menuState === 'open' ? 'active' : ''}`}
style={{ position: 'relative' }} style={{ position: 'relative' }}
onClick={() => { onClick={() => {
buttonClickTS.current = Date.now(); buttonClickTS.current = Date.now();
@ -203,13 +204,44 @@ function NavMenu(props) {
<Icon icon="user" size="l" /> <span>Profile</span> <Icon icon="user" size="l" /> <span>Profile</span>
</MenuLink> </MenuLink>
)} )}
<MenuLink to="/l"> {lists?.length > 0 ? (
<Icon icon="list" size="l" /> <span>Lists</span> <SubMenu2
</MenuLink> menuClassName="nav-submenu"
overflow="auto"
gap={-8}
label={
<>
<Icon icon="list" size="l" />
<span class="menu-grow">Lists</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/l">
<span>All Lists</span>
</MenuLink>
{lists?.length > 0 && (
<>
<MenuDivider />
{lists.map((list) => (
<MenuLink key={list.id} to={`/l/${list.id}`}>
<span>{list.title}</span>
</MenuLink>
))}
</>
)}
</SubMenu2>
) : (
<MenuLink to="/l">
<Icon icon="list" size="l" />
<span>Lists</span>
</MenuLink>
)}
<MenuLink to="/b"> <MenuLink to="/b">
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span> <Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
</MenuLink> </MenuLink>
<SubMenu <SubMenu2
menuClassName="nav-submenu"
overflow="auto" overflow="auto"
gap={-8} gap={-8}
label={ label={
@ -223,11 +255,15 @@ function NavMenu(props) {
<MenuLink to="/f"> <MenuLink to="/f">
<Icon icon="heart" size="l" /> <span>Likes</span> <Icon icon="heart" size="l" /> <span>Likes</span>
</MenuLink> </MenuLink>
<MenuLink to="/ft"> <MenuLink to="/fh">
<Icon icon="hashtag" size="l" />{' '} <Icon icon="hashtag" size="l" />{' '}
<span>Followed Hashtags</span> <span>Followed Hashtags</span>
</MenuLink> </MenuLink>
<MenuDivider /> <MenuDivider />
<MenuLink to="/ft">
<Icon icon="filters" size="l" />
Filters
</MenuLink>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
states.showGenericAccounts = { states.showGenericAccounts = {
@ -253,7 +289,7 @@ function NavMenu(props) {
<Icon icon="block" size="l" /> <Icon icon="block" size="l" />
Blocked users&hellip; Blocked users&hellip;
</MenuItem>{' '} </MenuItem>{' '}
</SubMenu> </SubMenu2>
<MenuDivider /> <MenuDivider />
<MenuItem <MenuItem
onClick={() => { onClick={() => {

View file

@ -2,11 +2,12 @@ import { Fragment } from 'preact';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import states from '../utils/states'; import states, { statusKey } from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import useTruncated from '../utils/useTruncated'; import useTruncated from '../utils/useTruncated';
import Avatar from './avatar'; import Avatar from './avatar';
import CustomEmoji from './custom-emoji';
import FollowRequestButtons from './follow-request-buttons'; import FollowRequestButtons from './follow-request-buttons';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
@ -25,6 +26,9 @@ const NOTIFICATION_ICONS = {
update: 'pencil', update: 'pencil',
'admin.signup': 'account-edit', 'admin.signup': 'account-edit',
'admin.report': 'account-warning', 'admin.report': 'account-warning',
severed_relationships: 'heart-break',
emoji_reaction: 'emoji2',
'pleroma:emoji_reaction': 'emoji2',
}; };
/* /*
@ -42,6 +46,24 @@ admin.sign_up = Someone signed up (optionally sent to admins)
admin.report = A new report has been filed admin.report = A new report has been filed
*/ */
function emojiText(emoji, emoji_url) {
let url;
let staticUrl;
if (typeof emoji_url === 'string') {
url = emoji_url;
} else {
url = emoji_url?.url;
staticUrl = emoji_url?.staticUrl;
}
return url ? (
<>
reacted to your post with{' '}
<CustomEmoji url={url} staticUrl={staticUrl} alt={emoji} />
</>
) : (
`reacted to your post with ${emoji}.`
);
}
const contentText = { const contentText = {
mention: 'mentioned you in their post.', mention: 'mentioned you in their post.',
status: 'published a post.', status: 'published a post.',
@ -63,6 +85,35 @@ const contentText = {
'favourite+reblog_reply': 'boosted & liked your reply.', 'favourite+reblog_reply': 'boosted & liked your reply.',
'admin.sign_up': 'signed up.', 'admin.sign_up': 'signed up.',
'admin.report': (targetAccount) => <>reported {targetAccount}</>, 'admin.report': (targetAccount) => <>reported {targetAccount}</>,
severed_relationships: (name) => (
<>
Lost connections with <i>{name}</i>.
</>
),
emoji_reaction: emojiText,
'pleroma:emoji_reaction': emojiText,
};
// account_suspension, domain_block, user_domain_block
const SEVERED_RELATIONSHIPS_TEXT = {
account_suspension: ({ from, targetName }) => (
<>
An admin from <i>{from}</i> has suspended <i>{targetName}</i>, which means
you can no longer receive updates from them or interact with them.
</>
),
domain_block: ({ from, targetName, followersCount, followingCount }) => (
<>
An admin from <i>{from}</i> has blocked <i>{targetName}</i>. Affected
followers: {followersCount}, followings: {followingCount}.
</>
),
user_domain_block: ({ targetName, followersCount, followingCount }) => (
<>
You have blocked <i>{targetName}</i>. Removed followers: {followersCount},
followings: {followingCount}.
</>
),
}; };
const AVATARS_LIMIT = 50; const AVATARS_LIMIT = 50;
@ -73,7 +124,8 @@ function Notification({
isStatic, isStatic,
disableContextMenu, disableContextMenu,
}) { }) {
const { id, status, account, report, _accounts, _statuses } = notification; const { id, status, account, report, event, _accounts, _statuses } =
notification;
let { type } = notification; let { type } = notification;
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update // status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
@ -128,13 +180,30 @@ function Notification({
if (typeof text === 'function') { if (typeof text === 'function') {
const count = _statuses?.length || _accounts?.length; const count = _statuses?.length || _accounts?.length;
if (count) { if (type === 'admin.report') {
text = text(count);
} else if (type === 'admin.report') {
const targetAccount = report?.targetAccount; const targetAccount = report?.targetAccount;
if (targetAccount) { if (targetAccount) {
text = text(<NameText account={targetAccount} showAvatar />); text = text(<NameText account={targetAccount} showAvatar />);
} }
} else if (type === 'severed_relationships') {
const targetName = event?.targetName;
if (targetName) {
text = text(targetName);
}
} else if (
(type === 'emoji_reaction' || type === 'pleroma:emoji_reaction') &&
notification.emoji
) {
const emojiURL =
notification.emoji_url || // This is string
status?.emojis?.find?.(
(emoji) =>
emoji?.shortcode ===
notification.emoji.replace(/^:/, '').replace(/:$/, ''),
); // Emoji object instead of string
text = text(notification.emoji, emojiURL);
} else if (count) {
text = text(count);
} }
} }
@ -159,6 +228,7 @@ function Notification({
accounts: _accounts, accounts: _accounts,
showReactions: type === 'favourite+reblog', showReactions: type === 'favourite+reblog',
excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [], excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [],
postID: statusKey(actualStatusID, instance),
}; };
}; };
@ -203,9 +273,11 @@ function Notification({
</b>{' '} </b>{' '}
</> </>
) : ( ) : (
<> account && (
<NameText account={account} showAvatar />{' '} <>
</> <NameText account={account} showAvatar />{' '}
</>
)
)} )}
</> </>
)} )}
@ -224,6 +296,23 @@ function Notification({
{type === 'follow_request' && ( {type === 'follow_request' && (
<FollowRequestButtons accountID={account.id} /> <FollowRequestButtons accountID={account.id} />
)} )}
{type === 'severed_relationships' && (
<div>
{SEVERED_RELATIONSHIPS_TEXT[event.type]({
from: instance,
...event,
})}
<br />
<a
href={`https://${instance}/severed_relationships`}
target="_blank"
rel="noopener noreferrer"
>
Learn more <Icon icon="external" size="s" />
</a>
.
</div>
)}
</> </>
)} )}
{_accounts?.length > 1 && ( {_accounts?.length > 1 && (

View file

@ -8,7 +8,7 @@ import dayjs from 'dayjs';
import dayjsTwitter from 'dayjs-twitter'; import dayjsTwitter from 'dayjs-twitter';
import localizedFormat from 'dayjs/plugin/localizedFormat'; import localizedFormat from 'dayjs/plugin/localizedFormat';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import { useMemo } from 'preact/hooks'; import { useEffect, useMemo, useReducer } from 'preact/hooks';
dayjs.extend(dayjsTwitter); dayjs.extend(dayjsTwitter);
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
@ -18,22 +18,49 @@ const dtf = new Intl.DateTimeFormat();
export default function RelativeTime({ datetime, format }) { export default function RelativeTime({ datetime, format }) {
if (!datetime) return null; if (!datetime) return null;
const [renderCount, rerender] = useReducer((x) => x + 1, 0);
const date = useMemo(() => dayjs(datetime), [datetime]); const date = useMemo(() => dayjs(datetime), [datetime]);
const dateStr = useMemo(() => { const [dateStr, dt, title] = useMemo(() => {
let str;
if (format === 'micro') { if (format === 'micro') {
// If date <= 1 day ago or day is within this year // If date <= 1 day ago or day is within this year
const now = dayjs(); const now = dayjs();
const dayDiff = now.diff(date, 'day'); const dayDiff = now.diff(date, 'day');
if (dayDiff <= 1 || now.year() === date.year()) { if (dayDiff <= 1 || now.year() === date.year()) {
return date.twitter(); str = date.twitter();
} else { } else {
return dtf.format(date.toDate()); str = dtf.format(date.toDate());
} }
} }
return date.fromNow(); if (!str) str = date.fromNow();
}, [date, format]); return [str, date.toISOString(), date.format('LLLL')];
const dt = useMemo(() => date.toISOString(), [date]); }, [date, format, renderCount]);
const title = useMemo(() => date.format('LLLL'), [date]);
useEffect(() => {
let timeout;
let raf;
function rafRerender() {
raf = requestAnimationFrame(() => {
rerender();
scheduleRerender();
});
}
function scheduleRerender() {
// If less than 1 minute, rerender every 10s
// If less than 1 hour rerender every 1m
// Else, don't need to rerender
if (date.diff(dayjs(), 'minute', true) < 1) {
timeout = setTimeout(rafRerender, 10_000);
} else if (date.diff(dayjs(), 'hour', true) < 1) {
timeout = setTimeout(rafRerender, 60_000);
}
}
scheduleRerender();
return () => {
clearTimeout(timeout);
cancelAnimationFrame(raf);
};
}, []);
return ( return (
<time datetime={dt} title={title}> <time datetime={dt} title={title}>

View file

@ -14,6 +14,7 @@ import tabMenuBarUrl from '../assets/tab-menu-bar.svg';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { fetchFollowedTags } from '../utils/followed-tags'; import { fetchFollowedTags } from '../utils/followed-tags';
import { getLists, getListTitle } from '../utils/lists';
import pmem from '../utils/pmem'; import pmem from '../utils/pmem';
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
import states from '../utils/states'; import states from '../utils/states';
@ -43,7 +44,7 @@ const TYPES = [
const TYPE_TEXT = { const TYPE_TEXT = {
following: 'Home / Following', following: 'Home / Following',
notifications: 'Notifications', notifications: 'Notifications',
list: 'List', list: 'Lists',
public: 'Public (Local / Federated)', public: 'Public (Local / Federated)',
search: 'Search', search: 'Search',
'account-statuses': 'Account', 'account-statuses': 'Account',
@ -58,6 +59,7 @@ const TYPE_PARAMS = {
{ {
text: 'List ID', text: 'List ID',
name: 'id', name: 'id',
notRequired: true,
}, },
], ],
public: [ public: [
@ -122,10 +124,6 @@ const TYPE_PARAMS = {
}, },
], ],
}; };
const fetchListTitle = pmem(async ({ id }) => {
const list = await api().masto.v1.lists.$select(id).fetch();
return list.title;
});
const fetchAccountTitle = pmem(async ({ id }) => { const fetchAccountTitle = pmem(async ({ id }) => {
const account = await api().masto.v1.accounts.$select(id).fetch(); const account = await api().masto.v1.accounts.$select(id).fetch();
return account.username || account.acct || account.displayName; return account.username || account.acct || account.displayName;
@ -150,10 +148,11 @@ export const SHORTCUTS_META = {
icon: 'notification', icon: 'notification',
}, },
list: { list: {
id: 'list', id: ({ id }) => (id ? 'list' : 'lists'),
title: fetchListTitle, title: ({ id }) => (id ? getListTitle(id) : 'Lists'),
path: ({ id }) => `/l/${id}`, path: ({ id }) => (id ? `/l/${id}` : '/l'),
icon: 'list', icon: 'list',
excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []),
}, },
public: { public: {
id: 'public', id: 'public',
@ -496,18 +495,8 @@ function ShortcutsSettings({ onClose }) {
); );
} }
const FETCH_MAX_AGE = 1000 * 60; // 1 minute
const fetchLists = pmem(
() => {
const { masto } = api();
return masto.v1.lists.list();
},
{
maxAge: FETCH_MAX_AGE,
},
);
const FORM_NOTES = { const FORM_NOTES = {
list: `Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`,
search: `For multi-column mode, search term is required, else the column will not be shown.`, search: `For multi-column mode, search term is required, else the column will not be shown.`,
hashtag: 'Multiple hashtags are supported. Space-separated.', hashtag: 'Multiple hashtags are supported. Space-separated.',
}; };
@ -532,8 +521,7 @@ function ShortcutForm({
if (currentType !== 'list') return; if (currentType !== 'list') return;
try { try {
setUIState('loading'); setUIState('loading');
const lists = await fetchLists(); const lists = await getLists();
lists.sort((a, b) => a.title.localeCompare(b.title));
setLists(lists); setLists(lists);
setUIState('default'); setUIState('default');
} catch (e) { } catch (e) {
@ -644,6 +632,7 @@ function ShortcutForm({
disabled={disabled || uiState === 'loading'} disabled={disabled || uiState === 'loading'}
defaultValue={editMode ? shortcut.id : undefined} defaultValue={editMode ? shortcut.id : undefined}
> >
<option value=""></option>
{lists.map((list) => ( {lists.map((list) => (
<option value={list.id}>{list.title}</option> <option value={list.id}>{list.title}</option>
))} ))}

View file

@ -1,14 +1,15 @@
import './shortcuts.css'; import './shortcuts.css';
import { Menu, MenuItem } from '@szhsin/react-menu'; import { MenuDivider } from '@szhsin/react-menu';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useMemo, useRef } from 'preact/hooks'; import { useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import { SHORTCUTS_META } from '../components/shortcuts-settings'; import { SHORTCUTS_META } from '../components/shortcuts-settings';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { getLists } from '../utils/lists';
import states from '../utils/states'; import states from '../utils/states';
import AsyncText from './AsyncText'; import AsyncText from './AsyncText';
@ -16,6 +17,7 @@ import Icon from './icon';
import Link from './link'; import Link from './link';
import Menu2 from './menu2'; import Menu2 from './menu2';
import MenuLink from './menu-link'; import MenuLink from './menu-link';
import SubMenu2 from './submenu2';
function Shortcuts() { function Shortcuts() {
const { instance } = api(); const { instance } = api();
@ -34,47 +36,48 @@ function Shortcuts() {
const menuRef = useRef(); const menuRef = useRef();
const formattedShortcuts = useMemo( const hasLists = useRef(false);
() => const formattedShortcuts = shortcuts
shortcuts .map((pin, i) => {
.map((pin, i) => { const { type, ...data } = pin;
const { type, ...data } = pin; if (!SHORTCUTS_META[type]) return null;
if (!SHORTCUTS_META[type]) return null; let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
if (typeof id === 'function') { if (typeof id === 'function') {
id = id(data, i); id = id(data, i);
} }
if (typeof path === 'function') { if (typeof path === 'function') {
path = path( path = path(
{ {
...data, ...data,
instance: data.instance || instance, instance: data.instance || instance,
}, },
i, i,
); );
} }
if (typeof title === 'function') { if (typeof title === 'function') {
title = title(data, i); title = title(data, i);
} }
if (typeof subtitle === 'function') { if (typeof subtitle === 'function') {
subtitle = subtitle(data, i); subtitle = subtitle(data, i);
} }
if (typeof icon === 'function') { if (typeof icon === 'function') {
icon = icon(data, i); icon = icon(data, i);
} }
return { if (id === 'lists') {
id, hasLists.current = true;
path, }
title,
subtitle, return {
icon, id,
}; path,
}) title,
.filter(Boolean), subtitle,
[shortcuts], icon,
); };
})
.filter(Boolean);
const navigate = useNavigate(); const navigate = useNavigate();
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => { useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
@ -88,6 +91,8 @@ function Shortcuts() {
} }
}); });
const [lists, setLists] = useState([]);
return ( return (
<div id="shortcuts"> <div id="shortcuts">
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? ( {snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
@ -147,6 +152,11 @@ function Shortcuts() {
menuClassName="glass-menu shortcuts-menu" menuClassName="glass-menu shortcuts-menu"
gap={8} gap={8}
position="anchor" position="anchor"
onMenuChange={(e) => {
if (e.open && hasLists.current) {
getLists().then(setLists);
}
}}
menuButton={ menuButton={
<button <button
type="button" type="button"
@ -171,6 +181,35 @@ function Shortcuts() {
} }
> >
{formattedShortcuts.map(({ id, path, title, subtitle, icon }, i) => { {formattedShortcuts.map(({ id, path, title, subtitle, icon }, i) => {
if (id === 'lists') {
return (
<SubMenu2
menuClassName="glass-menu"
overflow="auto"
gap={-8}
label={
<>
<Icon icon={icon} size="l" />
<span class="menu-grow">
<AsyncText>{title}</AsyncText>
</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/l">
<span>All Lists</span>
</MenuLink>
<MenuDivider />
{lists?.map((list) => (
<MenuLink key={list.id} to={`/l/${list.id}`}>
<span>{list.title}</span>
</MenuLink>
))}
</SubMenu2>
);
}
return ( return (
<MenuLink <MenuLink
to={path} to={path}

View file

@ -105,7 +105,7 @@
padding: 2px; padding: 2px;
vertical-align: top; vertical-align: top;
text-transform: uppercase; text-transform: uppercase;
text-shadow: 0 1px var(--bg-color); /* text-shadow: 0 1px var(--bg-color); */
&:hover { &:hover {
color: var(--text-color); color: var(--text-color);
@ -160,7 +160,7 @@
display: block; display: block;
position: relative; position: relative;
&:after { &[data-read-more]:after {
content: attr(data-read-more); content: attr(data-read-more);
line-height: 1; line-height: 1;
display: inline-block; display: inline-block;
@ -618,31 +618,33 @@
~ *:not( ~ *:not(
.content.truncated, .content.truncated,
.media-container, .media-container,
.media-first-container,
.card, .card,
.media-figure-multiple, .media-figure-multiple,
.spoiler-media-button .spoiler-media-button
), ),
~ .card .meta-container { ~ .card .meta-container {
/* filter: blur(5px) invert(0.5);
image-rendering: crisp-edges;
image-rendering: pixelated; */
opacity: 0.2; opacity: 0.2;
text-decoration-thickness: 1.5em; text-decoration-thickness: 1.5em;
text-decoration-line: line-through; text-decoration-line: line-through;
/* text-rendering: optimizeSpeed; */
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
/* contain: layout; */
/* transform: scale(0.97);
transition: transform 0.1s ease-in-out; */
* { * {
text-decoration-color: inherit; text-decoration-color: inherit;
text-decoration-thickness: 1.5em; text-decoration-thickness: 1.5em;
text-decoration-line: line-through; text-decoration-line: line-through;
/* text-rendering: optimizeSpeed; */
} }
}
~ *:not(
.media-container,
.media-first-container,
.card,
.media-figure-multiple,
.spoiler-media-button
),
~ .card .meta-container {
img { img {
filter: invert(0.5); filter: invert(0.5);
background-color: black; background-color: black;
@ -708,11 +710,12 @@
} }
} }
~ :is(.media-container, .media-figure-multiple) .media { ~ :is(.media-container, .media-first-container, .media-figure-multiple)
.media {
background-image: radial-gradient( background-image: radial-gradient(
circle at 50% 50%, circle at 50% 50%,
var(--average-color, var(--bg-faded-color)), var(--average-color, var(--bg-faded-color)),
var(--bg-color) 20em var(--bg-color) 25em
); );
> *:not(.media-play, .alt-badge) { > *:not(.media-play, .alt-badge) {
@ -790,7 +793,9 @@
black 1.5em black 1.5em
); );
} }
.timeline-deck .status:not(.truncated .status) .content.truncated:after { .timeline-deck
.status:not(.truncated .status)
.content.truncated[data-read-more]:after {
content: attr(data-read-more); content: attr(data-read-more);
line-height: 1; line-height: 1;
display: inline-block; display: inline-block;
@ -908,7 +913,7 @@
grid-auto-rows: 1fr; grid-auto-rows: 1fr;
gap: 2px; gap: 2px;
/* height: 160px; */ /* height: 160px; */
min-height: 88px; min-height: var(--min-dimension);
height: auto; height: auto;
max-height: max(160px, 33vh); max-height: max(160px, 33vh);
} }
@ -1037,9 +1042,9 @@
.status .media-container.media-eq1 .media { .status .media-container.media-eq1 .media {
display: inline-block; display: inline-block;
max-width: 100% !important; max-width: 100% !important;
min-width: 88px; min-width: var(--min-dimension);
/* width: auto; */ /* width: auto; */
min-height: 88px; min-height: var(--min-dimension);
/* --maxAspectHeight: max(160px, 33vh); /* --maxAspectHeight: max(160px, 33vh);
--aspectWidth: calc(--width / --height * var(--maxAspectHeight)); */ --aspectWidth: calc(--width / --height * var(--maxAspectHeight)); */
width: min(var(--aspectWidth), var(--width), 100%); width: min(var(--aspectWidth), var(--width), 100%);
@ -1300,7 +1305,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
:is(.status, .media-post) .media-audio { :is(.status, .media-post) .media-audio {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 88px; min-height: var(--min-dimension);
background-image: radial-gradient( background-image: radial-gradient(
circle at center center, circle at center center,
transparent, transparent,
@ -1314,6 +1319,227 @@ body:has(#modal-container .carousel) .status .media img:hover {
background-blend-mode: multiply; background-blend-mode: multiply;
} }
.status.skeleton .media-first-container {
min-height: 3em;
background-color: var(--outline-color);
}
.status-media-first {
.meta-name {
opacity: 0.65;
transition: opacity 0.5s ease-in-out;
b + i {
opacity: 0;
transition: opacity 0.5s ease-in-out;
}
}
:is(:hover, :focus) > & .meta-name {
opacity: 1;
b + i {
opacity: 0.5;
}
}
.media-first-spoiler-content {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
transition: opacity 0.5s ease-in-out;
opacity: 0.5;
}
&:hover .media-first-spoiler-content {
opacity: 1;
}
.media-first-spoiler-button {
display: inline-flex !important;
}
.media-first-container {
margin-top: 8px;
display: flex;
max-height: 80vh;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
user-select: none;
margin-inline: -16px;
position: relative;
scrollbar-width: none;
/* border: var(--hairline-width) solid var(--outline-color);
border-inline-width: 0;
background-color: var(--bg-faded-color); */
@media (min-width: 40em) {
margin-inline: 0;
/* border-radius: 4px; */
border-inline-width: var(--hairline-width);
}
&::-webkit-scrollbar {
display: none;
}
> .media-first-item {
scroll-snap-align: center;
scroll-snap-stop: always;
flex-shrink: 0;
display: flex;
width: 100%;
align-items: center;
justify-content: center;
&:not(:only-child) {
background-color: var(--bg-blur-color);
box-shadow: inset 0 0 0 var(--hairline-width) var(--outline-color);
}
.media {
/* background-color: var(--average-color, var(--bg-faded-color)); */
width: var(--width);
max-width: 100%;
max-height: 100%;
min-height: var(--min-dimension);
/* max-height: min(var(--height), 80vh); */
&:active {
transform: none;
}
img,
video {
object-fit: scale-down;
animation: none;
&:not([data-loaded='true']) {
background-color: var(--bg-color);
}
}
}
}
.media-carousel-controls {
flex-shrink: 0;
width: 100%;
position: sticky;
right: 0;
left: 0;
pointer-events: none;
display: flex;
justify-content: space-between;
}
.carousel-indexer {
z-index: 1;
position: absolute;
top: 8px;
right: 8px;
color: var(--media-fg-color);
background-color: var(--media-bg-color);
padding: 2px 8px;
border-radius: 16px;
font-size: 0.8em;
font-variant-numeric: tabular-nums;
opacity: 0.6;
transition: opacity 1.5s ease-in-out;
border: var(--hairline-width) solid var(--media-outline-color);
}
.media-carousel-button {
display: flex;
flex-shrink: 0;
padding-inline: 8px;
margin-block: 3em;
pointer-events: auto;
cursor: pointer;
align-items: center;
justify-content: center;
}
.carousel-button {
@media (pointer: coarse) {
display: none;
}
+ .carousel-button {
left: auto;
right: 8px;
}
}
@media (hover: hover) and (pointer: fine) {
.carousel-button {
filter: opacity(0);
}
&:hover .carousel-button {
filter: opacity(1);
}
}
}
:is(:hover, :focus) > & .carousel-indexer {
opacity: 0;
}
.media-carousel-dots {
pointer-events: none;
display: flex;
gap: 5px;
justify-content: center;
margin-top: 8px;
padding: 8px;
.carousel-dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background-color: var(--text-color);
transition: all 0.3s ease-in-out;
opacity: 0.3;
&.active {
opacity: 1;
background-color: var(--text-color);
transform: scale(1.5);
}
}
}
.media-first-content {
margin-top: 8px;
height: 1.75em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9em;
mask-image: linear-gradient(to bottom, black 1.5em, transparent 1.75em);
opacity: 0.5;
transition: opacity 0.5s ease-in-out;
@media (min-width: 40em) {
margin-inline: 16px;
}
* {
text-align: center;
/* Brute force ellipsis */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap !important;
pointer-events: none;
}
a {
filter: grayscale(0.5);
}
}
:is(:hover, :focus) > & .media-first-content {
opacity: 1;
}
}
.status:not(.large) .hashtag-stuffing { .status:not(.large) .hashtag-stuffing {
opacity: 0.75; opacity: 0.75;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
@ -1585,16 +1811,16 @@ a:focus-visible .card img {
} }
.card .meta.domain { .card .meta.domain {
opacity: 1; opacity: 1;
color: var(--link-color); color: var(--text-insignificant-color);
white-space: nowrap;
text-overflow: ellipsis; .domain {
overflow: hidden; color: var(--link-color);
display: block; }
} }
.card:visited .meta.domain { .card:visited .meta .domain {
color: var(--link-visited-color); color: var(--link-visited-color);
} }
.card .meta.domain * { .card .meta .domain * {
vertical-align: middle; vertical-align: middle;
} }
a.card { a.card {
@ -1695,12 +1921,14 @@ a.card:is(:hover, :focus):visited {
} }
.poll-label input:is([type='radio'], [type='checkbox']) { .poll-label input:is([type='radio'], [type='checkbox']) {
flex-shrink: 0; flex-shrink: 0;
margin: 3px; margin: 0 3px;
min-height: 0.9em;
} }
.poll-option-votes { .poll-option-votes {
flex-shrink: 0; flex-shrink: 0;
font-size: 90%; font-size: 90%;
opacity: 0.75; opacity: 0.75;
line-height: 1;
} }
.poll-option-leading .poll-option-votes { .poll-option-leading .poll-option-votes {
font-weight: bold; font-weight: bold;
@ -1719,6 +1947,7 @@ a.card:is(:hover, :focus):visited {
} }
.poll-option-title { .poll-option-title {
text-shadow: 0 1px var(--bg-color); text-shadow: 0 1px var(--bg-color);
line-height: 1.2;
} }
.poll-option-title .icon { .poll-option-title .icon {
vertical-align: middle; vertical-align: middle;
@ -1753,6 +1982,13 @@ a.card:is(:hover, :focus):visited {
margin-left: calc(-50px - 16px); margin-left: calc(-50px - 16px);
} }
/* EMOJI REACTIONS */
.status.large .emoji-reactions {
cursor: default;
margin-left: calc(-50px - 16px);
}
/* ACTIONS */ /* ACTIONS */
.status .actions { .status .actions {
@ -2279,7 +2515,7 @@ a.card:is(:hover, :focus):visited {
mask-image: linear-gradient(to bottom, #000 80px, transparent); mask-image: linear-gradient(to bottom, #000 80px, transparent);
} }
&:after { &[data-read-more]:after {
content: attr(data-read-more); content: attr(data-read-more);
line-height: 1; line-height: 1;
display: inline-block; display: inline-block;

View file

@ -20,12 +20,14 @@ import {
useRef, useRef,
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import punycode from 'punycode';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useLongPress } from 'use-long-press'; import { useLongPress } from 'use-long-press';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import AccountBlock from '../components/account-block'; import CustomEmoji from '../components/custom-emoji';
import EmojiText from '../components/emoji-text'; import EmojiText from '../components/emoji-text';
import LazyShazam from '../components/lazy-shazam';
import Loader from '../components/loader'; import Loader from '../components/loader';
import Menu2 from '../components/menu2'; import Menu2 from '../components/menu2';
import MenuConfirm from '../components/menu-confirm'; import MenuConfirm from '../components/menu-confirm';
@ -167,15 +169,19 @@ function Status({
allowContextMenu, allowContextMenu,
showActionsBar, showActionsBar,
showReplyParent, showReplyParent,
mediaFirst,
}) { }) {
if (skeleton) { if (skeleton) {
return ( return (
<div class="status skeleton"> <div class={`status skeleton ${mediaFirst ? 'status-media-first' : ''}`}>
<Avatar size="xxl" /> {!mediaFirst && <Avatar size="xxl" />}
<div class="container"> <div class="container">
<div class="meta"> </div> <div class="meta">
{(size === 's' || mediaFirst) && <Avatar size="m" />}
</div>
<div class="content-container"> <div class="content-container">
<div class="content"> {mediaFirst && <div class="media-first-container" />}
<div class={`content ${mediaFirst ? 'media-first-content' : ''}`}>
<p> </p> <p> </p>
</div> </div>
</div> </div>
@ -241,8 +247,14 @@ function Status({
_deleted, _deleted,
_pinned, _pinned,
// _filtered, // _filtered,
// Non-Mastodon
emojiReactions,
} = status; } = status;
// if (!mediaAttachments?.length) mediaFirst = false;
const hasMediaAttachments = !!mediaAttachments?.length;
if (mediaFirst && hasMediaAttachments) size = 's';
const currentAccount = useMemo(() => { const currentAccount = useMemo(() => {
return store.session.get('currentAccount'); return store.session.get('currentAccount');
}, []); }, []);
@ -350,6 +362,7 @@ function Status({
size={size} size={size}
contentTextWeight={contentTextWeight} contentTextWeight={contentTextWeight}
readOnly={readOnly} readOnly={readOnly}
mediaFirst={mediaFirst}
/> />
</div> </div>
); );
@ -374,6 +387,7 @@ function Status({
contentTextWeight={contentTextWeight} contentTextWeight={contentTextWeight}
readOnly={readOnly} readOnly={readOnly}
enableCommentHint enableCommentHint
mediaFirst={mediaFirst}
/> />
</div> </div>
); );
@ -407,6 +421,7 @@ function Status({
contentTextWeight={contentTextWeight} contentTextWeight={contentTextWeight}
readOnly={readOnly} readOnly={readOnly}
enableCommentHint enableCommentHint
mediaFirst={mediaFirst}
/> />
</div> </div>
); );
@ -724,25 +739,6 @@ function Status({
const isPinnable = ['public', 'unlisted', 'private'].includes(visibility); const isPinnable = ['public', 'unlisted', 'private'].includes(visibility);
const StatusMenuItems = ( const StatusMenuItems = (
<> <>
{isSizeLarge && (
<>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
heading: 'Boosted/Liked by…',
fetchAccounts: fetchBoostedLikedByAccounts,
instance,
showReactions: true,
};
}}
>
<Icon icon="react" />
<span>
Boosted/Liked by<span class="more-insignificant"></span>
</span>
</MenuItem>
</>
)}
{!isSizeLarge && sameInstance && ( {!isSizeLarge && sameInstance && (
<> <>
<div class="menu-control-group-horizontal status-menu"> <div class="menu-control-group-horizontal status-menu">
@ -840,56 +836,85 @@ function Status({
</div> </div>
</> </>
)} )}
{(enableTranslate || !language || differentLanguage) && <MenuDivider />} {!isSizeLarge && sameInstance && (isSizeLarge || showActionsBar) && (
{enableTranslate ? ( <MenuDivider />
<div class={supportsTTS ? 'menu-horizontal' : ''}> )}
{(isSizeLarge || showActionsBar) && (
<>
<MenuItem <MenuItem
disabled={forceTranslate}
onClick={() => { onClick={() => {
setForceTranslate(true); states.showGenericAccounts = {
heading: 'Boosted/Liked by…',
fetchAccounts: fetchBoostedLikedByAccounts,
instance,
showReactions: true,
postID: sKey,
};
}} }}
> >
<Icon icon="translate" /> <Icon icon="react" />
<span>Translate</span> <span>
Boosted/Liked by<span class="more-insignificant"></span>
</span>
</MenuItem> </MenuItem>
{supportsTTS && ( </>
<MenuItem )}
onClick={() => { {!mediaFirst && (
const postText = getPostText(status); <>
if (postText) { {(enableTranslate || !language || differentLanguage) && (
speak(postText, language); <MenuDivider />
}
}}
>
<Icon icon="speak" />
<span>Speak</span>
</MenuItem>
)} )}
</div> {enableTranslate ? (
) : ( <div class={supportsTTS ? 'menu-horizontal' : ''}>
(!language || differentLanguage) && (
<div class={supportsTTS ? 'menu-horizontal' : ''}>
<MenuLink
to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuLink>
{supportsTTS && (
<MenuItem <MenuItem
disabled={forceTranslate}
onClick={() => { onClick={() => {
const postText = getPostText(status); setForceTranslate(true);
if (postText) {
speak(postText, language);
}
}} }}
> >
<Icon icon="speak" /> <Icon icon="translate" />
<span>Speak</span> <span>Translate</span>
</MenuItem> </MenuItem>
)} {supportsTTS && (
</div> <MenuItem
) onClick={() => {
const postText = getPostText(status);
if (postText) {
speak(postText, language);
}
}}
>
<Icon icon="speak" />
<span>Speak</span>
</MenuItem>
)}
</div>
) : (
(!language || differentLanguage) && (
<div class={supportsTTS ? 'menu-horizontal' : ''}>
<MenuLink
to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuLink>
{supportsTTS && (
<MenuItem
onClick={() => {
const postText = getPostText(status);
if (postText) {
speak(postText, language);
}
}}
>
<Icon icon="speak" />
<span>Speak</span>
</MenuItem>
)}
</div>
)
)}
</>
)} )}
{((!isSizeLarge && sameInstance) || {((!isSizeLarge && sameInstance) ||
enableTranslate || enableTranslate ||
@ -1376,7 +1401,7 @@ function Status({
}[size] }[size]
} ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''} ${ } ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''} ${
isContextMenuOpen ? 'status-menu-open' : '' isContextMenuOpen ? 'status-menu-open' : ''
}`} } ${mediaFirst && hasMediaAttachments ? 'status-media-first' : ''}`}
onMouseEnter={debugHover} onMouseEnter={debugHover}
onContextMenu={(e) => { onContextMenu={(e) => {
if (!showContextMenu) return; if (!showContextMenu) return;
@ -1586,11 +1611,14 @@ function Status({
}`} }`}
/> />
) : ( ) : (
<Icon visibility !== 'public' &&
icon={visibilityIconsMap[visibility]} visibility !== 'direct' && (
alt={visibilityText[visibility]} <Icon
size="s" icon={visibilityIconsMap[visibility]}
/> alt={visibilityText[visibility]}
size="s"
/>
)
)}{' '} )}{' '}
<RelativeTime datetime={createdAtDate} format="micro" /> <RelativeTime datetime={createdAtDate} format="micro" />
{!previewMode && !readOnly && ( {!previewMode && !readOnly && (
@ -1641,11 +1669,15 @@ function Status({
// {StatusMenuItems} // {StatusMenuItems}
// </Menu> // </Menu>
<span class="time"> <span class="time">
<Icon {visibility !== 'public' && visibility !== 'direct' && (
icon={visibilityIconsMap[visibility]} <>
alt={visibilityText[visibility]} <Icon
size="s" icon={visibilityIconsMap[visibility]}
/>{' '} alt={visibilityText[visibility]}
size="s"
/>{' '}
</>
)}
<RelativeTime datetime={createdAtDate} format="micro" /> <RelativeTime datetime={createdAtDate} format="micro" />
</span> </span>
))} ))}
@ -1697,188 +1729,253 @@ function Status({
} }
} }
> >
{!!spoilerText && ( {mediaFirst && hasMediaAttachments ? (
<> <>
<div {(!!spoilerText || !!sensitive) && !readingExpandSpoilers && (
class="content spoiler-content" <>
lang={language} {!!spoilerText && (
dir="auto" <span
ref={spoilerContentRef} class="spoiler-content media-first-spoiler-content"
data-read-more={readMoreText} lang={language}
> dir="auto"
<p> ref={spoilerContentRef}
<EmojiText text={spoilerText} emojis={emojis} /> data-read-more={readMoreText}
</p> >
</div> <EmojiText text={spoilerText} emojis={emojis} />{' '}
{readingExpandSpoilers || previewMode ? ( </span>
<div class="spoiler-divider"> )}
<Icon icon="eye-open" /> Content warning <button
class={`light spoiler-button media-first-spoiler-button ${
showSpoiler ? 'spoiling' : ''
}`}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (showSpoiler) {
delete states.spoilers[id];
if (!readingExpandSpoilers) {
delete states.spoilersMedia[id];
}
} else {
states.spoilers[id] = true;
if (!readingExpandSpoilers) {
states.spoilersMedia[id] = true;
}
}
}}
>
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
{showSpoiler ? 'Show less' : 'Show content'}
</button>
</>
)}
<MediaFirstContainer
mediaAttachments={mediaAttachments}
language={language}
postID={id}
instance={instance}
/>
{!!content && (
<div class="media-first-content content" ref={contentRef}>
<PostContent
post={status}
instance={instance}
previewMode={previewMode}
/>
</div> </div>
) : (
<button
class={`light spoiler-button ${
showSpoiler ? 'spoiling' : ''
}`}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (showSpoiler) {
delete states.spoilers[id];
if (!readingExpandSpoilers) {
delete states.spoilersMedia[id];
}
} else {
states.spoilers[id] = true;
if (!readingExpandSpoilers) {
states.spoilersMedia[id] = true;
}
}
}}
>
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
{showSpoiler ? 'Show less' : 'Show content'}
</button>
)} )}
</> </>
)} ) : (
{!!content && ( <>
<div {!!spoilerText && (
class="content" <>
ref={contentRef} <div
data-read-more={readMoreText} class="content spoiler-content"
>
<PostContent
post={status}
instance={instance}
previewMode={previewMode}
/>
<QuoteStatuses id={id} instance={instance} level={quoted} />
</div>
)}
{!!poll && (
<Poll
lang={language}
poll={poll}
readOnly={readOnly || !sameInstance || !authenticated}
onUpdate={(newPoll) => {
states.statuses[sKey].poll = newPoll;
}}
refresh={() => {
return masto.v1.polls
.$select(poll.id)
.fetch()
.then((pollResponse) => {
states.statuses[sKey].poll = pollResponse;
})
.catch((e) => {}); // Silently fail
}}
votePoll={(choices) => {
return masto.v1.polls
.$select(poll.id)
.votes.create({
choices,
})
.then((pollResponse) => {
states.statuses[sKey].poll = pollResponse;
})
.catch((e) => {}); // Silently fail
}}
/>
)}
{(((enableTranslate || inlineTranslate) &&
!!content.trim() &&
!!getHTMLText(emojifyText(content, emojis)) &&
differentLanguage) ||
forceTranslate) && (
<TranslationBlock
forceTranslate={forceTranslate || inlineTranslate}
mini={!isSizeLarge && !withinContext}
sourceLanguage={language}
text={getPostText(status)}
/>
)}
{!previewMode &&
sensitive &&
!!mediaAttachments.length &&
readingExpandMedia !== 'show_all' && (
<button
class={`plain spoiler-media-button ${
showSpoilerMedia ? 'spoiling' : ''
}`}
type="button"
hidden={!readingExpandSpoilers && !!spoilerText}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (showSpoilerMedia) {
delete states.spoilersMedia[id];
} else {
states.spoilersMedia[id] = true;
}
}}
>
<Icon icon={showSpoilerMedia ? 'eye-open' : 'eye-close'} />{' '}
{showSpoilerMedia ? 'Show less' : 'Show media'}
</button>
)}
{!!mediaAttachments.length && (
<MultipleMediaFigure
lang={language}
enabled={showMultipleMediaCaptions}
captionChildren={captionChildren}
>
<div
ref={mediaContainerRef}
class={`media-container media-eq${mediaAttachments.length} ${
mediaAttachments.length > 2 ? 'media-gt2' : ''
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
>
{displayedMediaAttachments.map((media, i) => (
<Media
key={media.id}
media={media}
autoAnimate={isSizeLarge}
showCaption={mediaAttachments.length === 1}
allowLongerCaption={
!content && mediaAttachments.length === 1
}
lang={language} lang={language}
altIndex={ dir="auto"
showMultipleMediaCaptions && ref={spoilerContentRef}
!!media.description && data-read-more={readMoreText}
i + 1 >
} <p>
to={`/${instance}/s/${id}?${ <EmojiText text={spoilerText} emojis={emojis} />
withinContext ? 'media' : 'media-only' </p>
}=${i + 1}`} </div>
onClick={ {readingExpandSpoilers || previewMode ? (
onMediaClick <div class="spoiler-divider">
? (e) => { <Icon icon="eye-open" /> Content warning
onMediaClick(e, i, media, status); </div>
) : (
<button
class={`light spoiler-button ${
showSpoiler ? 'spoiling' : ''
}`}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (showSpoiler) {
delete states.spoilers[id];
if (!readingExpandSpoilers) {
delete states.spoilersMedia[id];
} }
: undefined } else {
} states.spoilers[id] = true;
if (!readingExpandSpoilers) {
states.spoilersMedia[id] = true;
}
}
}}
>
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
{showSpoiler ? 'Show less' : 'Show content'}
</button>
)}
</>
)}
{!!content && (
<div
class="content"
ref={contentRef}
data-read-more={readMoreText}
>
<PostContent
post={status}
instance={instance}
previewMode={previewMode}
/> />
))} <QuoteStatuses id={id} instance={instance} level={quoted} />
</div> </div>
</MultipleMediaFigure> )}
{!!poll && (
<Poll
lang={language}
poll={poll}
readOnly={readOnly || !sameInstance || !authenticated}
onUpdate={(newPoll) => {
states.statuses[sKey].poll = newPoll;
}}
refresh={() => {
return masto.v1.polls
.$select(poll.id)
.fetch()
.then((pollResponse) => {
states.statuses[sKey].poll = pollResponse;
})
.catch((e) => {}); // Silently fail
}}
votePoll={(choices) => {
return masto.v1.polls
.$select(poll.id)
.votes.create({
choices,
})
.then((pollResponse) => {
states.statuses[sKey].poll = pollResponse;
})
.catch((e) => {}); // Silently fail
}}
/>
)}
{(((enableTranslate || inlineTranslate) &&
!!content.trim() &&
!!getHTMLText(emojifyText(content, emojis)) &&
differentLanguage) ||
forceTranslate) && (
<TranslationBlock
forceTranslate={forceTranslate || inlineTranslate}
mini={!isSizeLarge && !withinContext}
sourceLanguage={language}
text={getPostText(status)}
/>
)}
{!previewMode &&
sensitive &&
!!mediaAttachments.length &&
readingExpandMedia !== 'show_all' && (
<button
class={`plain spoiler-media-button ${
showSpoilerMedia ? 'spoiling' : ''
}`}
type="button"
hidden={!readingExpandSpoilers && !!spoilerText}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (showSpoilerMedia) {
delete states.spoilersMedia[id];
} else {
states.spoilersMedia[id] = true;
}
}}
>
<Icon
icon={showSpoilerMedia ? 'eye-open' : 'eye-close'}
/>{' '}
{showSpoilerMedia ? 'Show less' : 'Show media'}
</button>
)}
{!!mediaAttachments.length && (
<MultipleMediaFigure
lang={language}
enabled={showMultipleMediaCaptions}
captionChildren={captionChildren}
>
<div
ref={mediaContainerRef}
class={`media-container media-eq${
mediaAttachments.length
} ${mediaAttachments.length > 2 ? 'media-gt2' : ''} ${
mediaAttachments.length > 4 ? 'media-gt4' : ''
}`}
>
{displayedMediaAttachments.map((media, i) => (
<Media
key={media.id}
media={media}
autoAnimate={isSizeLarge}
showCaption={mediaAttachments.length === 1}
allowLongerCaption={
!content && mediaAttachments.length === 1
}
lang={language}
altIndex={
showMultipleMediaCaptions &&
!!media.description &&
i + 1
}
to={`/${instance}/s/${id}?${
withinContext ? 'media' : 'media-only'
}=${i + 1}`}
onClick={
onMediaClick
? (e) => {
onMediaClick(e, i, media, status);
}
: undefined
}
/>
))}
</div>
</MultipleMediaFigure>
)}
{!!card &&
/^https/i.test(card?.url) &&
!sensitive &&
!spoilerText &&
!poll &&
!mediaAttachments.length &&
!snapStates.statusQuotes[sKey] && (
<Card
card={card}
selfReferential={
card?.url === status.url || card?.url === status.uri
}
instance={currentInstance}
/>
)}
</>
)} )}
{!!card &&
/^https/i.test(card?.url) &&
!sensitive &&
!spoilerText &&
!poll &&
!mediaAttachments.length &&
!snapStates.statusQuotes[sKey] && (
<Card
card={card}
selfReferential={
card?.url === status.url || card?.url === status.uri
}
instance={currentInstance}
/>
)}
</div> </div>
{!isSizeLarge && showCommentCount && ( {!isSizeLarge && showCommentCount && (
<div class="content-comment-hint insignificant"> <div class="content-comment-hint insignificant">
@ -1925,6 +2022,63 @@ function Status({
</> </>
)} )}
</div> </div>
{!!emojiReactions?.length && (
<div class="emoji-reactions">
{emojiReactions.map((emojiReaction) => {
const { name, count, me, url, staticUrl } = emojiReaction;
if (url) {
// Some servers return url and staticUrl
return (
<span
class={`emoji-reaction tag ${
me ? '' : 'insignificant'
}`}
>
<CustomEmoji
alt={name}
url={url}
staticUrl={staticUrl}
/>{' '}
{count}
</span>
);
}
const isShortCode = /^:.+?:$/.test(name);
if (isShortCode) {
const emoji = emojis.find(
(e) =>
e.shortcode ===
name.replace(/^:/, '').replace(/:$/, ''),
);
if (emoji) {
return (
<span
class={`emoji-reaction tag ${
me ? '' : 'insignificant'
}`}
>
<CustomEmoji
alt={name}
url={emoji.url}
staticUrl={emoji.staticUrl}
/>{' '}
{count}
</span>
);
}
}
return (
<span
class={`emoji-reaction tag ${
me ? '' : 'insignificant'
}`}
>
{name} {count}
</span>
);
})}
</div>
)}
<div class={`actions ${_deleted ? 'disabled' : ''}`}> <div class={`actions ${_deleted ? 'disabled' : ''}`}>
<div class="action has-count"> <div class="action has-count">
<StatusButton <StatusButton
@ -2099,6 +2253,101 @@ function MultipleMediaFigure(props) {
); );
} }
function MediaFirstContainer(props) {
const { mediaAttachments, language, postID, instance } = props;
const moreThanOne = mediaAttachments.length > 1;
const carouselRef = useRef();
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
let handleScroll = () => {
const { clientWidth, scrollLeft } = carouselRef.current;
const index = Math.round(scrollLeft / clientWidth);
setCurrentIndex(index);
};
if (carouselRef.current) {
carouselRef.current.addEventListener('scroll', handleScroll, {
passive: true,
});
}
return () => {
if (carouselRef.current) {
carouselRef.current.removeEventListener('scroll', handleScroll);
}
};
}, []);
return (
<>
<div class="media-first-container" ref={carouselRef}>
{mediaAttachments.map((media, i) => (
<div class="media-first-item" key={media.id}>
<Media
media={media}
lang={language}
to={`/${instance}/s/${postID}?media-only=${i + 1}`}
/>
</div>
))}
{moreThanOne && (
<div class="media-carousel-controls">
<div class="carousel-indexer">
{currentIndex + 1}/{mediaAttachments.length}
</div>
<label class="media-carousel-button">
<button
type="button"
class="carousel-button"
hidden={currentIndex === 0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
carouselRef.current.focus();
carouselRef.current.scrollTo({
left: carouselRef.current.clientWidth * (currentIndex - 1),
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-left" />
</button>
</label>
<label class="media-carousel-button">
<button
type="button"
class="carousel-button"
hidden={currentIndex === mediaAttachments.length - 1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
carouselRef.current.focus();
carouselRef.current.scrollTo({
left: carouselRef.current.clientWidth * (currentIndex + 1),
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-right" />
</button>
</label>
</div>
)}
</div>
{moreThanOne && (
<div class="media-carousel-dots">
{mediaAttachments.map((media, i) => (
<span
key={media.id}
class={`carousel-dot ${i === currentIndex ? 'active' : ''}`}
/>
))}
</div>
)}
</>
);
}
function Card({ card, selfReferential, instance }) { function Card({ card, selfReferential, instance }) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { const {
@ -2177,9 +2426,9 @@ function Card({ card, selfReferential, instance }) {
); );
if (hasText && (image || (type === 'photo' && blurhash))) { if (hasText && (image || (type === 'photo' && blurhash))) {
const domain = new URL(url).hostname const domain = punycode.toUnicode(
.replace(/^www\./, '') new URL(url).hostname.replace(/^www\./, '').replace(/\/$/, ''),
.replace(/\/$/, ''); );
let blurhashImage; let blurhashImage;
const rgbAverageColor = const rgbAverageColor =
image && blurhash ? getBlurHashAverageColor(blurhash) : null; image && blurhash ? getBlurHashAverageColor(blurhash) : null;
@ -2228,8 +2477,14 @@ function Card({ card, selfReferential, instance }) {
/> />
</div> </div>
<div class="meta-container"> <div class="meta-container">
<p class="meta domain" dir="auto"> <p class="meta domain">
{domain} <span class="domain">{domain}</span>{' '}
{!!publishedAt && <>&middot; </>}
{!!publishedAt && (
<>
<RelativeTime datetime={publishedAt} format="micro" />
</>
)}
</p> </p>
<p class="title" dir="auto" title={title}> <p class="title" dir="auto" title={title}>
{title} {title}
@ -2289,7 +2544,9 @@ function Card({ card, selfReferential, instance }) {
// ); // );
} }
if (hasText && !image) { if (hasText && !image) {
const domain = new URL(url).hostname.replace(/^www\./, ''); const domain = punycode.toUnicode(
new URL(url).hostname.replace(/^www\./, ''),
);
return ( return (
<a <a
href={cardStatusURL || url} href={cardStatusURL || url}
@ -2301,7 +2558,15 @@ function Card({ card, selfReferential, instance }) {
> >
<div class="meta-container"> <div class="meta-container">
<p class="meta domain"> <p class="meta domain">
<Icon icon="link" size="s" /> <span>{domain}</span> <span class="domain">
<Icon icon="link" size="s" /> <span>{domain}</span>
</span>{' '}
{!!publishedAt && <>&middot; </>}
{!!publishedAt && (
<>
<RelativeTime datetime={publishedAt} format="micro" />
</>
)}
</p> </p>
<p class="title" title={title}> <p class="title" title={title}>
{title} {title}
@ -2804,21 +3069,6 @@ function StatusButton({
); );
} }
export function formatDuration(time) {
if (!time) return;
let hours = Math.floor(time / 3600);
let minutes = Math.floor((time % 3600) / 60);
let seconds = Math.round(time % 60);
if (hours === 0) {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
} else {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
.toString()
.padStart(2, '0')}`;
}
}
function nicePostURL(url) { function nicePostURL(url) {
if (!url) return; if (!url) return;
const urlObj = new URL(url); const urlObj = new URL(url);
@ -2828,7 +3078,7 @@ function nicePostURL(url) {
const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || []; const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || [];
return ( return (
<> <>
{host} {punycode.toUnicode(host)}
{username ? ( {username ? (
<> <>
/{username} /{username}
@ -3068,20 +3318,22 @@ const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
return uniqueQuotes.map((q) => { return uniqueQuotes.map((q) => {
return ( return (
<Link <LazyShazam>
key={q.instance + q.id} <Link
to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`} key={q.instance + q.id}
class="status-card-link" to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
data-read-more="Read more →" class="status-card-link"
> data-read-more="Read more →"
<Status >
statusID={q.id} <Status
instance={q.instance} statusID={q.id}
size="s" instance={q.instance}
quoted={level + 1} size="s"
enableCommentHint quoted={level + 1}
/> enableCommentHint
</Link> />
</Link>
</LazyShazam>
); );
}); });
}); });

View file

@ -0,0 +1,25 @@
import { SubMenu } from '@szhsin/react-menu';
import { useRef } from 'preact/hooks';
export default function SubMenu2(props) {
const menuRef = useRef();
return (
<SubMenu
{...props}
instanceRef={menuRef}
// Test fix for bug; submenus not opening on Android
itemProps={{
onPointerMove: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
onPointerLeave: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
}}
/>
);
}

View file

@ -1,5 +1,11 @@
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
@ -9,6 +15,7 @@ import FilterContext from '../utils/filter-context';
import { filteredItems, isFiltered } from '../utils/filters'; import { filteredItems, isFiltered } from '../utils/filters';
import states, { statusKey } from '../utils/states'; import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek'; import statusPeek from '../utils/status-peek';
import { isMediaFirstInstance } from '../utils/store-utils';
import { groupBoosts, groupContext } from '../utils/timeline-utils'; import { groupBoosts, groupContext } from '../utils/timeline-utils';
import useInterval from '../utils/useInterval'; import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility'; import usePageVisibility from '../utils/usePageVisibility';
@ -51,7 +58,7 @@ function Timeline({
}) { }) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('start');
const [showMore, setShowMore] = useState(false); const [showMore, setShowMore] = useState(false);
const [showNew, setShowNew] = useState(false); const [showNew, setShowNew] = useState(false);
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true);
@ -59,6 +66,8 @@ function Timeline({
console.debug('RENDER Timeline', id, refresh); console.debug('RENDER Timeline', id, refresh);
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
const allowGrouping = view !== 'media'; const allowGrouping = view !== 'media';
const loadItems = useDebouncedCallback( const loadItems = useDebouncedCallback(
(firstLoad) => { (firstLoad) => {
@ -209,17 +218,13 @@ function Timeline({
const showNewPostsIndicator = const showNewPostsIndicator =
items.length > 0 && uiState !== 'loading' && showNew; items.length > 0 && uiState !== 'loading' && showNew;
const handleLoadNewPosts = useCallback(() => { const handleLoadNewPosts = useCallback(() => {
loadItems(true); if (showNewPostsIndicator) loadItems(true);
scrollableRef.current?.scrollTo({ scrollableRef.current?.scrollTo({
top: 0, top: 0,
behavior: 'smooth', behavior: 'smooth',
}); });
}, [loadItems]); }, [loadItems, showNewPostsIndicator]);
const dotRef = useHotkeys('.', () => { const dotRef = useHotkeys('.', handleLoadNewPosts);
if (showNewPostsIndicator) {
handleLoadNewPosts();
}
});
// const { // const {
// scrollDirection, // scrollDirection,
@ -359,12 +364,15 @@ function Timeline({
<FilterContext.Provider value={filterContext}> <FilterContext.Provider value={filterContext}>
<div <div
id={`${id}-page`} id={`${id}-page`}
class="deck-container" class={`deck-container ${
mediaFirst ? 'deck-container-media-first' : ''
}`}
ref={(node) => { ref={(node) => {
scrollableRef.current = node; scrollableRef.current = node;
jRef.current = node; jRef.current = node;
kRef.current = node; kRef.current = node;
oRef.current = node; oRef.current = node;
dotRef.current = node;
}} }}
tabIndex="-1" tabIndex="-1"
> >
@ -435,6 +443,7 @@ function Timeline({
view={view} view={view}
showFollowedTags={showFollowedTags} showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent} showReplyParent={showReplyParent}
mediaFirst={mediaFirst}
/> />
))} ))}
{showMore && {showMore &&
@ -446,14 +455,14 @@ function Timeline({
height: '20vh', height: '20vh',
}} }}
> >
<Status skeleton /> <Status skeleton mediaFirst={mediaFirst} />
</li> </li>
<li <li
style={{ style={{
height: '25vh', height: '25vh',
}} }}
> >
<Status skeleton /> <Status skeleton mediaFirst={mediaFirst} />
</li> </li>
</> </>
))} ))}
@ -493,13 +502,14 @@ function Timeline({
/> />
) : ( ) : (
<li key={i}> <li key={i}>
<Status skeleton /> <Status skeleton mediaFirst={mediaFirst} />
</li> </li>
), ),
)} )}
</ul> </ul>
) : ( ) : (
uiState !== 'error' && <p class="ui-state">{emptyText}</p> uiState !== 'error' &&
uiState !== 'start' && <p class="ui-state">{emptyText}</p>
)} )}
{uiState === 'error' && ( {uiState === 'error' && (
<p class="ui-state"> <p class="ui-state">
@ -527,6 +537,7 @@ const TimelineItem = memo(
view, view,
showFollowedTags, showFollowedTags,
showReplyParent, showReplyParent,
mediaFirst,
}) => { }) => {
console.debug('RENDER TimelineItem', status.id); console.debug('RENDER TimelineItem', status.id);
const { id: statusID, reblog, items, type, _pinned } = status; const { id: statusID, reblog, items, type, _pinned } = status;
@ -535,6 +546,7 @@ const TimelineItem = memo(
const url = instance const url = instance
? `/${instance}/s/${actualStatusID}` ? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`; : `/s/${actualStatusID}`;
if (items) { if (items) {
const fItems = filteredItems(items, filterContext); const fItems = filteredItems(items, filterContext);
let title = ''; let title = '';
@ -587,6 +599,7 @@ const TimelineItem = memo(
contentTextWeight contentTextWeight
enableCommentHint enableCommentHint
// allowFilters={allowFilters} // allowFilters={allowFilters}
mediaFirst={mediaFirst}
/> />
) : ( ) : (
<Status <Status
@ -596,6 +609,7 @@ const TimelineItem = memo(
contentTextWeight contentTextWeight
enableCommentHint enableCommentHint
// allowFilters={allowFilters} // allowFilters={allowFilters}
mediaFirst={mediaFirst}
/> />
)} )}
</Link> </Link>
@ -691,6 +705,7 @@ const TimelineItem = memo(
showFollowedTags={showFollowedTags} showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent} showReplyParent={showReplyParent}
// allowFilters={allowFilters} // allowFilters={allowFilters}
mediaFirst={mediaFirst}
/> />
) : ( ) : (
<Status <Status
@ -700,6 +715,7 @@ const TimelineItem = memo(
showFollowedTags={showFollowedTags} showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent} showReplyParent={showReplyParent}
// allowFilters={allowFilters} // allowFilters={allowFilters}
mediaFirst={mediaFirst}
/> />
)} )}
</Link> </Link>

View file

@ -10,6 +10,7 @@ import localeCode2Text from '../utils/localeCode2Text';
import pmem from '../utils/pmem'; import pmem from '../utils/pmem';
import Icon from './icon'; import Icon from './icon';
import LazyShazam from './lazy-shazam';
import Loader from './loader'; import Loader from './loader';
const { PHANPY_LINGVA_INSTANCES } = import.meta.env; const { PHANPY_LINGVA_INSTANCES } = import.meta.env;
@ -142,23 +143,21 @@ function TranslationBlock({
detectedLang !== targetLangText detectedLang !== targetLangText
) { ) {
return ( return (
<div class="shazam-container"> <LazyShazam>
<div class="shazam-container-inner"> <div class="status-translation-block-mini">
<div class="status-translation-block-mini"> <Icon
<Icon icon="translate"
icon="translate" alt={`Auto-translated from ${sourceLangText}`}
alt={`Auto-translated from ${sourceLangText}`} />
/> <output
<output lang={targetLang}
lang={targetLang} dir="auto"
dir="auto" title={pronunciationContent || ''}
title={pronunciationContent || ''} >
> {translatedContent}
{translatedContent} </output>
</output>
</div>
</div> </div>
</div> </LazyShazam>
); );
} }
return null; return null;

View file

@ -3,11 +3,15 @@ import './index.css';
import './app.css'; import './app.css';
import { render } from 'preact'; import { render } from 'preact';
import { lazy } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import Compose from './components/compose'; import IntlSegmenterSuspense from './components/intl-segmenter-suspense';
// import Compose from './components/compose';
import useTitle from './utils/useTitle'; import useTitle from './utils/useTitle';
const Compose = lazy(() => import('./components/compose'));
if (window.opener) { if (window.opener) {
console = window.opener.console; console = window.opener.console;
} }
@ -57,23 +61,25 @@ function App() {
console.debug('OPEN COMPOSE'); console.debug('OPEN COMPOSE');
return ( return (
<Compose <IntlSegmenterSuspense>
editStatus={editStatus} <Compose
replyToStatus={replyToStatus} editStatus={editStatus}
draftStatus={draftStatus} replyToStatus={replyToStatus}
standalone draftStatus={draftStatus}
hasOpener={window.opener} standalone
onClose={(results) => { hasOpener={window.opener}
const { newStatus, fn = () => {} } = results || {}; onClose={(results) => {
try { const { newStatus, fn = () => {} } = results || {};
if (newStatus) { try {
window.opener.__STATES__.reloadStatusPage++; if (newStatus) {
} window.opener.__STATES__.reloadStatusPage++;
fn(); }
setUIState('closed'); fn();
} catch (e) {} setUIState('closed');
}} } catch (e) {}
/> }}
/>
</IntlSegmenterSuspense>
); );
} }

View file

@ -16,6 +16,12 @@
--blue-color: royalblue; --blue-color: royalblue;
--purple-color: blueviolet; --purple-color: blueviolet;
--purple-fg-color: color-mix(
in srgb-linear,
var(--purple-color) 60%,
var(--text-color) 40%
);
--purple-bg-color: color-mix(in srgb, var(--purple-color) 10%, transparent);
--green-color: darkgreen; --green-color: darkgreen;
--orange-color: darkorange; --orange-color: darkorange;
--orange-light-bg-color: color-mix( --orange-light-bg-color: color-mix(
@ -23,7 +29,18 @@
var(--orange-color) 20%, var(--orange-color) 20%,
transparent transparent
); );
--orange-fg-color: color-mix(
in srgb-linear,
var(--orange-color) 60%,
var(--text-color) 40%
);
--orange-bg-color: color-mix(in srgb, var(--orange-color) 10%, transparent);
--red-color: orangered; --red-color: orangered;
--red-text-color: color-mix(
in srgb-linear,
var(--red-color) 60%,
var(--text-color) 40%
);
--red-bg-color: color-mix(in lch, var(--red-color) 40%, transparent); --red-bg-color: color-mix(in lch, var(--red-color) 40%, transparent);
--bg-color: #fff; --bg-color: #fff;
--bg-faded-color: #f0f2f5; --bg-faded-color: #f0f2f5;
@ -91,6 +108,8 @@
--timing-function: cubic-bezier(0.3, 0.5, 0, 1); --timing-function: cubic-bezier(0.3, 0.5, 0, 1);
--spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275); --spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275);
--min-dimension: 88px;
} }
@media (min-resolution: 2dppx) { @media (min-resolution: 2dppx) {
@ -328,6 +347,7 @@ button[hidden] {
} }
input[type='text'], input[type='text'],
input[type='search'],
textarea, textarea,
select { select {
color: var(--text-color); color: var(--text-color);
@ -337,6 +357,7 @@ select {
border-radius: 4px; border-radius: 4px;
} }
input[type='text']:focus, input[type='text']:focus,
input[type='search']:focus,
textarea:focus, textarea:focus,
select:focus { select:focus {
border-color: var(--outline-color); border-color: var(--outline-color);
@ -352,7 +373,7 @@ textarea:disabled {
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
} }
:is(input[type='text'], textarea, select).block { :is(input[type='text'], input[type='search'], textarea, select).block {
display: block; display: block;
width: 100%; width: 100%;
} }

View file

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

View file

@ -6,6 +6,7 @@ import {
useRef, useRef,
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import punycode from 'punycode';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -150,7 +151,7 @@ function AccountStatuses() {
} }
} }
const results = []; let results = [];
if (firstLoad) { if (firstLoad) {
const { value } = await masto.v1.accounts const { value } = await masto.v1.accounts
.$select(id) .$select(id)
@ -191,6 +192,26 @@ function AccountStatuses() {
} }
const { value, done } = await accountStatusesIterator.current.next(); const { value, done } = await accountStatusesIterator.current.next();
if (value?.length) { if (value?.length) {
// Check if value is same as pinned post (results)
// If the index for every post is the same, means API might not support pinned posts
if (results.length) {
let pinnedStatusesIds = [];
if (results[0]?.type === 'pinned') {
pinnedStatusesIds = results[0].id;
} else {
pinnedStatusesIds = results
.filter((status) => status._pinned)
.map((status) => status.id);
}
const containsAllPinned = pinnedStatusesIds.every((postId) =>
value.some((status) => status.id === postId),
);
if (containsAllPinned) {
// Remove pinned posts
results = [];
}
}
results.push(...value); results.push(...value);
value.forEach((item) => { value.forEach((item) => {
@ -516,7 +537,13 @@ function AccountStatuses() {
> >
<Icon icon="transfer" />{' '} <Icon icon="transfer" />{' '}
<small class="menu-double-lines"> <small class="menu-double-lines">
Switch to account's instance (<b>{accountInstance}</b>) Switch to account's instance{' '}
{accountInstance ? (
<>
{' '}
(<b>{punycode.toUnicode(accountInstance)}</b>)
</>
) : null}
</small> </small>
</MenuItem> </MenuItem>
{!sameCurrentInstance && ( {!sameCurrentInstance && (

View file

@ -813,6 +813,10 @@
text-decoration: none; text-decoration: none;
text-decoration-color: transparent; text-decoration-color: transparent;
color: var(--link-text-color); color: var(--link-text-color);
span {
text-decoration: none;
}
} }
} }

View file

@ -13,6 +13,7 @@ import {
useRef, useRef,
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import punycode from 'punycode';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { uid } from 'uid/single'; import { uid } from 'uid/single';
@ -191,6 +192,7 @@ function Catchup() {
const [posts, setPosts] = useState([]); const [posts, setPosts] = useState([]);
const catchupRangeRef = useRef(); const catchupRangeRef = useRef();
const catchupLastRef = useRef();
const NS = useMemo(() => getCurrentAccountNS(), []); const NS = useMemo(() => getCurrentAccountNS(), []);
const handleCatchupClick = useCallback(async ({ duration } = {}) => { const handleCatchupClick = useCallback(async ({ duration } = {}) => {
const now = Date.now(); const now = Date.now();
@ -925,7 +927,15 @@ function Catchup() {
type="button" type="button"
onClick={() => { onClick={() => {
if (range < RANGES[RANGES.length - 1].value) { if (range < RANGES[RANGES.length - 1].value) {
const duration = range * 60 * 60 * 1000; let duration;
if (
range === RANGES[RANGES.length - 1].value &&
catchupLastRef.current?.checked
) {
duration = Date.now() - lastCatchupEndAt;
} else {
duration = range * 60 * 60 * 1000;
}
handleCatchupClick({ duration }); handleCatchupClick({ duration });
} else { } else {
handleCatchupClick(); handleCatchupClick();
@ -935,11 +945,25 @@ function Catchup() {
Catch up Catch up
</button> </button>
</div> </div>
{lastCatchupRange && range > lastCatchupRange && ( {lastCatchupRange && range > lastCatchupRange ? (
<p class="catchup-info"> <p class="catchup-info">
<Icon icon="info" /> Overlaps with your last catch-up <Icon icon="info" /> Overlaps with your last catch-up
</p> </p>
)} ) : range === RANGES[RANGES.length - 1].value &&
lastCatchupEndAt ? (
<p class="catchup-info">
<label>
<input
type="checkbox"
switch
checked
ref={catchupLastRef}
/>{' '}
Until the last catch-up (
{dtf.format(new Date(lastCatchupEndAt))})
</label>
</p>
) : null}
<p class="insignificant"> <p class="insignificant">
<small> <small>
Note: your instance might only show a maximum of 800 posts in Note: your instance might only show a maximum of 800 posts in
@ -956,10 +980,12 @@ function Catchup() {
<Link to={`/catchup?id=${pc.id}`}> <Link to={`/catchup?id=${pc.id}`}>
<Icon icon="history2" />{' '} <Icon icon="history2" />{' '}
<span> <span>
{formatRange( {pc.startAt
new Date(pc.startAt), ? dtf.formatRange(
new Date(pc.endAt), new Date(pc.startAt),
)} new Date(pc.endAt),
)
: `… – ${dtf.format(new Date(pc.endAt))}`}
</span> </span>
</Link>{' '} </Link>{' '}
<span> <span>
@ -1011,7 +1037,7 @@ function Catchup() {
{posts.length > 0 && ( {posts.length > 0 && (
<p> <p>
<b class="ib"> <b class="ib">
{formatRange( {dtf.formatRange(
new Date(posts[0].createdAt), new Date(posts[0].createdAt),
new Date(posts[posts.length - 1].createdAt), new Date(posts[posts.length - 1].createdAt),
)} )}
@ -1074,9 +1100,11 @@ function Catchup() {
height, height,
publishedAt, publishedAt,
} = card; } = card;
const domain = new URL(url).hostname const domain = punycode.toUnicode(
.replace(/^www\./, '') new URL(url).hostname
.replace(/\/$/, ''); .replace(/^www\./, '')
.replace(/\/$/, ''),
);
let accentColor; let accentColor;
if (blurhash) { if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash); const averageColor = getBlurHashAverageColor(blurhash);
@ -1132,7 +1160,12 @@ function Catchup() {
)} )}
</div> </div>
{!!title && ( {!!title && (
<h1 class="title" lang={language} dir="auto"> <h1
class="title"
lang={language}
dir="auto"
title={title}
>
{title} {title}
</h1> </h1>
)} )}
@ -1142,6 +1175,7 @@ function Catchup() {
class="description" class="description"
lang={language} lang={language}
dir="auto" dir="auto"
title={description}
> >
{description} {description}
</p> </p>
@ -1255,7 +1289,7 @@ function Catchup() {
authors[author].avatarStatic || authors[author].avatar authors[author].avatarStatic || authors[author].avatar
} }
size="xxl" size="xxl"
alt={`${authors[author].displayName} (@${authors[author].username})`} alt={`${authors[author].displayName} (@${authors[author].acct})`}
/>{' '} />{' '}
<span class="count">{authorCounts[author]}</span> <span class="count">{authorCounts[author]}</span>
<span class="username">{authors[author].username}</span> <span class="username">{authors[author].username}</span>
@ -1836,9 +1870,6 @@ const dtf = new Intl.DateTimeFormat(locale, {
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
}); });
function formatRange(startDate, endDate) {
return dtf.formatRange(startDate, endDate);
}
function binByTime(data, key, numBins) { function binByTime(data, key, numBins) {
// Extract dates from data objects // Extract dates from data objects

149
src/pages/filters.css Normal file
View file

@ -0,0 +1,149 @@
#filters-page {
.filters-list {
list-style: none;
padding: 0;
margin: 0;
li {
padding: 8px 16px;
border-bottom: var(--hairline-width) solid var(--outline-color);
display: flex;
align-items: center;
justify-content: space-between;
}
h2 {
font-weight: 500;
margin: 0;
padding: 0;
font-size: 1em;
}
}
}
#filters-add-edit-modal {
.filter-form-row {
margin-bottom: 16px;
+ .filter-form-row {
margin-top: 16px;
border-top: 1px solid var(--outline-color);
padding-top: 16px;
}
}
main {
padding-top: 10px;
line-height: 1.5;
p {
margin-block: 1em;
}
}
label {
display: flex;
align-items: center;
gap: 4px;
}
.filter-form-keywords {
margin: 0 -16px 16px;
}
.filter-form-cols {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
.filter-form-col {
flex-basis: 160px;
flex-grow: 1;
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
}
}
.filter-keywords {
--gap: 16px;
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--gap);
padding: var(--gap);
overflow-y: auto;
min-height: 80px;
max-height: 25vh;
background-color: var(--bg-faded-blur-color);
counter-reset: index;
scroll-behavior: smooth;
li {
counter-increment: index;
display: flex;
gap: 4px;
align-items: center;
flex-wrap: wrap;
&:not(:only-child):before {
content: counter(index);
font-size: 10px;
color: var(--text-insignificant-color);
align-self: flex-start;
}
input[type='text'] {
flex-basis: 160px;
flex-grow: 100;
}
.filter-keyword-actions {
display: flex;
gap: 8px;
flex-grow: 1;
align-items: center;
justify-content: space-between;
label {
font-size: 0.8em;
line-height: 1;
}
}
}
}
.filter-keywords-footer {
padding: 8px 16px 0;
display: flex;
justify-content: space-between;
}
input[type='text'] {
display: block;
width: 100%;
}
.filter-form-footer {
display: flex;
gap: 16px;
justify-content: space-between;
align-items: center;
> span {
display: flex;
align-items: center;
}
button[type='submit'] {
padding-inline: 24px;
}
}
}

581
src/pages/filters.jsx Normal file
View file

@ -0,0 +1,581 @@
import './filters.css';
import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import MenuConfirm from '../components/menu-confirm';
import Modal from '../components/modal';
import NavMenu from '../components/nav-menu';
import RelativeTime from '../components/relative-time';
import { api } from '../utils/api';
import useInterval from '../utils/useInterval';
import useTitle from '../utils/useTitle';
const FILTER_CONTEXT = ['home', 'public', 'notifications', 'thread', 'account'];
const FILTER_CONTEXT_UNIMPLEMENTED = ['notifications', 'thread', 'account'];
const FILTER_CONTEXT_LABELS = {
home: 'Home and lists',
notifications: 'Notifications',
public: 'Public timelines',
thread: 'Conversations',
account: 'Profiles',
};
const EXPIRY_DURATIONS = [
0, // forever
30 * 60, // 30 minutes
60 * 60, // 1 hour
6 * 60 * 60, // 6 hours
12 * 60 * 60, // 12 hours
60 * 60 * 24, // 24 hours
60 * 60 * 24 * 7, // 7 days
60 * 60 * 24 * 30, // 30 days
];
const EXPIRY_DURATIONS_LABELS = {
0: 'Never',
1800: '30 minutes',
3600: '1 hour',
21600: '6 hours',
43200: '12 hours',
86_400: '24 hours',
604_800: '7 days',
2_592_000: '30 days',
};
function Filters() {
const { masto } = api();
useTitle(`Filters`, `/ft`);
const [uiState, setUIState] = useState('default');
const [showFiltersAddEditModal, setShowFiltersAddEditModal] = useState(false);
const [reloadCount, reload] = useReducer((c) => c + 1, 0);
const [filters, setFilters] = useState([]);
useEffect(() => {
setUIState('loading');
(async () => {
try {
const filters = await masto.v2.filters.list();
filters.sort((a, b) => a.title.localeCompare(b.title));
filters.forEach((filter) => {
if (filter.keywords?.length) {
filter.keywords.sort((a, b) => a.id - b.id);
}
});
console.log(filters);
setFilters(filters);
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}, [reloadCount]);
return (
<div id="filters-page" class="deck-container" tabIndex="-1">
<div class="timeline-deck deck">
<header>
<div class="header-grid">
<div class="header-side">
<NavMenu />
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
</Link>
</div>
<h1>Filters</h1>
<div class="header-side">
<button
type="button"
class="plain"
onClick={() => {
setShowFiltersAddEditModal(true);
}}
>
<Icon icon="plus" size="l" alt="New filter" />
</button>
</div>
</div>
</header>
<main>
{filters.length > 0 ? (
<>
<ul class="filters-list">
{filters.map((filter) => {
const { id, title, expiresAt, keywords } = filter;
return (
<li key={id}>
<div>
<h2>{title}</h2>
{keywords?.length > 0 && (
<div>
{keywords.map((k) => (
<>
<span class="tag collapsed insignificant">
{k.wholeWord ? `${k.keyword}` : k.keyword}
</span>{' '}
</>
))}
</div>
)}
<small class="insignificant">
<ExpiryStatus expiresAt={expiresAt} />
</small>
</div>
<button
type="button"
class="plain"
onClick={() => {
setShowFiltersAddEditModal({
filter,
});
}}
>
<Icon icon="pencil" size="l" alt="Edit filter" />
</button>
</li>
);
})}
</ul>
{filters.length > 1 && (
<footer class="ui-state">
<small class="insignificant">
{filters.length} filter
{filters.length === 1 ? '' : 's'}
</small>
</footer>
)}
</>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Unable to load filters.</p>
) : (
<p class="ui-state">No filters yet.</p>
)}
</main>
</div>
{!!showFiltersAddEditModal && (
<Modal
title="Add filter"
onClose={() => {
setShowFiltersAddEditModal(false);
}}
>
<FiltersAddEdit
filter={showFiltersAddEditModal?.filter}
onClose={(result) => {
if (result.state === 'success') {
reload();
}
setShowFiltersAddEditModal(false);
}}
/>
</Modal>
)}
</div>
);
}
let _id = 1;
const incID = () => _id++;
function FiltersAddEdit({ filter, onClose }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const editMode = !!filter;
const { context, expiresAt, id, keywords, title, filterAction } =
filter || {};
const hasExpiry = !!expiresAt;
const expiresAtDate = hasExpiry && new Date(expiresAt);
const [editKeywords, setEditKeywords] = useState(keywords || []);
const keywordsRef = useRef();
// Hacky way of handling removed keywords for both existing and new ones
const [removedKeywordIDs, setRemovedKeywordIDs] = useState([]);
const [removedKeyword_IDs, setRemovedKeyword_IDs] = useState([]);
const filteredEditKeywords = editKeywords.filter(
(k) =>
!removedKeywordIDs.includes(k.id) && !removedKeyword_IDs.includes(k._id),
);
return (
<div class="sheet" id="filters-add-edit-modal">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header>
<h2>{editMode ? 'Edit filter' : 'New filter'}</h2>
</header>
<main>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
const title = formData.get('title');
const keywordIDs = formData.getAll('keyword_attributes[][id]');
const keywordKeywords = formData.getAll(
'keyword_attributes[][keyword]',
);
// const keywordWholeWords = formData.getAll(
// 'keyword_attributes[][whole_word]',
// );
// Not using getAll because it skips the empty checkboxes
const keywordWholeWords = [
...keywordsRef.current.querySelectorAll(
'input[name="keyword_attributes[][whole_word]"]',
),
].map((i) => i.checked);
const keywordsAttributes = keywordKeywords.map((k, i) => ({
id: keywordIDs[i] || undefined,
keyword: k,
wholeWord: keywordWholeWords[i],
}));
// if (editMode && keywords?.length) {
// // Find which one got deleted and add to keywordsAttributes
// keywords.forEach((k) => {
// if (!keywordsAttributes.find((ka) => ka.id === k.id)) {
// keywordsAttributes.push({
// ...k,
// _destroy: true,
// });
// }
// });
// }
if (editMode && removedKeywordIDs?.length) {
removedKeywordIDs.forEach((id) => {
keywordsAttributes.push({
id,
_destroy: true,
});
});
}
const context = formData.getAll('context');
let expiresIn = formData.get('expires_in');
const filterAction = formData.get('filter_action');
console.log({
title,
keywordIDs,
keywords: keywordKeywords,
wholeWords: keywordWholeWords,
keywordsAttributes,
context,
expiresIn,
filterAction,
});
// Required fields
if (!title || !context?.length) {
return;
}
setUIState('loading');
(async () => {
try {
let filterResult;
if (editMode) {
if (expiresIn === '' || expiresIn === null) {
// No value
// Preserve existing expiry if not specified
// Seconds from now to expiresAtDate
// Other clients don't do this
expiresIn = Math.floor((expiresAtDate - new Date()) / 1000);
} else if (expiresIn === '0' || expiresIn === 0) {
// 0 = Never
expiresIn = null;
} else {
expiresIn = +expiresIn;
}
filterResult = await masto.v2.filters.$select(id).update({
title,
context,
expiresIn,
keywordsAttributes,
filterAction,
});
} else {
expiresIn = +expiresIn || null;
filterResult = await masto.v2.filters.create({
title,
context,
expiresIn,
keywordsAttributes,
filterAction,
});
}
console.log({ filterResult });
setUIState('default');
onClose?.({
state: 'success',
filter: filterResult,
});
} catch (error) {
console.error(error);
setUIState('error');
alert(
editMode
? 'Unable to edit filter'
: 'Unable to create filter',
);
}
})();
}}
>
<div class="filter-form-row">
<label>
<b>Title</b>
<input
type="text"
name="title"
defaultValue={title}
disabled={uiState === 'loading'}
dir="auto"
required
/>
</label>
</div>
<div class="filter-form-keywords" ref={keywordsRef}>
{filteredEditKeywords.length ? (
<ul class="filter-keywords">
{filteredEditKeywords.map((k) => {
const { id, keyword, wholeWord, _id } = k;
return (
<li key={`${id}-${_id}`}>
<input
type="hidden"
name="keyword_attributes[][id]"
value={id}
/>
<input
name="keyword_attributes[][keyword]"
type="text"
defaultValue={keyword}
disabled={uiState === 'loading'}
required
/>
<div class="filter-keyword-actions">
<label>
<input
name="keyword_attributes[][whole_word]"
type="checkbox"
value={id} // Hacky way to map checkbox boolean to the keyword id
defaultChecked={wholeWord}
disabled={uiState === 'loading'}
/>{' '}
Whole word
</label>
<button
type="button"
class="light danger small"
disabled={uiState === 'loading'}
onClick={() => {
if (id) {
removedKeywordIDs.push(id);
setRemovedKeywordIDs([...removedKeywordIDs]);
} else if (_id) {
removedKeyword_IDs.push(_id);
setRemovedKeyword_IDs([...removedKeyword_IDs]);
}
}}
>
<Icon icon="x" />
</button>
</div>
</li>
);
})}
</ul>
) : (
<div class="filter-keywords">
<div class="insignificant">No keywords. Add one.</div>
</div>
)}
<footer class="filter-keywords-footer">
<button
type="button"
class="light"
onClick={() => {
setEditKeywords([
...editKeywords,
{
_id: incID(),
keyword: '',
wholeWord: true,
},
]);
setTimeout(() => {
// Focus last input
const fields =
keywordsRef.current.querySelectorAll(
'input[type="text"]',
);
fields[fields.length - 1]?.focus?.();
}, 10);
}}
>
Add keyword
</button>{' '}
{filteredEditKeywords?.length > 1 && (
<small class="insignificant">
{filteredEditKeywords.length} keyword
{filteredEditKeywords.length === 1 ? '' : 's'}
</small>
)}
</footer>
</div>
<div class="filter-form-cols">
<div class="filter-form-col">
<div>
<b>Filter from</b>
</div>
{FILTER_CONTEXT.map((ctx) => (
<div>
<label
class={
FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx)
? 'insignificant'
: ''
}
>
<input
type="checkbox"
name="context"
value={ctx}
defaultChecked={!!context ? context.includes(ctx) : true}
disabled={uiState === 'loading'}
/>{' '}
{FILTER_CONTEXT_LABELS[ctx]}
{FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx) ? '*' : ''}
</label>{' '}
</div>
))}
<p>
<small class="insignificant">* Not implemented yet</small>
</p>
</div>
<div class="filter-form-col">
{editMode && (
<>
Status:{' '}
<b>
<ExpiryStatus expiresAt={expiresAt} showNeverExpires />
</b>
</>
)}
<div>
<label for="filters-expires_in">
{editMode ? 'Change expiry' : 'Expiry'}
</label>
<select
id="filters-expires_in"
name="expires_in"
disabled={uiState === 'loading'}
defaultValue={editMode ? undefined : 0}
>
{editMode && <option></option>}
{EXPIRY_DURATIONS.map((v) => (
<option value={v}>{EXPIRY_DURATIONS_LABELS[v]}</option>
))}
</select>
</div>
<p>
Filtered post will be
<br />
<label class="ib">
<input
type="radio"
name="filter_action"
value="warn"
defaultChecked={filterAction === 'warn' || !editMode}
disabled={uiState === 'loading'}
/>{' '}
minimized
</label>{' '}
<label class="ib">
<input
type="radio"
name="filter_action"
value="hide"
defaultChecked={filterAction === 'hide'}
disabled={uiState === 'loading'}
/>{' '}
hidden
</label>
</p>
</div>
</div>
<footer class="filter-form-footer">
<span>
<button type="submit" disabled={uiState === 'loading'}>
{editMode ? 'Save' : 'Create'}
</button>{' '}
<Loader abrupt hidden={uiState !== 'loading'} />
</span>
{editMode && (
<MenuConfirm
disabled={uiState === 'loading'}
align="end"
menuItemClassName="danger"
confirmLabel="Delete this filter?"
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v2.filters.$select(id).remove();
setUIState('default');
onClose?.({
state: 'success',
});
} catch (e) {
console.error(e);
setUIState('error');
alert('Unable to delete filter.');
}
})();
}}
>
<button
type="button"
class="light danger"
onClick={() => {}}
disabled={uiState === 'loading'}
>
Delete
</button>
</MenuConfirm>
)}
</footer>
</form>
</main>
</div>
);
}
function ExpiryStatus({ expiresAt, showNeverExpires }) {
const hasExpiry = !!expiresAt;
const expiresAtDate = hasExpiry && new Date(expiresAt);
const expired = hasExpiry && expiresAtDate <= new Date();
// If less than a minute left, re-render interval every second, else every minute
const [_, rerender] = useReducer((c) => c + 1, 0);
useInterval(rerender, expired || 30_000);
return expired ? (
'Expired'
) : hasExpiry ? (
<>
Expiring <RelativeTime datetime={expiresAtDate} />
</>
) : (
showNeverExpires && 'Never expires'
);
}
export default Filters;

View file

@ -10,7 +10,7 @@ import useTitle from '../utils/useTitle';
function FollowedHashtags() { function FollowedHashtags() {
const { masto, instance } = api(); const { masto, instance } = api();
useTitle(`Followed Hashtags`, `/ft`); useTitle(`Followed Hashtags`, `/fh`);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [followedHashtags, setFollowedHashtags] = useState([]); const [followedHashtags, setFollowedHashtags] = useState([]);

View file

@ -71,7 +71,8 @@ function Following({ title, path, id, ...props }) {
.next(); .next();
let { value } = results; let { value } = results;
console.log('checkForUpdates', latestItem.current, value); console.log('checkForUpdates', latestItem.current, value);
if (value?.length) { const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
if (value?.length && !valueContainsLatestItem) {
latestItem.current = value[0].id; latestItem.current = value[0].id;
value = dedupeBoosts(value, instance); value = dedupeBoosts(value, instance);
value = filteredItems(value, 'home'); value = filteredItems(value, 'home');

View file

@ -109,8 +109,9 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
}) })
.next(); .next();
let { value } = results; let { value } = results;
value = filteredItems(value, 'public'); const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
if (value?.length) { if (value?.length && !valueContainsLatestItem) {
value = filteredItems(value, 'public');
return true; return true;
} }
return false; return false;

View file

@ -1,6 +1,6 @@
import './lists.css'; import './lists.css';
import { Menu, MenuItem } from '@szhsin/react-menu'; import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
@ -12,10 +12,12 @@ import Link from '../components/link';
import ListAddEdit from '../components/list-add-edit'; import ListAddEdit from '../components/list-add-edit';
import Menu2 from '../components/menu2'; import Menu2 from '../components/menu2';
import MenuConfirm from '../components/menu-confirm'; import MenuConfirm from '../components/menu-confirm';
import MenuLink from '../components/menu-link';
import Modal from '../components/modal'; import Modal from '../components/modal';
import Timeline from '../components/timeline'; import Timeline from '../components/timeline';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { filteredItems } from '../utils/filters'; import { filteredItems } from '../utils/filters';
import { getList, getLists } from '../utils/lists';
import states, { saveStatus } from '../utils/states'; import states, { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
@ -61,8 +63,9 @@ function List(props) {
since_id: latestItem.current, since_id: latestItem.current,
}); });
let { value } = results; let { value } = results;
value = filteredItems(value, 'home'); const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
if (value?.length) { if (value?.length && !valueContainsLatestItem) {
value = filteredItems(value, 'home');
return true; return true;
} }
return false; return false;
@ -71,13 +74,18 @@ function List(props) {
} }
} }
const [lists, setLists] = useState([]);
useEffect(() => {
getLists().then(setLists);
}, []);
const [list, setList] = useState({ title: 'List' }); const [list, setList] = useState({ title: 'List' });
// const [title, setTitle] = useState(`List`); // const [title, setTitle] = useState(`List`);
useTitle(list.title, `/l/:id`); useTitle(list.title, `/l/:id`);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const list = await masto.v1.lists.$select(id).fetch(); const list = await getList(id);
setList(list); setList(list);
// setTitle(list.title); // setTitle(list.title);
} catch (e) { } catch (e) {
@ -107,9 +115,32 @@ function List(props) {
showReplyParent showReplyParent
// refresh={reloadCount} // refresh={reloadCount}
headerStart={ headerStart={
<Link to="/l" class="button plain"> // <Link to="/l" class="button plain">
<Icon icon="list" size="l" /> // <Icon icon="list" size="l" />
</Link> // </Link>
<Menu2
overflow="auto"
menuButton={
<button type="button" class="plain">
<Icon icon="list" size="l" alt="Lists" />
<Icon icon="chevron-down" size="s" />
</button>
}
>
<MenuLink to="/l">
<span>All Lists</span>
</MenuLink>
{lists?.length > 0 && (
<>
<MenuDivider />
{lists.map((list) => (
<MenuLink key={list.id} to={`/l/${list.id}`}>
<span>{list.title}</span>
</MenuLink>
))}
</>
)}
</Menu2>
} }
headerEnd={ headerEnd={
<Menu2 <Menu2

View file

@ -8,11 +8,10 @@ import ListAddEdit from '../components/list-add-edit';
import Loader from '../components/loader'; import Loader from '../components/loader';
import Modal from '../components/modal'; import Modal from '../components/modal';
import NavMenu from '../components/nav-menu'; import NavMenu from '../components/nav-menu';
import { api } from '../utils/api'; import { fetchLists } from '../utils/lists';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
function Lists() { function Lists() {
const { masto } = api();
useTitle(`Lists`, `/l`); useTitle(`Lists`, `/l`);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
@ -22,8 +21,7 @@ function Lists() {
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
const lists = await masto.v1.lists.list(); const lists = await fetchLists();
lists.sort((a, b) => a.title.localeCompare(b.title));
console.log(lists); console.log(lists);
setLists(lists); setLists(lists);
setUIState('default'); setUIState('default');

View file

@ -95,7 +95,9 @@ function Mentions({ columnMode, ...props }) {
latestConversationItem.current, latestConversationItem.current,
value, value,
); );
if (value?.length) { const valueContainsLatestItem =
value[0]?.id === latestConversationItem.current; // since_id might not be supported
if (value?.length && !valueContainsLatestItem) {
latestConversationItem.current = value[0].lastStatus.id; latestConversationItem.current = value[0].lastStatus.id;
return true; return true;
} }

View file

@ -198,6 +198,7 @@ function Notifications({ columnMode }) {
setUIState('default'); setUIState('default');
} catch (e) { } catch (e) {
console.error(e);
setUIState('error'); setUIState('error');
} }
})(); })();
@ -246,7 +247,6 @@ function Notifications({ columnMode }) {
const lastHiddenTime = useRef(); const lastHiddenTime = useRef();
usePageVisibility((visible) => { usePageVisibility((visible) => {
let unsub;
if (visible) { if (visible) {
const timeDiff = Date.now() - lastHiddenTime.current; const timeDiff = Date.now() - lastHiddenTime.current;
if (!lastHiddenTime.current || timeDiff > 1000 * 3) { if (!lastHiddenTime.current || timeDiff > 1000 * 3) {
@ -257,20 +257,16 @@ function Notifications({ columnMode }) {
} else { } else {
lastHiddenTime.current = Date.now(); lastHiddenTime.current = Date.now();
} }
unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
if (uiState === 'loading') {
return;
}
if (v) {
loadUpdates();
}
setShowNew(v);
});
} }
return () => {
unsub?.();
};
}); });
useEffect(() => {
let unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
if (uiState === 'loading') return;
if (v) loadUpdates();
setShowNew(v);
});
return () => unsub?.();
}, []);
const todayDate = new Date(); const todayDate = new Date();
const yesterdayDate = new Date(todayDate - 24 * 60 * 60 * 1000); const yesterdayDate = new Date(todayDate - 24 * 60 * 60 * 1000);
@ -417,7 +413,7 @@ function Notifications({ columnMode }) {
{supportsFilteredNotifications && ( {supportsFilteredNotifications && (
<button <button
type="button" type="button"
class="button plain" class="button plain4"
onClick={() => { onClick={() => {
setShowNotificationsSettings(true); setShowNotificationsSettings(true);
}} }}
@ -531,66 +527,72 @@ function Notifications({ columnMode }) {
)} )}
{supportsFilteredNotifications && {supportsFilteredNotifications &&
notificationsPolicy?.summary?.pendingRequestsCount > 0 && ( notificationsPolicy?.summary?.pendingRequestsCount > 0 && (
<div class="filtered-notifications"> <div class="shazam-container">
<details <div class="shazam-container-inner">
onToggle={async (e) => { <div class="filtered-notifications">
const { open } = e.target; <details
if (open) { onToggle={async (e) => {
const requests = await fetchNotificationsRequest(); const { open } = e.target;
setNotificationsRequests(requests); if (open) {
console.log({ open, requests }); const requests = await fetchNotificationsRequest();
} setNotificationsRequests(requests);
}} console.log({ open, requests });
> }
<summary> }}
Filtered notifications from{' '} >
{notificationsPolicy.summary.pendingRequestsCount} people <summary>
</summary> Filtered notifications from{' '}
{!notificationsRequests ? ( {notificationsPolicy.summary.pendingRequestsCount} people
<p class="ui-state"> </summary>
<Loader abrupt /> {!notificationsRequests ? (
</p> <p class="ui-state">
) : ( <Loader abrupt />
notificationsRequests?.length > 0 && ( </p>
<ul> ) : (
{notificationsRequests.map((request) => ( notificationsRequests?.length > 0 && (
<li key={request.id}> <ul>
<div class="request-notifcations"> {notificationsRequests.map((request) => (
{!request.lastStatus?.id && ( <li key={request.id}>
<AccountBlock <div class="request-notifcations">
useAvatarStatic {!request.lastStatus?.id && (
showStats <AccountBlock
account={request.account} 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> )}
{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> </div>
)} <NotificationRequestButtons
<NotificationRequestModalButton request={request} /> request={request}
</div> onChange={() => {
<NotificationRequestButtons loadNotifications(true);
request={request} }}
onChange={() => { />
loadNotifications(true); </li>
}} ))}
/> </ul>
</li> )
))} )}
</ul> </details>
) </div>
)} </div>
</details>
</div> </div>
)} )}
<div id="mentions-option"> <div id="mentions-option">
@ -606,7 +608,7 @@ function Notifications({ columnMode }) {
</label> </label>
</div> </div>
<h2 class="timeline-header">Today</h2> <h2 class="timeline-header">Today</h2>
{showTodayEmpty && !!snapStates.notifications.length && ( {showTodayEmpty && (
<p class="ui-state insignificant"> <p class="ui-state insignificant">
{uiState === 'default' ? "You're all caught up." : <>&hellip;</>} {uiState === 'default' ? "You're all caught up." : <>&hellip;</>}
</p> </p>

View file

@ -63,8 +63,9 @@ function Public({ local, columnMode, ...props }) {
}) })
.next(); .next();
let { value } = results; let { value } = results;
value = filteredItems(value, 'public'); const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
if (value?.length) { if (value?.length && !valueContainsLatestItem) {
value = filteredItems(value, 'public');
return true; return true;
} }
return false; return false;

View file

@ -177,6 +177,7 @@ function Search({ columnMode, ...props }) {
['/', 'Slash'], ['/', 'Slash'],
(e) => { (e) => {
searchFormRef.current?.focus?.(); searchFormRef.current?.focus?.();
searchFormRef.current?.select?.();
}, },
{ {
preventDefault: true, preventDefault: true,

View file

@ -28,6 +28,7 @@ const {
PHANPY_WEBSITE: WEBSITE, PHANPY_WEBSITE: WEBSITE,
PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL, PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL,
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL, PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
PHANPY_GIPHY_API_KEY: GIPHY_API_KEY,
} = import.meta.env; } = import.meta.env;
function Settings({ onClose }) { function Settings({ onClose }) {
@ -433,6 +434,37 @@ function Settings({ onClose }) {
</div> </div>
</div> </div>
</li> </li>
{!!GIPHY_API_KEY && authenticated && (
<li>
<label>
<input
type="checkbox"
checked={snapStates.settings.composerGIFPicker}
onChange={(e) => {
states.settings.composerGIFPicker = e.target.checked;
}}
/>{' '}
GIF Picker for composer
</label>
<div class="sub-section insignificant">
<small>
Note: This feature uses external GIF search service, powered
by{' '}
<a
href="https://developers.giphy.com/"
target="_blank"
rel="noopener noreferrer"
>
GIPHY
</a>
. G-rated (suitable for viewing by all ages), tracking
parameters are stripped, referrer information is omitted
from requests, but search queries and IP address information
will still reach their servers.
</small>
</div>
</li>
)}
{!!IMG_ALT_API_URL && authenticated && ( {!!IMG_ALT_API_URL && authenticated && (
<li> <li>
<label> <label>
@ -690,9 +722,10 @@ function PushNotificationsSection({ onClose }) {
) { ) {
setAllowNotifications(true); setAllowNotifications(true);
const { alerts, policy } = backendSubscription; const { alerts, policy } = backendSubscription;
console.log('backendSubscription', backendSubscription);
previousPolicyRef.current = policy; previousPolicyRef.current = policy;
const { elements } = pushFormRef.current; const { elements } = pushFormRef.current;
const policyEl = elements.namedItem(policy); const policyEl = elements.namedItem('policy');
if (policyEl) policyEl.value = policy; if (policyEl) policyEl.value = policy;
// alerts is {}, iterate it // alerts is {}, iterate it
Object.keys(alerts).forEach((alert) => { Object.keys(alerts).forEach((alert) => {
@ -721,65 +754,68 @@ function PushNotificationsSection({ onClose }) {
<form <form
ref={pushFormRef} ref={pushFormRef}
onChange={() => { onChange={() => {
const values = Object.fromEntries(new FormData(pushFormRef.current)); setTimeout(() => {
const allowNotifications = !!values['policy-allow']; const values = Object.fromEntries(new FormData(pushFormRef.current));
const params = { const allowNotifications = !!values['policy-allow'];
policy: values.policy, const params = {
data: { data: {
alerts: { policy: values.policy,
mention: !!values.mention, alerts: {
favourite: !!values.favourite, mention: !!values.mention,
reblog: !!values.reblog, favourite: !!values.favourite,
follow: !!values.follow, reblog: !!values.reblog,
follow_request: !!values.followRequest, follow: !!values.follow,
poll: !!values.poll, follow_request: !!values.followRequest,
update: !!values.update, poll: !!values.poll,
status: !!values.status, update: !!values.update,
status: !!values.status,
},
}, },
}, };
};
let alertsCount = 0; let alertsCount = 0;
// Remove false values from data.alerts // Remove false values from data.alerts
// API defaults to false anyway // API defaults to false anyway
Object.keys(params.data.alerts).forEach((key) => { Object.keys(params.data.alerts).forEach((key) => {
if (!params.data.alerts[key]) { if (!params.data.alerts[key]) {
delete params.data.alerts[key]; delete params.data.alerts[key];
} else { } else {
alertsCount++; alertsCount++;
} }
}); });
const policyChanged = previousPolicyRef.current !== params.policy; const policyChanged =
previousPolicyRef.current !== params.data.policy;
console.log('PN Form', { console.log('PN Form', {
values, values,
allowNotifications: allowNotifications, allowNotifications: allowNotifications,
params, params,
}); });
if (allowNotifications && alertsCount > 0) { if (allowNotifications && alertsCount > 0) {
if (policyChanged) { if (policyChanged) {
console.debug('Policy changed.'); console.debug('Policy changed.');
removeSubscription() removeSubscription()
.then(() => { .then(() => {
updateSubscription(params); updateSubscription(params);
}) })
.catch((err) => { .catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
});
} else {
updateSubscription(params).catch((err) => {
console.warn(err); console.warn(err);
alert('Failed to update subscription. Please try again.'); alert('Failed to update subscription. Please try again.');
}); });
}
} else { } else {
updateSubscription(params).catch((err) => { removeSubscription().catch((err) => {
console.warn(err); console.warn(err);
alert('Failed to update subscription. Please try again.'); alert('Failed to remove subscription. Please try again.');
}); });
} }
} else { }, 100);
removeSubscription().catch((err) => {
console.warn(err);
alert('Failed to remove subscription. Please try again.');
});
}
}} }}
> >
<h3>Push Notifications (beta)</h3> <h3>Push Notifications (beta)</h3>

View file

@ -12,10 +12,10 @@ import {
useRef, useRef,
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import punycode from 'punycode';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { matchPath, useSearchParams } from 'react-router-dom'; import { matchPath, useSearchParams } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import Avatar from '../components/avatar'; import Avatar from '../components/avatar';
@ -122,7 +122,7 @@ function StatusPage(params) {
}, [showMedia]); }, [showMedia]);
const mediaAttachments = mediaStatusID const mediaAttachments = mediaStatusID
? mediaStatus?.mediaAttachments ? snapStates.statuses[statusKey(mediaStatusID, instance)]?.mediaAttachments
: heroStatus?.mediaAttachments; : heroStatus?.mediaAttachments;
const handleMediaClose = useCallback(() => { const handleMediaClose = useCallback(() => {
@ -1208,7 +1208,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
{postInstance ? ( {postInstance ? (
<> <>
{' '} {' '}
(<b>{postInstance}</b>) (<b>{punycode.toUnicode(postInstance)}</b>)
</> </>
) : ( ) : (
'' ''

View file

@ -3,6 +3,7 @@ import '../components/links-bar.css';
import { MenuItem } from '@szhsin/react-menu'; import { MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash'; import { getBlurHashAverageColor } from 'fast-blurhash';
import { useMemo, useRef, useState } from 'preact/hooks'; import { useMemo, useRef, useState } from 'preact/hooks';
import punycode from 'punycode';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -161,9 +162,9 @@ function Trending({ columnMode, ...props }) {
url, url,
width, width,
} = link; } = link;
const domain = new URL(url).hostname const domain = punycode.toUnicode(
.replace(/^www\./, '') new URL(url).hostname.replace(/^www\./, '').replace(/\/$/, ''),
.replace(/\/$/, ''); );
let accentColor; let accentColor;
if (blurhash) { if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash); const averageColor = getBlurHashAverageColor(blurhash);
@ -217,13 +218,23 @@ function Trending({ columnMode, ...props }) {
)} )}
</div> </div>
{!!title && ( {!!title && (
<h1 class="title" lang={language} dir="auto"> <h1
class="title"
lang={language}
dir="auto"
title={title}
>
{title} {title}
</h1> </h1>
)} )}
</header> </header>
{!!description && ( {!!description && (
<p class="description" lang={language} dir="auto"> <p
class="description"
lang={language}
dir="auto"
title={description}
>
{description} {description}
</p> </p>
)} )}

View file

@ -0,0 +1,14 @@
export default function formatDuration(time) {
if (!time) return;
let hours = Math.floor(time / 3600);
let minutes = Math.floor((time % 3600) / 60);
let seconds = Math.round(time % 60);
if (hours === 0) {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
} else {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
.toString()
.padStart(2, '0')}`;
}
}

View file

@ -6,6 +6,7 @@ const statusPostRegexes = [
/\/notes\/([^\/]+)/i, // Misskey, Firefish /\/notes\/([^\/]+)/i, // Misskey, Firefish
/^\/(?:notice|objects)\/([a-z0-9-]+)/i, // Pleroma /^\/(?:notice|objects)\/([a-z0-9-]+)/i, // Pleroma
/\/@[^@\/]+@?[^\/]+?\/([^\/]+)/i, // Mastodon /\/@[^@\/]+@?[^\/]+?\/([^\/]+)/i, // Mastodon
/^\/p\/[^\/]+\/([^\/]+)/i, // Pixelfed
]; ];
export function getInstanceStatusObject(url) { export function getInstanceStatusObject(url) {

View file

@ -63,11 +63,11 @@ function groupNotifications(notifications) {
mappedNotification.id += `-${id}`; mappedNotification.id += `-${id}`;
} }
} else { } else {
account._types = [type]; if (account) account._types = [type];
let n = (notificationsMap[key] = { let n = (notificationsMap[key] = {
...notification, ...notification,
type: virtualType, type: virtualType,
_accounts: [account], _accounts: account ? [account] : [],
}); });
cleanNotifications[j++] = n; cleanNotifications[j++] = n;
} }

114
src/utils/lists.js Normal file
View file

@ -0,0 +1,114 @@
import { api } from './api';
import pmem from './pmem';
import store from './store';
const FETCH_MAX_AGE = 1000 * 60; // 1 minute
const MAX_AGE = 24 * 60 * 60 * 1000; // 1 day
export const fetchLists = pmem(
async () => {
const { masto } = api();
const lists = await masto.v1.lists.list();
lists.sort((a, b) => a.title.localeCompare(b.title));
if (lists.length) {
setTimeout(() => {
// Save to local storage, with saved timestamp
store.account.set('lists', {
lists,
updatedAt: Date.now(),
});
}, 1);
}
return lists;
},
{
maxAge: FETCH_MAX_AGE,
},
);
export async function getLists() {
try {
const { lists, updatedAt } = store.account.get('lists') || {};
if (!lists?.length) return await fetchLists();
if (Date.now() - updatedAt > MAX_AGE) {
// Stale-while-revalidate
fetchLists();
return lists;
}
return lists;
} catch (e) {
return [];
}
}
export const fetchList = pmem(
(id) => {
const { masto } = api();
return masto.v1.lists.$select(id).fetch();
},
{
maxAge: FETCH_MAX_AGE,
},
);
export async function getList(id) {
const { lists } = store.account.get('lists') || {};
console.log({ lists });
if (lists?.length) {
const theList = lists.find((l) => l.id === id);
if (theList) return theList;
}
try {
return fetchList(id);
} catch (e) {
return null;
}
}
export async function getListTitle(id) {
const list = await getList(id);
return list?.title || '';
}
export function addListStore(list) {
const { lists } = store.account.get('lists') || {};
if (lists?.length) {
lists.push(list);
lists.sort((a, b) => a.title.localeCompare(b.title));
store.account.set('lists', {
lists,
updatedAt: Date.now(),
});
}
}
export function updateListStore(list) {
const { lists } = store.account.get('lists') || {};
if (lists?.length) {
const index = lists.findIndex((l) => l.id === list.id);
if (index !== -1) {
lists[index] = list;
lists.sort((a, b) => a.title.localeCompare(b.title));
store.account.set('lists', {
lists,
updatedAt: Date.now(),
});
}
}
}
export function deleteListStore(listID) {
const { lists } = store.account.get('lists') || {};
if (lists?.length) {
const index = lists.findIndex((l) => l.id === listID);
if (index !== -1) {
lists.splice(index, 1);
store.account.set('lists', {
lists,
updatedAt: Date.now(),
});
}
}
}

View file

@ -1,10 +1,16 @@
export default function localeCode2Text(code) { import mem from './mem';
const IntlDN = new Intl.DisplayNames(navigator.languages, {
type: 'language',
});
function _localeCode2Text(code) {
try { try {
return new Intl.DisplayNames(navigator.languages, { return IntlDN.of(code);
type: 'language',
}).of(code);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return null; return null;
} }
} }
export default mem(_localeCode2Text);

16
src/utils/open-osk.jsx Normal file
View file

@ -0,0 +1,16 @@
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
export default function openOSK() {
if (isSafari) {
const fauxEl = document.createElement('input');
fauxEl.style.position = 'absolute';
fauxEl.style.top = '0';
fauxEl.style.left = '0';
fauxEl.style.opacity = '0';
document.body.appendChild(fauxEl);
fauxEl.focus();
setTimeout(() => {
document.body.removeChild(fauxEl);
}, 500);
}
}

View file

@ -67,6 +67,7 @@ const states = proxy({
contentTranslationAutoInline: false, contentTranslationAutoInline: false,
shortcutSettingsCloudImportExport: false, shortcutSettingsCloudImportExport: false,
mediaAltGenerator: false, mediaAltGenerator: false,
composerGIFPicker: false,
cloakMode: false, cloakMode: false,
}, },
}); });
@ -99,6 +100,8 @@ export function initStates() {
store.account.get('settings-shortcutSettingsCloudImportExport') ?? false; store.account.get('settings-shortcutSettingsCloudImportExport') ?? false;
states.settings.mediaAltGenerator = states.settings.mediaAltGenerator =
store.account.get('settings-mediaAltGenerator') ?? false; store.account.get('settings-mediaAltGenerator') ?? false;
states.settings.composerGIFPicker =
store.account.get('settings-composerGIFPicker') ?? false;
states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false; states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false;
} }
@ -140,6 +143,9 @@ subscribe(states, (changes) => {
if (path.join('.') === 'settings.mediaAltGenerator') { if (path.join('.') === 'settings.mediaAltGenerator') {
store.account.set('settings-mediaAltGenerator', !!value); store.account.set('settings-mediaAltGenerator', !!value);
} }
if (path.join('.') === 'settings.composerGIFPicker') {
store.account.set('settings-composerGIFPicker', !!value);
}
if (path?.[0] === 'shortcuts') { if (path?.[0] === 'shortcuts') {
store.account.set('shortcuts', states.shortcuts); store.account.set('shortcuts', states.shortcuts);
} }

View file

@ -2,6 +2,7 @@ import store from './store';
export function getAccount(id) { export function getAccount(id) {
const accounts = store.local.getJSON('accounts') || []; const accounts = store.local.getJSON('accounts') || [];
if (!id) return accounts[0];
return accounts.find((a) => a.info.id === id) || accounts[0]; return accounts.find((a) => a.info.id === id) || accounts[0];
} }
@ -125,3 +126,8 @@ export function getCurrentInstanceConfiguration() {
const instance = getCurrentInstance(); const instance = getCurrentInstance();
return getInstanceConfiguration(instance); return getInstanceConfiguration(instance);
} }
export function isMediaFirstInstance() {
const instance = getCurrentInstance();
return /pixelfed/i.test(instance?.version);
}

View file

@ -83,15 +83,23 @@ function _unfurlMastodonLink(instance, url) {
limit: 1, limit: 1,
}) })
.then((results) => { .then((results) => {
if (results.statuses.length > 0) { const { statuses } = results;
const status = results.statuses[0]; if (statuses.length > 0) {
return { // Filter out statuses that has content that contains the URL, in-case-sensitive
status, const theStatuses = statuses.filter(
instance, (status) =>
}; !status.content?.toLowerCase().includes(theURL.toLowerCase()),
} else { );
throw new Error('No results');
if (theStatuses.length === 1) {
return {
status: theStatuses[0],
instance,
};
}
// If there are multiple statuses, give up, something is wrong
} }
throw new Error('No results');
}); });
function handleFulfill(result) { function handleFulfill(result) {

View file

@ -110,6 +110,7 @@ export default defineConfig({
], ],
build: { build: {
sourcemap: true, sourcemap: true,
cssCodeSplit: false,
rollupOptions: { rollupOptions: {
treeshake: false, treeshake: false,
input: { input: {
@ -117,9 +118,9 @@ export default defineConfig({
compose: resolve(__dirname, 'compose/index.html'), compose: resolve(__dirname, 'compose/index.html'),
}, },
output: { output: {
manualChunks: { // manualChunks: {
'intl-segmenter-polyfill': ['@formatjs/intl-segmenter/polyfill'], // 'intl-segmenter-polyfill': ['@formatjs/intl-segmenter/polyfill'],
}, // },
chunkFileNames: (chunkInfo) => { chunkFileNames: (chunkInfo) => {
const { facadeModuleId } = chunkInfo; const { facadeModuleId } = chunkInfo;
if (facadeModuleId && facadeModuleId.includes('icon')) { if (facadeModuleId && facadeModuleId.includes('icon')) {