Compare commits

..

10 commits

126 changed files with 4363 additions and 11751 deletions

View file

@ -10,7 +10,6 @@ assignees: ''
**Describe the bug** **Describe the bug**
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
- Which site: [e.g. dev.phanpy.social OR phanpy.social] - Which site: [e.g. dev.phanpy.social OR phanpy.social]
- Which site version: [On Phanpy, go to Settings -> About]
- Which instance: [e.g. mastodon.social] - Which instance: [e.g. mastodon.social]
**To Reproduce** **To Reproduce**

View file

@ -3,20 +3,17 @@
"useTabs": false, "useTabs": false,
"singleQuote": true, "singleQuote": true,
"trailingComma": "all", "trailingComma": "all",
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
"importOrder": [ "importOrder": [
"^[^.].*.css$", "^[^.].*.css$",
"index.css$", "index.css$",
".css$", ".css$",
"",
"./polyfills",
"",
"<THIRD_PARTY_MODULES>", "<THIRD_PARTY_MODULES>",
"",
"/assets/", "/assets/",
"",
"^../", "^../",
"",
"^[./]" "^[./]"
] ],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrderGroupNamespaceSpecifiers": true,
"importOrderCaseInsensitive": true
} }

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_CLIENT_NAME="Phanpy Dev" \ PHANPY_APP_TITLE="Phanpy Dev" \
PHANPY_WEBSITE="https://dev.phanpy.social" \ PHANPY_WEBSITE="https://dev.phanpy.social" \
npm run build npm run build
``` ```
@ -179,13 +179,6 @@ 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_IMG_ALT_API_URL` (optional, no defaults):
- API endpoint for self-hosted instance of [img-alt-api](https://github.com/cheeaun/img-alt-api).
- If provided, a setting will appear for users to enable the image description generator in the composer. Disabled by default.
- `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
@ -199,7 +192,7 @@ See documentation for [lingva-translate](https://github.com/thedaviddelta/lingva
These are self-hosted by other wonderful folks. These are self-hosted by other wonderful folks.
- [ferengi.one](https://m.ferengi.one/) by [@david@weaknotes.com](https://weaknotes.com/@david) - [ferengi.one](https://m.ferengi.one/) by [@david@collantes.social](https://collantes.social/@david)
- [phanpy.blaede.family](https://phanpy.blaede.family/) by [@cassidy@blaede.family](https://mastodon.blaede.family/@cassidy) - [phanpy.blaede.family](https://phanpy.blaede.family/) by [@cassidy@blaede.family](https://mastodon.blaede.family/@cassidy)
- [phanpy.mstdn.mx](https://phanpy.mstdn.mx/) by [@maop@mstdn.mx](https://mstdn.mx/@maop) - [phanpy.mstdn.mx](https://phanpy.mstdn.mx/) by [@maop@mstdn.mx](https://mstdn.mx/@maop)
- [phanpy.vmst.io](https://phanpy.vmst.io/) by [@vmstan@vmst.io](https://vmst.io/@vmstan) - [phanpy.vmst.io](https://phanpy.vmst.io/) by [@vmstan@vmst.io](https://vmst.io/@vmstan)
@ -207,11 +200,6 @@ These are self-hosted by other wonderful folks.
- [phanpy.bauxite.tech](https://phanpy.bauxite.tech) by [@b4ux1t3@hachyderm.io](https://hachyderm.io/@b4ux1t3) - [phanpy.bauxite.tech](https://phanpy.bauxite.tech) by [@b4ux1t3@hachyderm.io](https://hachyderm.io/@b4ux1t3)
- [phanpy.hear-me.social](https://phanpy.hear-me.social) by [@admin@hear-me.social](https://hear-me.social/@admin) - [phanpy.hear-me.social](https://phanpy.hear-me.social) by [@admin@hear-me.social](https://hear-me.social/@admin)
- [phanpy.fulda.social](https://phanpy.fulda.social) by [@Ganneff@fulda.social](https://fulda.social/@Ganneff) - [phanpy.fulda.social](https://phanpy.fulda.social) by [@Ganneff@fulda.social](https://fulda.social/@Ganneff)
- [phanpy.crmbl.uk](https://phanpy.crmbl.uk) by [@snail@crmbl.uk](https://mstdn.crmbl.uk/@snail)
- [halo.mookiesplace.com](https://halo.mookiesplace.com) by [@mookie@mookiesplace.com](https://mookiesplace.com/@mookie)
- [social.qrk.one](https://social.qrk.one) by [@kev@fosstodon.org](https://fosstodon.org/@kev)
- [phanpy.cz](https://phanpy.cz) by [@zdendys@mamutovo.cz](https://mamutovo.cz/@zdendys)
- [phanpy.social.tchncs.de](https://phanpy.social.tchncs.de) by [@milan@social.tchncs.de](https://social.tchncs.de/@milan)
> Note: Add yours by creating a pull request. > Note: Add yours by creating a pull request.
@ -247,8 +235,6 @@ And here I am. Building a Mastodon web client.
## Alternative web clients ## Alternative web clients
- Phanpy forks ↓
- [Agora](https://agorasocial.app/)
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - forks ↓ - [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - forks ↓
- [Semaphore](https://semaphore.social/) - [Semaphore](https://semaphore.social/)
- [Enafore](https://enafore.social/) - [Enafore](https://enafore.social/)
@ -264,8 +250,6 @@ 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

View file

@ -5,11 +5,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1710146030, "lastModified": 1694529238,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", "rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1723175592, "lastModified": 1701253981,
"narHash": "sha256-M0xJ3FbDUc4fRZ84dPGx5VvgFsOzds77KiBMW/mMTnI=", "narHash": "sha256-ztaDIyZ7HrTAfEEUt9AtTDNoCYxUdSd6NrRHaYOIxtk=",
"owner": "nixOS", "owner": "nixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "5e0ca22929f3342b19569b21b2f3462f053e497b", "rev": "e92039b55bcd58469325ded85d4f58dd5a4eaf58",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -9,17 +9,19 @@
esbuild = pkgs.buildGoModule rec { esbuild = pkgs.buildGoModule rec {
pname = "esbuild"; pname = "esbuild";
version = "0.21.5"; version = "0.19.5";
src = pkgs.fetchFromGitHub { src = pkgs.fetchFromGitHub {
owner = "evanw"; owner = "evanw";
repo = "esbuild"; repo = "esbuild";
rev = "v${version}"; rev = "v${version}";
hash = "sha256-FpvXWIlt67G8w3pBKZo/mcp57LunxDmRUaCU/Ne89B8="; hash = "sha256-mIXsPj804jxDd8+jPH8tWnnUj7dXIRwfKTeT4WDWb5c=";
}; };
vendorHash = "sha256-+BfxCyg0KkDQpHt/wycy/8CTG6YBA/VJvJFhhzUnSiQ="; vendorHash = "sha256-+BfxCyg0KkDQpHt/wycy/8CTG6YBA/VJvJFhhzUnSiQ=";
subPackages = [ "cmd/esbuild" ]; subPackages = [ "cmd/esbuild" ];
ldflags = [ "-s" "-w" ]; ldflags = [ "-s" "-w" ];
meta.mainProgram = "esbuild"; meta.mainProgram = "esbuild";
@ -36,7 +38,7 @@
src = lib.cleanSource ./.; src = lib.cleanSource ./.;
npmFlags = [ "--legacy-peer-deps" ]; npmFlags = [ "--legacy-peer-deps" ];
npmDepsHash = "sha256-VROK9Emxi+jFqwidA/CUxQwxitKf7Y6mx0yuOCUwrzI="; npmDepsHash = "sha256-YJwJOTTbypjT7BbGR5VWkKv+T/nAwbsc7s1kV41AGKM=";
# npmDepsHash = lib.fakeHash; # npmDepsHash = lib.fakeHash;
# DTTH-specific env variables # DTTH-specific env variables

4685
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,63 +7,60 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"fetch-instances": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js", "fetch-instances": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js",
"sourcemap": "npx source-map-explorer dist/assets/*.js", "sourcemap": "npx source-map-explorer dist/assets/*.js"
"bundle-visualizer": "npx vite-bundle-visualizer"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "~0.5.4", "@formatjs/intl-localematcher": "~0.5.4",
"@formatjs/intl-segmenter": "~11.5.7", "@formatjs/intl-segmenter": "~11.5.5",
"@formkit/auto-animate": "~0.8.2", "@formkit/auto-animate": "~0.8.1",
"@github/text-expander-element": "~2.7.1", "@github/text-expander-element": "~2.6.1",
"@iconify-icons/mingcute": "~1.2.9", "@iconify-icons/mingcute": "~1.2.9",
"@justinribeiro/lite-youtube": "~1.5.0", "@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~4.2.1", "@szhsin/react-menu": "~4.1.0",
"compare-versions": "~6.1.1", "@uidotdev/usehooks": "~2.4.1",
"dayjs": "~1.11.12", "compare-versions": "~6.1.0",
"dayjs": "~1.11.10",
"dayjs-twitter": "~0.5.0", "dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.4", "fast-blurhash": "~1.1.2",
"fast-equals": "~5.0.1", "fast-equals": "~5.0.1",
"fuse.js": "~7.0.0", "html-prettify": "^1.0.7",
"html-prettify": "~1.0.7",
"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.8.0", "masto": "~6.6.4",
"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.23.1", "preact": "~10.19.6",
"punycode": "~2.3.1",
"react-hotkeys-hook": "~4.5.0", "react-hotkeys-hook": "~4.5.0",
"react-intersection-observer": "~9.13.0", "react-intersection-observer": "~9.8.1",
"react-quick-pinch-zoom": "~5.1.0", "react-quick-pinch-zoom": "~5.1.0",
"react-router-dom": "6.6.2", "react-router-dom": "6.6.2",
"string-length": "6.0.0", "string-length": "6.0.0",
"swiped-events": "~1.2.0", "swiped-events": "~1.1.9",
"tinyld": "~1.3.4",
"toastify-js": "~1.12.0", "toastify-js": "~1.12.0",
"uid": "~2.0.2", "uid": "~2.0.2",
"use-debounce": "~10.0.2", "use-debounce": "~10.0.0",
"use-long-press": "~3.2.0", "use-long-press": "~3.2.0",
"use-resize-observer": "~9.1.0", "use-resize-observer": "~9.1.0",
"valtio": "1.13.2" "valtio": "1.13.2"
}, },
"devDependencies": { "devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "~4.3.1", "@preact/preset-vite": "~2.8.1",
"@preact/preset-vite": "~2.9.0", "@trivago/prettier-plugin-sort-imports": "~4.3.0",
"postcss": "~8.4.40", "postcss": "~8.4.35",
"postcss-dark-theme-class": "~1.3.0", "postcss-dark-theme-class": "~1.2.1",
"postcss-preset-env": "~10.0.0", "postcss-preset-env": "~9.4.0",
"twitter-text": "~3.1.0", "twitter-text": "~3.1.0",
"vite": "~5.3.5", "vite": "~5.1.5",
"vite-plugin-generate-file": "~0.2.0", "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.20.1", "vite-plugin-pwa": "~0.19.2",
"vite-plugin-remove-console": "~2.2.0", "vite-plugin-remove-console": "~2.2.0",
"workbox-cacheable-response": "~7.1.0", "workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.1.0", "workbox-expiration": "~7.0.0",
"workbox-routing": "~7.1.0", "workbox-routing": "~7.0.0",
"workbox-strategies": "~7.1.0" "workbox-strategies": "~7.0.0"
}, },
"postcss": { "postcss": {
"plugins": { "plugins": {

View file

@ -62,7 +62,7 @@ const iconsRoute = new Route(
cacheName: 'icons', cacheName: 'icons',
plugins: [ plugins: [
new ExpirationPlugin({ new ExpirationPlugin({
maxEntries: 300, maxEntries: 50,
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
purgeOnQuotaError: true, purgeOnQuotaError: true,
}), }),
@ -96,28 +96,6 @@ const apiExtendedRoute = new RegExpRoute(
); );
registerRoute(apiExtendedRoute); registerRoute(apiExtendedRoute);
// Note: expiration is not working as expected
// https://github.com/GoogleChrome/workbox/issues/3316
//
// const apiIntermediateRoute = new RegExpRoute(
// // Matches:
// // - trends/*
// // - timelines/link
// /^https?:\/\/[^\/]+\/api\/v\d+\/(trends|timelines\/link)/,
// new StaleWhileRevalidate({
// cacheName: 'api-intermediate',
// plugins: [
// new ExpirationPlugin({
// maxAgeSeconds: 1 * 60, // 1min
// }),
// new CacheableResponsePlugin({
// statuses: [0, 200],
// }),
// ],
// }),
// );
// registerRoute(apiIntermediateRoute);
const apiRoute = new RegExpRoute( const apiRoute = new RegExpRoute(
// Matches: // Matches:
// - statuses/:id/context - some contexts are really huge // - statuses/:id/context - some contexts are really huge

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
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,
@ -9,9 +10,7 @@ import {
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import { matchPath, Route, Routes, useLocation } from 'react-router-dom'; import { matchPath, Route, Routes, useLocation } from 'react-router-dom';
import 'swiped-events'; import 'swiped-events';
import { subscribe } from 'valtio'; import { subscribe } from 'valtio';
import BackgroundService from './components/background-service'; import BackgroundService from './components/background-service';
@ -19,16 +18,15 @@ 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';
@ -55,10 +53,12 @@ import { getAccessToken } from './utils/auth';
import focusDeck from './utils/focus-deck'; import focusDeck from './utils/focus-deck';
import states, { initStates, statusKey } from './utils/states'; import states, { initStates, statusKey } from './utils/states';
import store from './utils/store'; import store from './utils/store';
import { getCurrentAccount, setCurrentAccountID } 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 = [
@ -129,15 +129,13 @@ setInterval(() => {
// Related: https://github.com/vitejs/vite/issues/10600 // Related: https://github.com/vitejs/vite/issues/10600
setTimeout(() => { setTimeout(() => {
for (const icon in ICONS) { for (const icon in ICONS) {
setTimeout(() => { queueMicrotask(() => {
if (Array.isArray(ICONS[icon])) { if (Array.isArray(ICONS[icon])) {
ICONS[icon][0]?.(); ICONS[icon][0]?.();
} else if (typeof ICONS[icon] === 'object') {
ICONS[icon].module?.();
} else { } else {
ICONS[icon]?.(); ICONS[icon]?.();
} }
}, 1); });
} }
}, 5000); }, 5000);
@ -330,11 +328,11 @@ function App() {
const client = initClient({ instance: instanceURL, accessToken }); const client = initClient({ instance: instanceURL, accessToken });
await Promise.allSettled([ await Promise.allSettled([
initPreferences(client),
initInstance(client, instanceURL), initInstance(client, instanceURL),
initAccount(client, instanceURL, accessToken, vapidKey), initAccount(client, instanceURL, accessToken, vapidKey),
]); ]);
initStates(); initStates();
initPreferences(client);
setIsLoggedIn(true); setIsLoggedIn(true);
setUIState('default'); setUIState('default');
@ -343,15 +341,15 @@ function App() {
window.__IGNORE_GET_ACCOUNT_ERROR__ = true; window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
const account = getCurrentAccount(); const account = getCurrentAccount();
if (account) { if (account) {
setCurrentAccountID(account.info.id); store.session.set('currentAccount', account.info.id);
const { client } = api({ account }); const { client } = api({ account });
const { instance } = client; const { instance } = client;
// console.log('masto', masto); // console.log('masto', masto);
initStates(); initStates();
initPreferences(client);
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
await initPreferences(client);
await initInstance(client, instance); await initInstance(client, instance);
} catch (e) { } catch (e) {
} finally { } finally {
@ -388,7 +386,9 @@ function App() {
)} )}
{isLoggedIn && <ComposeButton />} {isLoggedIn && <ComposeButton />}
{isLoggedIn && <Shortcuts />} {isLoggedIn && <Shortcuts />}
<Modals /> <Suspense>
<Modals />
</Suspense>
{isLoggedIn && <NotificationService />} {isLoggedIn && <NotificationService />}
<BackgroundService isLoggedIn={isLoggedIn} /> <BackgroundService isLoggedIn={isLoggedIn} />
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />} {uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
@ -463,9 +463,15 @@ 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="/fh" element={<FollowedHashtags />} /> <Route path="/ft" element={<FollowedHashtags />} />
<Route path="/ft" element={<Filters />} /> <Route
<Route path="/catchup" element={<Catchup />} /> path="/catchup"
element={
<Suspense>
<Catchup />
</Suspense>
}
/>
</> </>
)} )}
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} /> <Route path="/:instance?/t/:hashtag" element={<Hashtag />} />

View file

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -9,17 +9,13 @@ body.cloak,
.status .content-container, .status .content-container,
.status .content-container *, .status .content-container *,
.status .content-compact > *, .status .content-compact > *,
.account-container .actions small,
.account-container :is(header, main > *:not(.actions)), .account-container :is(header, main > *:not(.actions)),
.account-container :is(header, main > *:not(.actions)) *, .account-container :is(header, main > *:not(.actions)) *,
.header-double-lines *, .header-double-lines,
.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 *,
.status.compact-thread *,
.status .content-compact {
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; */
@ -53,19 +49,9 @@ body.cloak,
body.cloak, body.cloak,
.cloak { .cloak {
.header-double-lines *,
.account-container .profile-metadata b,
.account-container .actions small,
.account-container .stats *,
.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;
} }
.account-container .actions small,
.status .content-compact {
background-color: currentColor !important;
}
} }

View file

@ -6,14 +6,8 @@ export const ICONS = {
'x-circle': () => import('@iconify-icons/mingcute/close-circle-line'), 'x-circle': () => import('@iconify-icons/mingcute/close-circle-line'),
transfer: () => import('@iconify-icons/mingcute/transfer-4-line'), transfer: () => import('@iconify-icons/mingcute/transfer-4-line'),
rocket: () => import('@iconify-icons/mingcute/rocket-line'), rocket: () => import('@iconify-icons/mingcute/rocket-line'),
'arrow-left': { 'arrow-left': () => import('@iconify-icons/mingcute/arrow-left-line'),
module: () => import('@iconify-icons/mingcute/arrow-left-line'), 'arrow-right': () => import('@iconify-icons/mingcute/arrow-right-line'),
rtl: true,
},
'arrow-right': {
module: () => import('@iconify-icons/mingcute/arrow-right-line'),
rtl: true,
},
'arrow-up': () => import('@iconify-icons/mingcute/arrow-up-line'), 'arrow-up': () => import('@iconify-icons/mingcute/arrow-up-line'),
'arrow-down': () => import('@iconify-icons/mingcute/arrow-down-line'), 'arrow-down': () => import('@iconify-icons/mingcute/arrow-down-line'),
earth: () => import('@iconify-icons/mingcute/earth-line'), earth: () => import('@iconify-icons/mingcute/earth-line'),
@ -22,14 +16,8 @@ export const ICONS = {
'eye-close': () => import('@iconify-icons/mingcute/eye-close-line'), 'eye-close': () => import('@iconify-icons/mingcute/eye-close-line'),
'eye-open': () => import('@iconify-icons/mingcute/eye-2-line'), 'eye-open': () => import('@iconify-icons/mingcute/eye-2-line'),
message: () => import('@iconify-icons/mingcute/mail-line'), message: () => import('@iconify-icons/mingcute/mail-line'),
comment: { comment: () => import('@iconify-icons/mingcute/chat-3-line'),
module: () => import('@iconify-icons/mingcute/chat-3-line'), comment2: () => import('@iconify-icons/mingcute/comment-2-line'),
rtl: true,
},
comment2: {
module: () => import('@iconify-icons/mingcute/comment-2-line'),
rtl: true,
},
home: () => import('@iconify-icons/mingcute/home-3-line'), home: () => import('@iconify-icons/mingcute/home-3-line'),
notification: () => import('@iconify-icons/mingcute/notification-line'), notification: () => import('@iconify-icons/mingcute/notification-line'),
follow: () => import('@iconify-icons/mingcute/user-follow-line'), follow: () => import('@iconify-icons/mingcute/user-follow-line'),
@ -43,46 +31,23 @@ export const ICONS = {
gear: () => import('@iconify-icons/mingcute/settings-3-line'), gear: () => import('@iconify-icons/mingcute/settings-3-line'),
more: () => import('@iconify-icons/mingcute/more-3-line'), more: () => import('@iconify-icons/mingcute/more-3-line'),
more2: () => import('@iconify-icons/mingcute/more-1-fill'), more2: () => import('@iconify-icons/mingcute/more-1-fill'),
external: { external: () => import('@iconify-icons/mingcute/external-link-line'),
module: () => import('@iconify-icons/mingcute/external-link-line'), popout: () => import('@iconify-icons/mingcute/external-link-line'),
rtl: true, popin: [() => import('@iconify-icons/mingcute/external-link-line'), '180deg'],
},
popout: {
module: () => import('@iconify-icons/mingcute/external-link-line'),
rtl: true,
},
popin: {
module: () => import('@iconify-icons/mingcute/external-link-line'),
rotate: '180deg',
rtl: true,
},
plus: () => import('@iconify-icons/mingcute/add-circle-line'), plus: () => import('@iconify-icons/mingcute/add-circle-line'),
'chevron-left': { 'chevron-left': () => import('@iconify-icons/mingcute/left-line'),
module: () => import('@iconify-icons/mingcute/left-line'), 'chevron-right': () => import('@iconify-icons/mingcute/right-line'),
rtl: true,
},
'chevron-right': {
module: () => import('@iconify-icons/mingcute/right-line'),
rtl: true,
},
'chevron-down': () => import('@iconify-icons/mingcute/down-line'), 'chevron-down': () => import('@iconify-icons/mingcute/down-line'),
reply: { reply: [
module: () => import('@iconify-icons/mingcute/share-forward-line'), () => import('@iconify-icons/mingcute/share-forward-line'),
rotate: '180deg', '180deg',
flip: 'horizontal', 'horizontal',
rtl: true, ],
},
thread: () => import('@iconify-icons/mingcute/route-line'), thread: () => import('@iconify-icons/mingcute/route-line'),
group: { group: () => import('@iconify-icons/mingcute/group-line'),
module: () => import('@iconify-icons/mingcute/group-line'),
rtl: true,
},
bot: () => import('@iconify-icons/mingcute/android-2-line'), bot: () => import('@iconify-icons/mingcute/android-2-line'),
menu: () => import('@iconify-icons/mingcute/rows-4-line'), menu: () => import('@iconify-icons/mingcute/rows-4-line'),
list: { list: () => import('@iconify-icons/mingcute/list-check-line'),
module: () => import('@iconify-icons/mingcute/list-check-line'),
rtl: true,
},
search: () => import('@iconify-icons/mingcute/search-2-line'), search: () => import('@iconify-icons/mingcute/search-2-line'),
hashtag: () => import('@iconify-icons/mingcute/hashtag-line'), hashtag: () => import('@iconify-icons/mingcute/hashtag-line'),
info: () => import('@iconify-icons/mingcute/information-line'), info: () => import('@iconify-icons/mingcute/information-line'),
@ -97,21 +62,12 @@ export const ICONS = {
share: () => import('@iconify-icons/mingcute/share-2-line'), share: () => import('@iconify-icons/mingcute/share-2-line'),
sparkles: () => import('@iconify-icons/mingcute/sparkles-line'), sparkles: () => import('@iconify-icons/mingcute/sparkles-line'),
sparkles2: () => import('@iconify-icons/mingcute/sparkles-2-line'), sparkles2: () => import('@iconify-icons/mingcute/sparkles-2-line'),
exit: { exit: () => import('@iconify-icons/mingcute/exit-line'),
module: () => import('@iconify-icons/mingcute/exit-line'),
rtl: true,
},
translate: () => import('@iconify-icons/mingcute/translate-line'), translate: () => import('@iconify-icons/mingcute/translate-line'),
play: () => import('@iconify-icons/mingcute/play-fill'), play: () => import('@iconify-icons/mingcute/play-fill'),
trash: () => import('@iconify-icons/mingcute/delete-2-line'), trash: () => import('@iconify-icons/mingcute/delete-2-line'),
mute: { mute: () => import('@iconify-icons/mingcute/volume-mute-line'),
module: () => import('@iconify-icons/mingcute/volume-mute-line'), unmute: () => import('@iconify-icons/mingcute/volume-line'),
rtl: true,
},
unmute: {
module: () => import('@iconify-icons/mingcute/volume-line'),
rtl: true,
},
block: () => import('@iconify-icons/mingcute/forbid-circle-line'), block: () => import('@iconify-icons/mingcute/forbid-circle-line'),
unblock: [ unblock: [
() => import('@iconify-icons/mingcute/forbid-circle-line'), () => import('@iconify-icons/mingcute/forbid-circle-line'),
@ -122,57 +78,30 @@ 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: { layout4: () => import('@iconify-icons/mingcute/layout-4-line'),
module: () => import('@iconify-icons/mingcute/layout-4-line'),
rtl: true,
},
layout5: () => import('@iconify-icons/mingcute/layout-5-line'), layout5: () => import('@iconify-icons/mingcute/layout-5-line'),
announce: { announce: () => import('@iconify-icons/mingcute/announcement-line'),
module: () => import('@iconify-icons/mingcute/announcement-line'),
rtl: true,
},
alert: () => import('@iconify-icons/mingcute/alert-line'), alert: () => import('@iconify-icons/mingcute/alert-line'),
round: () => import('@iconify-icons/mingcute/round-fill'), round: () => import('@iconify-icons/mingcute/round-fill'),
'arrow-up-circle': () => 'arrow-up-circle': () =>
import('@iconify-icons/mingcute/arrow-up-circle-line'), import('@iconify-icons/mingcute/arrow-up-circle-line'),
'arrow-down-circle': () => 'arrow-down-circle': () =>
import('@iconify-icons/mingcute/arrow-down-circle-line'), import('@iconify-icons/mingcute/arrow-down-circle-line'),
clipboard: { clipboard: () => import('@iconify-icons/mingcute/clipboard-line'),
module: () => import('@iconify-icons/mingcute/clipboard-line'),
rtl: true,
},
'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'), 'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'),
'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'), 'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'), keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
cloud: () => import('@iconify-icons/mingcute/cloud-line'), cloud: () => import('@iconify-icons/mingcute/cloud-line'),
month: { month: () => import('@iconify-icons/mingcute/calendar-month-line'),
module: () => import('@iconify-icons/mingcute/calendar-month-line'),
rtl: true,
},
media: () => import('@iconify-icons/mingcute/photo-album-line'), media: () => import('@iconify-icons/mingcute/photo-album-line'),
speak: () => import('@iconify-icons/mingcute/radar-line'), speak: () => import('@iconify-icons/mingcute/radar-line'),
building: () => import('@iconify-icons/mingcute/building-5-line'), building: () => import('@iconify-icons/mingcute/building-5-line'),
history2: { history2: () => import('@iconify-icons/mingcute/history-2-line'),
module: () => import('@iconify-icons/mingcute/history-2-line'),
rtl: true,
},
document: () => import('@iconify-icons/mingcute/document-line'), document: () => import('@iconify-icons/mingcute/document-line'),
'arrows-right': { 'arrows-right': () => import('@iconify-icons/mingcute/arrows-right-line'),
module: () => import('@iconify-icons/mingcute/arrows-right-line'),
rtl: true,
},
code: () => import('@iconify-icons/mingcute/code-line'), code: () => import('@iconify-icons/mingcute/code-line'),
copy: () => import('@iconify-icons/mingcute/copy-2-line'), copy: () => import('@iconify-icons/mingcute/copy-2-line'),
quote: {
module: () => import('@iconify-icons/mingcute/quote-left-line'),
rtl: true,
},
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'),
'user-setting': () => import('@iconify-icons/mingcute/user-setting-line'), 'user-setting': () => import('@iconify-icons/mingcute/user-setting-line'),
minimize: () => import('@iconify-icons/mingcute/arrows-down-line'),
}; };

View file

@ -29,8 +29,6 @@
line-clamp: 1; line-clamp: 1;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
unicode-bidi: isolate;
direction: initial;
} }
a { a {

View file

@ -33,7 +33,7 @@ function AccountBlock({
<span> <span>
<b></b> <b></b>
<br /> <br />
<span class="account-block-acct"></span> <span class="account-block-acct">@</span>
</span> </span>
</div> </div>
); );
@ -62,7 +62,6 @@ function AccountBlock({
group, group,
followersCount, followersCount,
createdAt, createdAt,
locked,
} = account; } = account;
let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct]; let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
if (accountInstance) { if (accountInstance) {
@ -87,7 +86,7 @@ function AccountBlock({
class="account-block" class="account-block"
href={url} href={url}
target={external ? '_blank' : null} target={external ? '_blank' : null}
title={acct2 ? acct : `@${acct}`} title={`@${acct}`}
onClick={(e) => { onClick={(e) => {
if (external) return; if (external) return;
e.preventDefault(); e.preventDefault();
@ -120,31 +119,27 @@ function AccountBlock({
)} )}
</> </>
)}{' '} )}{' '}
<span class="account-block-acct bidi-isolate"> <span class="account-block-acct">
{acct2 ? '' : '@'} @{acct1}
{acct1}
<wbr /> <wbr />
{acct2} {acct2}
{locked && (
<>
{' '}
<Icon icon="lock" size="s" alt="Locked" />
</>
)}
</span> </span>
{showActivity && ( {showActivity && (
<div class="account-block-stats"> <>
Posts: {shortenNumber(statusesCount)} <br />
{!!lastStatusAt && ( <small class="last-status-at insignificant">
<> Posts: {statusesCount}
{' '} {!!lastStatusAt && (
&middot; Last posted:{' '} <>
{niceDateTime(lastStatusAt, { {' '}
hideTime: true, &middot; Last posted:{' '}
})} {niceDateTime(lastStatusAt, {
</> hideTime: true,
)} })}
</div> </>
)}
</small>
</>
)} )}
{showStats && ( {showStats && (
<div class="account-block-stats"> <div class="account-block-stats">

View file

@ -57,7 +57,7 @@
background-repeat: no-repeat; background-repeat: no-repeat;
animation: swoosh-bg-image 0.3s ease-in-out 0.3s both; animation: swoosh-bg-image 0.3s ease-in-out 0.3s both;
background-image: linear-gradient( background-image: linear-gradient(
var(--to-forward), to right,
var(--original-color) 0%, var(--original-color) 0%,
var(--original-color) calc(var(--originals-percentage) - var(--gap)), var(--original-color) calc(var(--originals-percentage) - var(--gap)),
var(--gap-color) calc(var(--originals-percentage) - var(--gap)), var(--gap-color) calc(var(--originals-percentage) - var(--gap)),
@ -181,8 +181,8 @@
opacity: 1; opacity: 1;
} }
.sheet .account-container .header-banner { .sheet .account-container .header-banner {
border-start-start-radius: 16px; border-top-left-radius: 16px;
border-start-end-radius: 16px; border-top-right-radius: 16px;
} }
.account-container .header-banner.header-is-avatar { .account-container .header-banner.header-is-avatar {
mask-image: linear-gradient( mask-image: linear-gradient(
@ -288,17 +288,10 @@
align-self: center !important; align-self: center !important;
/* clip a dog ear on top right */ /* clip a dog ear on top right */
clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%); clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%);
&:dir(rtl) {
/* top left */
clip-path: polygon(4px 0, 100% 0, 100% 100%, 0 100%, 0 4px);
}
/* 4x4px square on top right */ /* 4x4px square on top right */
background-size: 4px 4px; background-size: 4px 4px;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: top right; background-position: top right;
&:dir(rtl) {
background-position: top left;
}
background-image: linear-gradient( background-image: linear-gradient(
to bottom, to bottom,
var(--private-note-border-color), var(--private-note-border-color),
@ -318,7 +311,7 @@
box-orient: vertical; box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2; line-clamp: 2;
text-align: start; text-align: left;
} }
&:hover:not(:active) { &:hover:not(:active) {
@ -377,8 +370,7 @@
animation: appear 1s both ease-in-out; animation: appear 1s both ease-in-out;
> *:not(:first-child) { > *:not(:first-child) {
margin: 0; margin: 0 0 0 -4px;
margin-inline-start: -4px;
} }
} }
} }
@ -430,15 +422,15 @@
} }
&:has(+ .account-metadata-box) { &:has(+ .account-metadata-box) {
border-end-start-radius: 4px; border-bottom-left-radius: 4px;
border-end-end-radius: 4px; border-bottom-right-radius: 4px;
} }
+ .account-metadata-box { + .account-metadata-box {
border-start-start-radius: 4px; border-top-left-radius: 4px;
border-start-end-radius: 4px; border-top-right-radius: 4px;
border-end-start-radius: 16px; border-bottom-left-radius: 16px;
border-end-end-radius: 16px; border-bottom-right-radius: 16px;
} }
} }
@ -789,108 +781,3 @@
} }
} }
} }
#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: start;
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 { MenuDivider, MenuItem } from '@szhsin/react-menu'; import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu';
import { import {
useCallback, useCallback,
useEffect, useEffect,
@ -9,22 +9,18 @@ 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';
import showCompose from '../utils/show-compose';
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
import states, { hideAllModals } from '../utils/states'; import states, { hideAllModals } from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import { getCurrentAccountID, updateAccount } from '../utils/store-utils'; import { updateAccount } from '../utils/store-utils';
import supports from '../utils/supports';
import AccountBlock from './account-block'; import AccountBlock from './account-block';
import Avatar from './avatar'; import Avatar from './avatar';
@ -33,11 +29,9 @@ import Icon from './icon';
import Link from './link'; import Link from './link';
import ListAddEdit from './list-add-edit'; import ListAddEdit from './list-add-edit';
import Loader from './loader'; import Loader from './loader';
import MenuConfirm from './menu-confirm';
import MenuLink from './menu-link';
import Menu2 from './menu2'; import Menu2 from './menu2';
import MenuConfirm from './menu-confirm';
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 = [
@ -187,7 +181,6 @@ function AccountInfo({
memorial, memorial,
moved, moved,
roles, roles,
hideCollections,
} = info || {}; } = info || {};
let headerIsAvatar = false; let headerIsAvatar = false;
let { header, headerStatic } = info || {}; let { header, headerStatic } = info || {};
@ -201,7 +194,10 @@ function AccountInfo({
} }
} }
const isSelf = useMemo(() => id === getCurrentAccountID(), [id]); const isSelf = useMemo(
() => id === store.session.get('currentAccount'),
[id],
);
useEffect(() => { useEffect(() => {
const infoHasEssentials = !!( const infoHasEssentials = !!(
@ -231,7 +227,7 @@ function AccountInfo({
const accountInstance = useMemo(() => { const accountInstance = useMemo(() => {
if (!url) return null; if (!url) return null;
const domain = punycode.toUnicode(URL.parse(url).hostname); const domain = new URL(url).hostname;
return domain; return domain;
}, [url]); }, [url]);
@ -254,13 +250,12 @@ 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) {
let familiarFollowers = []; const familiarFollowers = await masto.v1.accounts.familiarFollowers.fetch(
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(
@ -345,17 +340,6 @@ 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"
@ -469,15 +453,12 @@ function AccountInfo({
e.target.classList.add('loaded'); e.target.classList.add('loaded');
try { try {
// Get color from four corners of image // Get color from four corners of image
const canvas = window.OffscreenCanvas const canvas = document.createElement('canvas');
? new OffscreenCanvas(1, 1)
: document.createElement('canvas');
const ctx = canvas.getContext('2d', { const ctx = canvas.getContext('2d', {
willReadFrequently: true, willReadFrequently: true,
}); });
canvas.width = e.target.width; canvas.width = e.target.width;
canvas.height = e.target.height; canvas.height = e.target.height;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(e.target, 0, 0); ctx.drawImage(e.target, 0, 0);
// const colors = [ // const colors = [
// ctx.getImageData(0, 0, 1, 1).data, // ctx.getImageData(0, 0, 1, 1).data,
@ -545,66 +526,13 @@ function AccountInfo({
/> />
)} )}
<header> <header>
{standalone ? ( <AccountBlock
<Menu2 account={info}
shift={ instance={instance}
window.matchMedia('(min-width: calc(40em))').matches avatarSize="xxxl"
? 114 external={standalone}
: 64 internal={!standalone}
} />
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 handleWithInstance = acct.includes('@')
? `@${acct}`
: `@${acct}@${instance}`;
try {
navigator.clipboard.writeText(handleWithInstance);
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>
@ -674,16 +602,12 @@ 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,
excludeRelationshipAttrs: isSelf excludeRelationshipAttrs: isSelf
? ['followedBy'] ? ['followedBy']
: [], : [],
blankCopy: hideCollections
? 'This user has chosen to not make this information available.'
: undefined,
}; };
}, 0); }, 0);
}} }}
@ -719,9 +643,6 @@ function AccountInfo({
fetchAccounts: fetchFollowing, fetchAccounts: fetchFollowing,
instance, instance,
excludeRelationshipAttrs: isSelf ? ['following'] : [], excludeRelationshipAttrs: isSelf ? ['following'] : [],
blankCopy: hideCollections
? 'This user has chosen to not make this information available.'
: undefined,
}; };
}, 0); }, 0);
}} }}
@ -831,49 +752,45 @@ function AccountInfo({
</div> </div>
</LinkOrDiv> </LinkOrDiv>
)} )}
{!moved && ( <div class="account-metadata-box">
<div class="account-metadata-box"> <div
<div class="shazam-container no-animation"
class="shazam-container no-animation" hidden={!!postingStats}
hidden={!!postingStats} >
> <div class="shazam-container-inner">
<div class="shazam-container-inner"> <button
<button type="button"
type="button" class="posting-stats-button"
class="posting-stats-button" disabled={postingStatsUIState === 'loading'}
disabled={postingStatsUIState === 'loading'} onClick={() => {
onClick={() => { renderPostingStats();
renderPostingStats(); }}
>
<div
class={`posting-stats-bar posting-stats-icon ${
postingStatsUIState === 'loading' ? 'loading' : ''
}`}
style={{
'--originals-percentage': '33%',
'--replies-percentage': '66%',
}} }}
> />
<div View post stats{' '}
class={`posting-stats-bar posting-stats-icon ${ {/* <Loader
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>
</> </>
@ -888,10 +805,8 @@ 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 {
@ -926,11 +841,9 @@ function RelatedActions({
const [currentInfo, setCurrentInfo] = useState(null); const [currentInfo, setCurrentInfo] = useState(null);
const [isSelf, setIsSelf] = useState(false); const [isSelf, setIsSelf] = useState(false);
const acctWithInstance = acct.includes('@') ? acct : `${acct}@${instance}`;
useEffect(() => { useEffect(() => {
if (info) { if (info) {
const currentAccount = getCurrentAccountID(); const currentAccount = store.session.get('currentAccount');
let currentID; let currentID;
(async () => { (async () => {
if (sameInstance && authenticated) { if (sameInstance && authenticated) {
@ -965,7 +878,7 @@ function RelatedActions({
accountID.current = currentID; accountID.current = currentID;
// if (moved) return; if (moved) return;
setRelationshipUIState('loading'); setRelationshipUIState('loading');
@ -1004,7 +917,6 @@ 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 (
@ -1086,11 +998,11 @@ function RelatedActions({
<> <>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
showCompose({ states.showCompose = {
draftStatus: { draftStatus: {
status: `@${currentInfo?.acct || acct} `, status: `@${currentInfo?.acct || acct} `,
}, },
}); };
}} }}
> >
<Icon icon="at" /> <Icon icon="at" />
@ -1104,82 +1016,16 @@ function RelatedActions({
<Icon icon="translate" /> <Icon icon="translate" />
<span>Translate bio</span> <span>Translate bio</span>
</MenuItem> </MenuItem>
{supports('@mastodon/profile-private-note') && ( <MenuItem
<MenuItem onClick={() => {
onClick={() => { setShowPrivateNoteModal(true);
setShowPrivateNoteModal(true); }}
}} >
> <Icon icon="pencil" />
<Icon icon="pencil" /> <span>
<span> {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} enabled.`
: `Boosts from @${username} disabled.`,
);
} 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
@ -1209,7 +1055,7 @@ function RelatedActions({
)} )}
<MenuItem <MenuItem
onClick={() => { onClick={() => {
const handle = `@${currentInfo?.acct || acctWithInstance}`; const handle = `@${currentInfo?.acct || acct}`;
try { try {
navigator.clipboard.writeText(handle); navigator.clipboard.writeText(handle);
showToast('Handle copied'); showToast('Handle copied');
@ -1223,8 +1069,8 @@ function RelatedActions({
<small> <small>
Copy handle Copy handle
<br /> <br />
<span class="more-insignificant bidi-isolate"> <span class="more-insignificant">
@{currentInfo?.acct || acctWithInstance} @{currentInfo?.acct || acct}
</span> </span>
</small> </small>
</MenuItem> </MenuItem>
@ -1298,7 +1144,7 @@ function RelatedActions({
<span>Unmute @{username}</span> <span>Unmute @{username}</span>
</MenuItem> </MenuItem>
) : ( ) : (
<SubMenu2 <SubMenu
menuClassName="menu-blur" menuClassName="menu-blur"
openTrigger="clickOnly" openTrigger="clickOnly"
direction="bottom" direction="bottom"
@ -1352,44 +1198,7 @@ function RelatedActions({
</MenuItem> </MenuItem>
))} ))}
</div> </div>
</SubMenu2> </SubMenu>
)}
{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
@ -1464,22 +1273,6 @@ function RelatedActions({
</MenuItem> </MenuItem>
</> </>
)} )}
{currentAuthenticated &&
isSelf &&
standalone &&
supports('@mastodon/profile-edit') && (
<>
<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 />
@ -1505,7 +1298,7 @@ function RelatedActions({
{!relationship && relationshipUIState === 'loading' && ( {!relationship && relationshipUIState === 'loading' && (
<Loader abrupt /> <Loader abrupt />
)} )}
{!!relationship && !moved && ( {!!relationship && (
<MenuConfirm <MenuConfirm
confirm={following || requested} confirm={following || requested}
confirmLabel={ confirmLabel={
@ -1621,22 +1414,6 @@ function RelatedActions({
/> />
</Modal> </Modal>
)} )}
{!!showEditProfile && (
<Modal
onClose={() => {
setShowEditProfile(false);
}}
>
<EditProfileSheet
onClose={({ state, account } = {}) => {
setShowEditProfile(false);
if (state === 'success' && account) {
onProfileUpdate(account);
}
}}
/>
</Modal>
)}
</> </>
); );
} }
@ -1659,12 +1436,12 @@ function lightenRGB([r, g, b]) {
function niceAccountURL(url) { function niceAccountURL(url) {
if (!url) return; if (!url) return;
const urlObj = URL.parse(url); const urlObj = new URL(url);
const { host, pathname } = urlObj; const { host, pathname } = urlObj;
const path = pathname.replace(/\/$/, '').replace(/^\//, ''); const path = pathname.replace(/\/$/, '').replace(/^\//, '');
return ( return (
<> <>
<span class="more-insignificant">{punycode.toUnicode(host)}/</span> <span class="more-insignificant">{host}/</span>
<wbr /> <wbr />
<span>{path}</span> <span>{path}</span>
</> </>
@ -1714,12 +1491,13 @@ function AddRemoveListsSheet({ accountID, onClose }) {
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
const lists = await getLists(); const lists = await masto.v1.lists.list();
setLists(lists); lists.sort((a, b) => a.title.localeCompare(b.title));
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) {
@ -1897,7 +1675,6 @@ function PrivateNoteSheet({
ref={textareaRef} ref={textareaRef}
name="note" name="note"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
dir="auto"
> >
{initialNote} {initialNote}
</textarea> </textarea>
@ -1925,217 +1702,4 @@ 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'}
dir="auto"
/>
</label>
</p>
<p>
<label>
Bio
<textarea
defaultValue={note}
name="note"
maxLength={500}
rows="5"
disabled={uiState === 'loading'}
dir="auto"
/>
</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}
dir="auto"
/>
</td>
<td>
<input
type="text"
name={`fields_attributes[${i}][value]`}
defaultValue={value}
disabled={disabled}
maxLength={255}
onChange={(e) => setHasValue(!!e.currentTarget.value)}
dir="auto"
/>
</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

@ -58,7 +58,7 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
if (result.accounts.length) { if (result.accounts.length) {
return result.accounts[0]; return result.accounts[0];
} else if (/https?:\/\/[^/]+\/@/.test(account)) { } else if (/https?:\/\/[^/]+\/@/.test(account)) {
const accountURL = URL.parse(account); const accountURL = new URL(account);
const { hostname, pathname } = accountURL; const { hostname, pathname } = accountURL;
const acct = const acct =
pathname.replace(/^\//, '').replace(/\/$/, '') + pathname.replace(/^\//, '').replace(/\/$/, '') +

View file

@ -21,7 +21,6 @@ const canvas = window.OffscreenCanvas
const ctx = canvas.getContext('2d', { const ctx = canvas.getContext('2d', {
willReadFrequently: true, willReadFrequently: true,
}); });
ctx.imageSmoothingEnabled = false;
function Avatar({ url, size, alt = '', squircle, ...props }) { function Avatar({ url, size, alt = '', squircle, ...props }) {
size = SIZES[size] || size || SIZES.m; size = SIZES[size] || size || SIZES.m;
@ -63,7 +62,7 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
if (avatarRef.current) avatarRef.current.dataset.loaded = true; if (avatarRef.current) avatarRef.current.dataset.loaded = true;
if (alphaCache[url] !== undefined) return; if (alphaCache[url] !== undefined) return;
if (isMissing) return; if (isMissing) return;
setTimeout(() => { queueMicrotask(() => {
try { try {
// Check if image has alpha channel // Check if image has alpha channel
const { width, height } = e.target; const { width, height } = e.target;
@ -88,7 +87,7 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
// Silent fail // Silent fail
alphaCache[url] = false; alphaCache[url] = false;
} }
}, 1); });
}} }}
/> />
)} )}

View file

@ -9,7 +9,7 @@ import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility'; import usePageVisibility from '../utils/usePageVisibility';
const STREAMING_TIMEOUT = 1000 * 3; // 3 seconds const STREAMING_TIMEOUT = 1000 * 3; // 3 seconds
const POLL_INTERVAL = 20_000; // 20 seconds const POLL_INTERVAL = 15_000; // 15 seconds
export default memo(function BackgroundService({ isLoggedIn }) { export default memo(function BackgroundService({ isLoggedIn }) {
// Notifications service // Notifications service
@ -46,7 +46,6 @@ export default memo(function BackgroundService({ isLoggedIn }) {
useEffect(() => { useEffect(() => {
let sub; let sub;
let streamTimeout;
let pollNotifications; let pollNotifications;
if (isLoggedIn && visible) { if (isLoggedIn && visible) {
const { masto, streaming, instance } = api(); const { masto, streaming, instance } = api();
@ -57,7 +56,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
let hasStreaming = false; let hasStreaming = false;
// 2. Start streaming // 2. Start streaming
if (streaming) { if (streaming) {
streamTimeout = setTimeout(() => { pollNotifications = setTimeout(() => {
(async () => { (async () => {
try { try {
hasStreaming = true; hasStreaming = true;
@ -95,7 +94,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
return () => { return () => {
sub?.unsubscribe?.(); sub?.unsubscribe?.();
sub = null; sub = null;
clearTimeout(streamTimeout); clearTimeout(pollNotifications);
clearInterval(pollNotifications); clearInterval(pollNotifications);
}; };
}, [visible, isLoggedIn]); }, [visible, isLoggedIn]);

View file

@ -39,8 +39,6 @@ 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,22 +1,12 @@
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useSnapshot } from 'valtio';
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';
export default function ComposeButton() { export default function ComposeButton() {
const snapStates = useSnapshot(states);
function handleButton(e) { function handleButton(e) {
if (snapStates.composerState.minimized) {
states.composerState.minimized = false;
openOSK();
return;
}
if (e.shiftKey) { if (e.shiftKey) {
const newWin = openCompose(); const newWin = openCompose();
@ -24,7 +14,6 @@ export default function ComposeButton() {
states.showCompose = true; states.showCompose = true;
} }
} else { } else {
openOSK();
states.showCompose = true; states.showCompose = true;
} }
} }
@ -37,14 +26,7 @@ export default function ComposeButton() {
}); });
return ( return (
<button <button type="button" id="compose-button" onClick={handleButton}>
type="button"
id="compose-button"
onClick={handleButton}
class={`${snapStates.composerState.minimized ? 'min' : ''} ${
snapStates.composerState.publishing ? 'loading' : ''
} ${snapStates.composerState.publishingError ? 'error' : ''}`}
>
<Icon icon="quill" size="xl" alt="Compose" /> <Icon icon="quill" size="xl" alt="Compose" />
</button> </button>
); );

View file

@ -1,48 +0,0 @@
import { shouldPolyfill } from '@formatjs/intl-segmenter/should-polyfill';
import { useEffect, useState } from 'preact/hooks';
import Loader from './loader';
const supportsIntlSegmenter = !shouldPolyfill();
function importIntlSegmenter() {
if (!supportsIntlSegmenter) {
return import('@formatjs/intl-segmenter/polyfill-force').catch(() => {});
}
}
function importCompose() {
return import('./compose');
}
export async function preload() {
try {
await importIntlSegmenter();
importCompose();
} catch (e) {
console.error(e);
}
}
export default function ComposeSuspense(props) {
const [Compose, setCompose] = useState(null);
useEffect(() => {
(async () => {
try {
if (supportsIntlSegmenter) {
const component = await importCompose();
setCompose(component);
} else {
await importIntlSegmenter();
const component = await importCompose();
setCompose(component);
}
} catch (e) {
console.error(e);
}
})();
}, []);
return Compose?.default ? <Compose.default {...props} /> : <Loader />;
}

View file

@ -16,6 +16,7 @@
} }
#compose-container .compose-top { #compose-container .compose-top {
text-align: right;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
@ -61,7 +62,7 @@
box-shadow: 0 -3px 12px -3px var(--drop-shadow-color); box-shadow: 0 -3px 12px -3px var(--drop-shadow-color);
} }
#compose-container .status-preview:has(.status-badge:not(:empty)) { #compose-container .status-preview:has(.status-badge:not(:empty)) {
border-start-end-radius: 8px; border-top-right-radius: 8px;
} }
#compose-container .status-preview :is(.content-container, .time) { #compose-container .status-preview :is(.content-container, .time) {
pointer-events: none; pointer-events: none;
@ -94,10 +95,6 @@
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
0 1px 10px var(--bg-color); 0 1px 10px var(--bg-color);
z-index: 2; z-index: 2;
strong {
color: var(--red-color);
}
} }
#_compose-container .status-preview-legend.reply-to { #_compose-container .status-preview-legend.reply-to {
color: var(--reply-to-color); color: var(--reply-to-color);
@ -110,8 +107,8 @@
} }
#compose-container form { #compose-container form {
--form-padding-inline: 8px; --form-padding-inline: 12px;
--form-padding-block: 0; --form-padding-block: 8px;
/* border-radius: 16px; */ /* border-radius: 16px; */
padding: var(--form-padding-block) var(--form-padding-inline); padding: var(--form-padding-block) var(--form-padding-inline);
background-color: var(--bg-blur-color); background-color: var(--bg-blur-color);
@ -207,7 +204,7 @@
left: -100vw !important; left: -100vw !important;
} }
#compose-container .toolbar-button select { #compose-container .toolbar-button select {
background-color: inherit; background-color: transparent;
border: 0; border: 0;
padding: 0 0 0 8px; padding: 0 0 0 8px;
margin: 0; margin: 0;
@ -215,8 +212,8 @@
line-height: 1em; line-height: 1em;
} }
#compose-container .toolbar-button:not(.show-field) select { #compose-container .toolbar-button:not(.show-field) select {
inset-inline-end: 0; right: 0;
inset-inline-start: auto !important; left: auto !important;
} }
#compose-container #compose-container
.toolbar-button:not(:disabled):is( .toolbar-button:not(:disabled):is(
@ -297,28 +294,19 @@
height: 2.2em; height: 2.2em;
} }
#compose-container .text-expander-menu li:is(:hover, :focus, [aria-selected]) { #compose-container .text-expander-menu li:is(:hover, :focus, [aria-selected]) {
background-color: var(--link-bg-color); color: var(--bg-color);
background-color: var(--link-color);
}
#compose-container
.text-expander-menu:hover
li[aria-selected]:not(:hover, :focus) {
color: var(--text-color); color: var(--text-color);
} background-color: var(--bg-color);
#compose-container .text-expander-menu li[aria-selected] {
box-shadow: inset 4px 0 0 0 var(--button-bg-color);
:dir(rtl) & {
box-shadow: inset -4px 0 0 0 var(--button-bg-color);
}
}
#compose-container .text-expander-menu li[data-more] {
&:not(:hover, :focus, [aria-selected]) {
color: var(--text-insignificant-color);
background-color: var(--bg-faded-color);
}
font-size: 0.8em;
justify-content: center;
} }
#compose-container .form-visibility-direct { #compose-container .form-visibility-direct {
--yellow-stripes: repeating-linear-gradient( --yellow-stripes: repeating-linear-gradient(
135deg, -45deg,
var(--reply-to-faded-color), var(--reply-to-faded-color),
var(--reply-to-faded-color) 10px, var(--reply-to-faded-color) 10px,
var(--reply-to-faded-color) 10px, var(--reply-to-faded-color) 10px,
@ -342,21 +330,6 @@
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: stretch; align-items: stretch;
.media-error {
padding: 2px;
color: var(--orange-fg-color);
background-color: transparent;
border: 1.5px dashed transparent;
line-height: 1;
border-radius: 4px;
display: flex;
&:is(:hover, :focus) {
background-color: var(--bg-color);
border-color: var(--orange-fg-color);
}
}
} }
#compose-container .media-preview { #compose-container .media-preview {
flex-shrink: 0; flex-shrink: 0;
@ -496,14 +469,14 @@
display: flex; display: flex;
gap: 4px; gap: 4px;
align-items: center; align-items: center;
border-inline-start: 1px solid var(--outline-color); border-left: 1px solid var(--outline-color);
padding-inline-start: 8px; padding-left: 8px;
} }
#compose-container .expires-in { #compose-container .expires-in {
flex-grow: 1; flex-grow: 1;
border-inline-start: 1px solid var(--outline-color); border-left: 1px solid var(--outline-color);
padding-inline-start: 8px; padding-left: 8px;
display: flex; display: flex;
gap: 4px; gap: 4px;
flex-wrap: wrap; flex-wrap: wrap;
@ -523,9 +496,8 @@
} }
} }
#compose-container button[type='submit'] { @media (min-width: 480px) {
border-radius: 8px; #compose-container button[type='submit'] {
@media (min-width: 480px) {
padding-inline: 24px; padding-inline: 24px;
} }
} }
@ -618,194 +590,44 @@
} */ } */
} }
#mention-sheet {
height: 50vh;
.accounts-list {
--list-gap: 1px;
list-style: none;
margin: 0;
padding: 8px 0;
display: flex;
flex-direction: column;
row-gap: var(--list-gap);
&.loading {
opacity: 0.5;
}
li {
display: flex;
flex-grow: 1;
/* align-items: center; */
margin: 0 -8px;
padding: 8px;
gap: 8px;
position: relative;
justify-content: space-between;
border-radius: 8px;
/* align-items: center; */
&:hover {
background-image: linear-gradient(
var(--to-forward),
transparent 75%,
var(--link-bg-color)
);
}
&.selected {
background-image: linear-gradient(
var(--to-forward),
var(--bg-faded-color) 75%,
var(--link-bg-color)
);
}
&:before {
content: '';
display: block;
border-top: var(--hairline-width) solid var(--divider-color);
position: absolute;
bottom: 0;
inset-inline-start: 58px;
inset-inline-end: 0;
}
&:has(+ li:is(.selected, :hover)):before,
&:is(.selected, :hover):before {
opacity: 0;
}
> button {
border-radius: 4px;
&:hover {
outline: 2px solid var(--button-bg-blur-color);
}
}
}
}
}
#custom-emojis-sheet { #custom-emojis-sheet {
max-height: 50vh; max-height: 50vh;
max-height: 50dvh; max-height: 50dvh;
}
header { #custom-emojis-sheet main {
.loader-container { mask-image: none;
margin: 0; }
} #custom-emojis-sheet .custom-emojis-list .section-header {
font-size: 80%;
form { text-transform: uppercase;
margin: 8px 0 0; color: var(--text-insignificant-color);
padding: 8px 0 4px;
input { position: sticky;
width: 100%; top: 0;
min-width: 0; background-color: var(--bg-blur-color);
} backdrop-filter: blur(1px);
} }
} #custom-emojis-sheet .custom-emojis-list section {
display: flex;
main { flex-wrap: wrap;
mask-image: none; }
min-height: 40vh; #custom-emojis-sheet .custom-emojis-list button {
padding-bottom: 88px; border-radius: 8px;
} background-image: radial-gradient(
closest-side,
.custom-emojis-matches { var(--img-bg-color),
margin: 0; transparent
padding: 0; );
list-style: none; }
display: flex; #custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) {
flex-wrap: wrap; filter: none;
} background-color: var(--bg-faded-color);
}
.custom-emojis-list { #custom-emojis-sheet .custom-emojis-list button img {
.section-header { transition: transform 0.1s ease-out;
font-size: 80%; }
text-transform: uppercase; #custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) img {
color: var(--text-insignificant-color); transform: scale(1.5);
padding: 8px 0 4px;
position: sticky;
top: 0;
background-color: var(--bg-color);
z-index: 1;
}
section {
display: flex;
flex-wrap: wrap;
}
button {
color: var(--text-color);
border-radius: 8px;
background-image: radial-gradient(
closest-side,
var(--img-bg-color),
transparent
);
text-shadow: 0 1px 0 var(--bg-color);
position: relative;
min-width: 44px;
min-height: 44px;
font-variant-numeric: slashed-zero;
font-feature-settings: 'ss01';
&[data-title]:after {
max-width: 50vw;
pointer-events: none;
position: absolute;
content: attr(data-title);
left: 50%;
top: 0;
background-color: var(--bg-color);
padding: 2px 4px;
border-radius: 4px;
font-size: 12px;
border: 1px solid var(--text-color);
transform: translate(-50%, -110%);
opacity: 0;
transition: opacity 0.1s ease-out 0.1s;
font-family: var(--monospace-font);
line-height: 1;
}
&.edge-left[data-title]:after {
left: 0;
transform: translate(0, -110%);
}
&.edge-right[data-title]:after {
left: 100%;
transform: translate(-100%, -110%);
}
&:is(:hover, :focus) {
z-index: 1;
filter: none;
background-color: var(--bg-faded-color);
&[data-title]:after {
opacity: 1;
}
}
img {
transition: transform 0.1s ease-out;
}
&:is(:hover, :focus) img {
transform: scale(2);
}
&.edge-left img {
transform-origin: left center;
}
&.edge-right img {
transform-origin: right center;
}
code {
font-size: 0.8em;
}
}
}
} }
.compose-field-container { .compose-field-container {
@ -901,165 +723,3 @@
} }
} }
} }
@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(
var(--to-forward),
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;
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,19 +0,0 @@
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

@ -27,7 +27,7 @@ button.draft-item {
background-color: var(--bg-color); background-color: var(--bg-color);
color: var(--text-color); color: var(--text-color);
border: 1px solid var(--link-faded-color); border: 1px solid var(--link-faded-color);
text-align: start; text-align: left;
padding: 0; padding: 0;
} }
button.draft-item:is(:hover, :focus) { button.draft-item:is(:hover, :focus) {

View file

@ -1,7 +1,5 @@
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;
@ -14,7 +12,21 @@ 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 <CustomEmoji staticUrl={staticUrl} alt={word} url={url} />; return (
<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,39 +1,4 @@
#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);
}
&:is(a) {
pointer-events: auto;
display: block;
text-decoration: none;
color: inherit;
&:hover {
border-color: var(--outline-hover-color);
}
> * {
pointer-events: none;
}
}
}
.accounts-list { .accounts-list {
--list-gap: 16px; --list-gap: 16px;
list-style: none; list-style: none;
@ -62,13 +27,13 @@
border-top: var(--hairline-width) solid var(--divider-color); border-top: var(--hairline-width) solid var(--divider-color);
position: absolute; position: absolute;
bottom: calc(-1 * var(--list-gap) / 2); bottom: calc(-1 * var(--list-gap) / 2);
inset-inline-start: 40px; left: 40px;
inset-inline-end: 0; right: 0;
} }
&:has(.reactions-block):before { &:has(.reactions-block):before {
/* avatar + reactions + gap */ /* avatar + reactions + gap */
inset-inline-start: calc(40px + 16px + 8px); left: calc(40px + 16px + 8px);
} }
} }

View file

@ -11,16 +11,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 Link from './link';
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 = () => {},
blankCopy = 'Nothing to show',
}) { }) {
const { masto, instance: currentInstance } = api(); const { masto, instance: currentInstance } = api();
const isCurrentInstance = instance ? instance === currentInstance : true; const isCurrentInstance = instance ? instance === currentInstance : true;
@ -133,8 +129,6 @@ 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}>
@ -144,14 +138,6 @@ export default function GenericAccounts({
<h2>{heading || 'Accounts'}</h2> <h2>{heading || 'Accounts'}</h2>
</header> </header>
<main> <main>
{post && (
<Link
to={`/${instance || currentInstance}/s/${post.id}`}
class="post-preview"
>
<Status status={post} size="s" readOnly />
</Link>
)}
{accounts.length > 0 ? ( {accounts.length > 0 ? (
<> <>
<ul class="accounts-list"> <ul class="accounts-list">
@ -222,7 +208,7 @@ export default function GenericAccounts({
) : uiState === 'error' ? ( ) : uiState === 'error' ? (
<p class="ui-state">Error loading accounts</p> <p class="ui-state">Error loading accounts</p>
) : ( ) : (
<p class="ui-state insignificant">{blankCopy}</p> <p class="ui-state insignificant">Nothing to show</p>
)} )}
</main> </main>
</div> </div>

View file

@ -53,14 +53,9 @@ function Icon({
return null; return null;
} }
let rotate, let rotate, flip;
flip,
rtl = false;
if (Array.isArray(iconBlock)) { if (Array.isArray(iconBlock)) {
[iconBlock, rotate, flip] = iconBlock; [iconBlock, rotate, flip] = iconBlock;
} else if (typeof iconBlock === 'object') {
({ rotate, flip, rtl } = iconBlock);
iconBlock = iconBlock.module;
} }
const [iconData, setIconData] = useState(ICONDATA[icon]); const [iconData, setIconData] = useState(ICONDATA[icon]);
@ -77,14 +72,13 @@ function Icon({
return ( return (
<span <span
class={`icon ${className} ${rtl ? 'rtl-flip' : ''}`} class={`icon ${className}`}
title={title || alt} title={title || alt}
style={{ style={{
width: `${iconSize}px`, width: `${iconSize}px`,
height: `${iconSize}px`, height: `${iconSize}px`,
...style, ...style,
}} }}
data-icon={icon}
> >
{iconData && ( {iconData && (
// <svg // <svg

View file

@ -1,29 +0,0 @@
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
const IntersectionView = ({ children, root = null, fallback = null }) => {
const ref = useRef();
const [show, setShow] = useState(false);
useLayoutEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
setShow(true);
observer.unobserve(ref.current);
}
},
{
root,
rootMargin: `${screen.height}px`,
},
);
if (ref.current) observer.observe(ref.current);
return () => {
if (ref.current) observer.unobserve(ref.current);
};
}, []);
return show ? children : <div ref={ref}>{fallback}</div>;
};
export default IntersectionView;

View file

@ -1,36 +0,0 @@
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

@ -1,59 +0,0 @@
/*
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;
const shazamIDs = {};
export default function LazyShazam({ id, children }) {
const containerRef = useRef();
const hasID = !!shazamIDs[id];
const [visible, setVisible] = useState(false);
const [visibleStart, setVisibleStart] = useState(hasID || false);
const { ref } = useInView({
root: null,
rootMargin: `-${TOP}px 0px 0px 0px`,
trackVisibility: true,
delay: 1000,
onChange: (inView) => {
if (inView) {
setVisible(true);
if (id) shazamIDs[id] = 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 (id) shazamIDs[id] = 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

@ -22,13 +22,15 @@ const Link = forwardRef((props, ref) => {
// Handle encodeURIComponent of searchParams values // Handle encodeURIComponent of searchParams values
if (!!hash && hash !== '/' && hash.includes('?')) { if (!!hash && hash !== '/' && hash.includes('?')) {
const parsedHash = URL.parse(hash, location.origin); // Fake base URL try {
if (parsedHash?.searchParams?.size) { const parsedHash = new URL(hash, location.origin); // Fake base URL
const searchParamsStr = Array.from(parsedHash.searchParams.entries()) if (parsedHash.searchParams.size) {
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`) const searchParamsStr = Array.from(parsedHash.searchParams.entries())
.join('&'); .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
hash = parsedHash.pathname + '?' + searchParamsStr; .join('&');
} hash = parsedHash.pathname + '?' + searchParamsStr;
}
} catch (e) {}
} }
const isActive = hash === to || decodeURIComponent(hash) === to; const isActive = hash === to || decodeURIComponent(hash) === to;

View file

@ -6,7 +6,7 @@
overflow-x: auto; overflow-x: auto;
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
mask-image: linear-gradient( mask-image: linear-gradient(
var(--to-forward), to right,
transparent, transparent,
black 16px, black 16px,
black calc(100% - 16px), black calc(100% - 16px),
@ -20,9 +20,6 @@
width: 95vw; width: 95vw;
max-width: calc(320px * 3.3); max-width: calc(320px * 3.3);
transform: translateX(calc(-50% + var(--main-width) / 2)); transform: translateX(calc(-50% + var(--main-width) / 2));
&:dir(rtl) {
transform: translateX(calc(50% - var(--main-width) / 2));
}
} }
} }
@ -41,16 +38,12 @@
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
position: absolute; position: absolute;
top: 8px; top: 8px;
inset-inline-start: 0; left: 0;
transform-origin: top left; transform-origin: top left;
transform: rotate(-90deg) translateX(-100%); transform: rotate(-90deg) translateX(-100%);
&:dir(rtl) {
transform-origin: top right;
transform: rotate(90deg) translateX(100%);
}
user-select: none; user-select: none;
background-image: linear-gradient( background-image: linear-gradient(
var(--to-backward), to left,
var(--text-color), var(--text-color),
var(--link-color) var(--link-color)
); );
@ -102,29 +95,6 @@
filter: brightness(0.8); filter: brightness(0.8);
} }
figure {
transition: 1s ease-out;
transition-property: opacity, mix-blend-mode;
}
&.inactive:not(:active, :hover) {
figure {
transition-duration: 0.3s;
opacity: 0.5;
mix-blend-mode: luminosity;
}
}
&.active {
border-color: var(--accent-color, var(--link-light-color));
height: 100%;
max-height: 100%;
+ button[disabled] {
display: none;
}
}
article { article {
width: 100%; width: 100%;
display: flex; display: flex;

View file

@ -1,7 +1,6 @@
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';
@ -76,14 +75,6 @@ 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');
@ -155,9 +146,6 @@ 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

@ -10,15 +10,14 @@ import {
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { oklab2rgb, rgb2oklab } from '../utils/color-utils'; import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
import isRTL from '../utils/is-rtl';
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
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 Media from './media'; import Media from './media';
import MenuLink from './menu-link';
import Menu2 from './menu2'; import Menu2 from './menu2';
import MenuLink from './menu-link';
const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env; const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env;
@ -55,7 +54,7 @@ function MediaModal({
const differentStatusID = prevStatusID.current !== statusID; const differentStatusID = prevStatusID.current !== statusID;
if (differentStatusID) prevStatusID.current = statusID; if (differentStatusID) prevStatusID.current = statusID;
carouselRef.current.scrollTo({ carouselRef.current.scrollTo({
left: scrollLeft * (isRTL() ? -1 : 1), left: scrollLeft,
behavior: differentStatusID ? 'auto' : 'smooth', behavior: differentStatusID ? 'auto' : 'smooth',
}); });
carouselRef.current.focus(); carouselRef.current.focus();
@ -92,7 +91,7 @@ function MediaModal({
useEffect(() => { useEffect(() => {
let handleScroll = () => { let handleScroll = () => {
const { clientWidth, scrollLeft } = carouselRef.current; const { clientWidth, scrollLeft } = carouselRef.current;
const index = Math.round(Math.abs(scrollLeft) / clientWidth); const index = Math.round(scrollLeft / clientWidth);
setCurrentIndex(index); setCurrentIndex(index);
}; };
if (carouselRef.current) { if (carouselRef.current) {
@ -179,7 +178,7 @@ function MediaModal({
? { ? {
backgroundAttachment: 'local', backgroundAttachment: 'local',
backgroundImage: `linear-gradient( backgroundImage: `linear-gradient(
to ${isRTL() ? 'left' : 'right'}, ${mediaAccentGradient})`, to right, ${mediaAccentGradient})`,
} }
: {} : {}
} }
@ -258,8 +257,7 @@ function MediaModal({
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
carouselRef.current.scrollTo({ carouselRef.current.scrollTo({
left: left: carouselRef.current.clientWidth * i,
carouselRef.current.clientWidth * i * (isRTL() ? -1 : 1),
behavior: 'smooth', behavior: 'smooth',
}); });
carouselRef.current.focus(); carouselRef.current.focus();
@ -370,10 +368,7 @@ function MediaModal({
e.stopPropagation(); e.stopPropagation();
carouselRef.current.focus(); carouselRef.current.focus();
carouselRef.current.scrollTo({ carouselRef.current.scrollTo({
left: left: carouselRef.current.clientWidth * (currentIndex - 1),
carouselRef.current.clientWidth *
(currentIndex - 1) *
(isRTL() ? -1 : 1),
behavior: 'smooth', behavior: 'smooth',
}); });
}} }}
@ -389,10 +384,7 @@ function MediaModal({
e.stopPropagation(); e.stopPropagation();
carouselRef.current.focus(); carouselRef.current.focus();
carouselRef.current.scrollTo({ carouselRef.current.scrollTo({
left: left: carouselRef.current.clientWidth * (currentIndex + 1),
carouselRef.current.clientWidth *
(currentIndex + 1) *
(isRTL() ? -1 : 1),
behavior: 'smooth', behavior: 'smooth',
}); });
}} }}

View file

@ -23,7 +23,7 @@
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
top: 0; top: 0;
inset-inline-start: 0; left: 0;
z-index: 1; z-index: 1;
background-color: var(--bg-blur-color); background-color: var(--bg-blur-color);
margin: 8px; margin: 8px;

View file

@ -8,7 +8,6 @@ import FilterContext from '../utils/filter-context';
import { isFiltered } from '../utils/filters'; import { isFiltered } from '../utils/filters';
import states, { statusKey } from '../utils/states'; import states, { statusKey } from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
import Media from './media'; import Media from './media';
@ -89,7 +88,7 @@ function MediaPost({
}; };
const currentAccount = useMemo(() => { const currentAccount = useMemo(() => {
return getCurrentAccountID(); return store.session.get('currentAccount');
}, []); }, []);
const isSelf = useMemo(() => { const isSelf = useMemo(() => {
return currentAccount && currentAccount === accountId; return currentAccount && currentAccount === accountId;

View file

@ -1,6 +1,5 @@
import { getBlurHashAverageColor } from 'fast-blurhash'; import { getBlurHashAverageColor } from 'fast-blurhash';
import { Fragment } from 'preact'; import { Fragment } from 'preact';
import { memo } from 'preact/compat';
import { import {
useCallback, useCallback,
useLayoutEffect, useLayoutEffect,
@ -10,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
@ -75,7 +74,7 @@ function Media({
altIndex, altIndex,
onClick = () => {}, onClick = () => {},
}) { }) {
let { const {
blurhash, blurhash,
description, description,
meta, meta,
@ -85,27 +84,15 @@ 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 const width = showOriginal ? original?.width : small?.width;
? original?.width const height = showOriginal ? original?.height : small?.height;
: 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 hasDimensions = width && height; const orientation = width >= height ? 'landscape' : 'portrait';
const orientation = hasDimensions
? width > height
? 'landscape'
: 'portrait'
: null;
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null; const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
@ -146,8 +133,7 @@ function Media({
enabled: pinchZoomEnabled, enabled: pinchZoomEnabled,
draggableUnZoomed: false, draggableUnZoomed: false,
inertiaFriction: 0.9, inertiaFriction: 0.9,
tapZoomFactor: 2, doubleTapZoomOutOnMaxScale: true,
doubleTapToggleZoom: true,
containerProps: { containerProps: {
className: 'media-zoom', className: 'media-zoom',
style: { style: {
@ -167,7 +153,7 @@ function Media({
[to], [to],
); );
const remoteMediaURLObj = remoteMediaURL ? getURLObj(remoteMediaURL) : null; const remoteMediaURLObj = remoteMediaURL ? new URL(remoteMediaURL) : null;
const isVideoMaybe = const isVideoMaybe =
type === 'unknown' && type === 'unknown' &&
remoteMediaURLObj && remoteMediaURLObj &&
@ -249,8 +235,6 @@ function Media({
); );
}; };
const [hasNaturalAspectRatio, setHasNaturalAspectRatio] = useState(undefined);
if (isImage) { if (isImage) {
// Note: type: unknown might not have width/height // Note: type: unknown might not have width/height
quickPinchZoomProps.containerProps.style.display = 'inherit'; quickPinchZoomProps.containerProps.style.display = 'inherit';
@ -275,8 +259,7 @@ function Media({
class={`media media-image ${className}`} class={`media media-image ${className}`}
onClick={onClick} onClick={onClick}
data-orientation={orientation} data-orientation={orientation}
data-has-alt={!showInlineDesc || undefined} data-has-alt={!showInlineDesc}
data-has-natural-aspect-ratio={hasNaturalAspectRatio || undefined}
style={ style={
showOriginal showOriginal
? { ? {
@ -307,11 +290,7 @@ function Media({
}} }}
onError={(e) => { onError={(e) => {
const { src } = e.target; const { src } = e.target;
if ( if (src === mediaURL && mediaURL !== remoteMediaURL) {
src === mediaURL &&
remoteMediaURL &&
mediaURL !== remoteMediaURL
) {
e.target.src = remoteMediaURL; e.target.src = remoteMediaURL;
} }
}} }}
@ -342,48 +321,6 @@ 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;
const $media = e.target.closest('.media');
if (!hasDimensions && $media) {
const { naturalWidth, naturalHeight } = e.target;
$media.dataset.orientation =
naturalWidth > naturalHeight ? 'landscape' : 'portrait';
$media.style.setProperty('--width', `${naturalWidth}px`);
$media.style.setProperty('--height', `${naturalHeight}px`);
$media.style.aspectRatio = `${naturalWidth}/${naturalHeight}`;
}
// Check natural aspect ratio vs display aspect ratio
if ($media) {
const {
clientWidth,
clientHeight,
naturalWidth,
naturalHeight,
} = e.target;
if (
clientWidth &&
clientHeight &&
naturalWidth &&
naturalHeight
) {
const minDimension = 88;
if (
naturalWidth < minDimension ||
naturalHeight < minDimension
) {
$media.dataset.hasSmallDimension = true;
} else {
const displayNaturalHeight =
(naturalHeight * clientWidth) / naturalWidth;
const almostSimilarHeight =
Math.abs(displayNaturalHeight - clientHeight) < 5;
if (almostSimilarHeight) {
setHasNaturalAspectRatio(true);
}
}
}
}
}} }}
onError={(e) => { onError={(e) => {
const { src } = e.target; const { src } = e.target;
@ -401,7 +338,6 @@ 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
@ -411,42 +347,27 @@ function Media({
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF; const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
const showProgress = original.duration > 5; const showProgress = original.duration > 5;
// This string is only for autoplay + muted to work on Mobile Safari
const gifHTML = `
<video
src="${url}"
poster="${previewUrl}"
width="${width}"
height="${height}"
data-orientation="${orientation}"
preload="auto"
autoplay
muted
playsinline
${loopable ? 'loop' : ''}
ondblclick="this.paused ? this.play() : this.pause()"
${
showProgress
? "ontimeupdate=\"this.closest('.media-gif') && this.closest('.media-gif').style.setProperty('--progress', `${~~((this.currentTime / this.duration) * 100)}%`)\""
: ''
}
></video>
`;
const videoHTML = ` const videoHTML = `
<video <video
src="${url}" src="${url}"
poster="${previewUrl}" poster="${previewUrl}"
width="${width}" width="${width}"
height="${height}" height="${height}"
data-orientation="${orientation}" data-orientation="${orientation}"
preload="auto" preload="auto"
autoplay autoplay
playsinline muted="${isGIF}"
${loopable ? 'loop' : ''} ${isGIF ? '' : 'controls'}
controls playsinline
></video> loop="${loopable}"
`; ${isGIF ? 'ondblclick="this.paused ? this.play() : this.pause()"' : ''}
${
isGIF && showProgress
? "ontimeupdate=\"this.closest('.media-gif') && this.closest('.media-gif').style.setProperty('--progress', `${~~((this.currentTime / this.duration) * 100)}%`)\""
: ''
}
></video>
`;
return ( return (
<Figure> <Figure>
@ -458,10 +379,8 @@ function Media({
data-formatted-duration={ data-formatted-duration={
!showOriginal ? formattedDuration : undefined !showOriginal ? formattedDuration : undefined
} }
data-label={ data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : undefined data-has-alt={!showInlineDesc}
}
data-has-alt={!showInlineDesc || undefined}
// style={{ // style={{
// backgroundColor: // backgroundColor:
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, // rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
@ -510,21 +429,16 @@ function Media({
<div <div
ref={mediaRef} ref={mediaRef}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: gifHTML, __html: videoHTML,
}} }}
/> />
</QuickPinchZoom> </QuickPinchZoom>
) : isGIF ? (
<div
class="video-container"
dangerouslySetInnerHTML={{
__html: gifHTML,
}}
/>
) : ( ) : (
<div <div
class="video-container" class="video-container"
dangerouslySetInnerHTML={{ __html: videoHTML }} dangerouslySetInnerHTML={{
__html: videoHTML,
}}
/> />
) )
) : isGIF ? ( ) : isGIF ? (
@ -559,61 +473,14 @@ function Media({
/> />
) : ( ) : (
<> <>
{previewUrl ? ( <img
<img src={previewUrl}
src={previewUrl} alt={showInlineDesc ? '' : description}
alt={showInlineDesc ? '' : description} width={width}
width={width} height={height}
height={height} data-orientation={orientation}
data-orientation={orientation} loading="lazy"
loading="lazy" />
decoding="async"
onLoad={(e) => {
if (!hasDimensions) {
const $media = e.target.closest('.media');
if ($media) {
const { naturalHeight, naturalWidth } = e.target;
$media.dataset.orientation =
naturalWidth > naturalHeight
? 'landscape'
: 'portrait';
$media.style.setProperty(
'--width',
`${naturalWidth}px`,
);
$media.style.setProperty(
'--height',
`${naturalHeight}px`,
);
$media.style.aspectRatio = `${naturalWidth}/${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>
@ -634,12 +501,12 @@ function Media({
data-formatted-duration={ data-formatted-duration={
!showOriginal ? formattedDuration : undefined !showOriginal ? formattedDuration : undefined
} }
data-has-alt={!showInlineDesc || undefined} data-has-alt={!showInlineDesc}
onClick={onClick} onClick={onClick}
style={!showOriginal && mediaStyles} style={!showOriginal && mediaStyles}
> >
{showOriginal ? ( {showOriginal ? (
<audio src={remoteUrl || url} preload="none" controls autoPlay /> <audio src={remoteUrl || url} preload="none" controls autoplay />
) : previewUrl ? ( ) : previewUrl ? (
<img <img
src={previewUrl} src={previewUrl}
@ -672,19 +539,4 @@ function Media({
} }
} }
function getURLObj(url) { export default Media;
// Fake base URL if url doesn't have https:// prefix
return URL.parse(url, location.origin);
}
export default memo(Media, (oldProps, newProps) => {
const oldMedia = oldProps.media || {};
const newMedia = newProps.media || {};
return (
oldMedia?.id === newMedia?.id &&
oldMedia.url === newMedia.url &&
oldProps.to === newProps.to &&
oldProps.class === newProps.class
);
});

View file

@ -1,8 +1,8 @@
import { MenuItem } from '@szhsin/react-menu'; import { Menu, MenuItem, SubMenu } 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,
@ -10,7 +10,6 @@ function MenuConfirm({
confirmLabel, confirmLabel,
menuItemClassName, menuItemClassName,
menuFooter, menuFooter,
menuExtras,
...props ...props
}) { }) {
const { children, onClick, ...restProps } = props; const { children, onClick, ...restProps } = props;
@ -23,9 +22,11 @@ function MenuConfirm({
} }
return children; return children;
} }
const Parent = subMenu ? SubMenu2 : Menu2; const Parent = subMenu ? SubMenu : Menu2;
const menuRef = useRef();
return ( return (
<Parent <Parent
instanceRef={menuRef}
openTrigger="clickOnly" openTrigger="clickOnly"
direction="bottom" direction="bottom"
overflow="auto" overflow="auto"
@ -35,11 +36,23 @@ 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}
</MenuItem> </MenuItem>
{menuExtras}
{menuFooter} {menuFooter}
</Parent> </Parent>
); );

View file

@ -1,33 +1,21 @@
import { Menu } from '@szhsin/react-menu'; import { Menu } from '@szhsin/react-menu';
import { useWindowSize } from '@uidotdev/usehooks';
import { useRef } from 'preact/hooks'; import { useRef } from 'preact/hooks';
import isRTL from '../utils/is-rtl';
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
import useWindowSize from '../utils/useWindowSize';
// It's like Menu but with sensible defaults, bug fixes and improvements. // It's like Menu but with sensible defaults, bug fixes and improvements.
function Menu2(props) { function Menu2(props) {
const { containerProps, instanceRef: _instanceRef, align } = props; const { containerProps, instanceRef: _instanceRef } = props;
const size = useWindowSize(); const size = useWindowSize();
const instanceRef = _instanceRef?.current ? _instanceRef : useRef(); const instanceRef = _instanceRef?.current ? _instanceRef : useRef();
// Values: start, end, center
// Note: don't mess with 'center'
const rtlAlign = isRTL()
? align === 'end'
? 'start'
: align === 'start'
? 'end'
: align
: align;
return ( return (
<Menu <Menu
boundingBoxPadding={safeBoundingBoxPadding()} boundingBoxPadding={safeBoundingBoxPadding()}
repositionFlag={`${size.width}x${size.height}`} repositionFlag={`${size.width}x${size.height}`}
unmountOnClose unmountOnClose
{...props} {...props}
align={rtlAlign}
instanceRef={instanceRef} instanceRef={instanceRef}
containerProps={{ containerProps={{
onClick: (e) => { onClick: (e) => {

View file

@ -1,7 +1,7 @@
#modal-container > div { #modal-container > div {
position: fixed; position: fixed;
top: 0; top: 0;
inset-inline-end: 0; right: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
z-index: 1000; z-index: 1000;
@ -10,65 +10,17 @@
align-items: center; align-items: center;
background-color: var(--backdrop-color); background-color: var(--backdrop-color);
animation: appear 0.5s var(--timing-function) both; animation: appear 0.5s var(--timing-function) both;
transition: all 0.5s var(--timing-function);
&.solid { &.solid {
background-color: var(--backdrop-solid-color); background-color: var(--backdrop-solid-color);
} }
--compose-button-dimension: 56px;
--compose-button-dimension-half: calc(var(--compose-button-dimension) / 2);
--compose-button-dimension-margin: 16px;
&.min {
/* Minimized */
pointer-events: none;
user-select: none;
overflow: hidden;
transform: scale(0);
--end: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-right)
);
:dir(rtl) & {
--end: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-left)
);
}
--bottom: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-bottom)
);
--origin-end: calc(
100% - var(--compose-button-dimension-half) - var(--end)
);
:dir(rtl) & {
--origin-end: calc(var(--compose-button-dimension-half) + var(--end));
}
--origin-bottom: calc(
100% - var(--compose-button-dimension-half) - var(--bottom)
);
transform-origin: var(--origin-end) var(--origin-bottom);
}
.sheet { .sheet {
transition: transform 0.3s var(--timing-function); transition: transform 0.3s var(--timing-function);
transform-origin: 80% 80%; transform-origin: center bottom;
} }
&:has(~ div) .sheet { &:has(~ div) .sheet {
transform: scale(0.975); transform: scale(0.975);
} }
} }
@media (max-width: calc(40em - 1px)) {
#app[data-shortcuts-view-mode='tab-menu-bar'] ~ #modal-container > div.min {
border: 2px solid red;
--bottom: calc(
var(--compose-button-dimension-margin) + env(safe-area-inset-bottom) +
52px
);
}
}

View file

@ -8,7 +8,7 @@ import useCloseWatcher from '../utils/useCloseWatcher';
const $modalContainer = document.getElementById('modal-container'); const $modalContainer = document.getElementById('modal-container');
function Modal({ children, onClose, onClick, class: className, minimized }) { function Modal({ children, onClose, onClick, class: className }) {
if (!children) return null; if (!children) return null;
const modalRef = useRef(); const modalRef = useRef();
@ -41,33 +41,6 @@ function Modal({ children, onClose, onClick, class: className, minimized }) {
); );
useCloseWatcher(onClose, [onClose]); useCloseWatcher(onClose, [onClose]);
useEffect(() => {
const $deckContainers = document.querySelectorAll('.deck-container');
if (minimized) {
// Similar to focusDeck in focus-deck.jsx
// Focus last deck
const page = $deckContainers[$deckContainers.length - 1]; // last one
if (page && page.tabIndex === -1) {
page.focus();
}
} else {
if (children) {
$deckContainers.forEach(($deckContainer) => {
$deckContainer.setAttribute('inert', '');
});
} else {
$deckContainers.forEach(($deckContainer) => {
$deckContainer.removeAttribute('inert');
});
}
}
return () => {
$deckContainers.forEach(($deckContainer) => {
$deckContainer.removeAttribute('inert');
});
};
}, [children, minimized]);
const Modal = ( const Modal = (
<div <div
ref={(node) => { ref={(node) => {
@ -81,8 +54,7 @@ function Modal({ children, onClose, onClick, class: className, minimized }) {
onClose?.(e); onClose?.(e);
} }
}} }}
tabIndex={minimized ? 0 : '-1'} tabIndex="-1"
inert={minimized}
onFocus={(e) => { onFocus={(e) => {
try { try {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {

View file

@ -1,4 +1,3 @@
import { useEffect } from 'preact/hooks';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { subscribe, useSnapshot } from 'valtio'; import { subscribe, useSnapshot } from 'valtio';
@ -9,7 +8,7 @@ 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 ComposeSuspense, { preload } from './compose-suspense'; 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';
@ -33,18 +32,11 @@ export default function Modals() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
useEffect(() => {
setTimeout(preload, 1000);
}, []);
return ( return (
<> <>
{!!snapStates.showCompose && ( {!!snapStates.showCompose && (
<Modal <Modal class="solid">
class={`solid ${snapStates.composerState.minimized ? 'min' : ''}`} <Compose
minimized={!!snapStates.composerState.minimized}
>
<ComposeSuspense
replyToStatus={ replyToStatus={
typeof snapStates.showCompose !== 'boolean' typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus ? snapStates.showCompose.replyToStatus
@ -187,9 +179,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)}
blankCopy={snapStates.showGenericAccounts.blankCopy}
/> />
</Modal> </Modal>
)} )}

View file

@ -5,14 +5,9 @@
unicode-bidi: isolate; unicode-bidi: isolate;
b { b {
font-weight: 600; font-weight: 500;
unicode-bidi: isolate; unicode-bidi: isolate;
} }
i {
font-variant-numeric: slashed-zero;
font-feature-settings: 'ss01';
}
} }
.name-text.show-acct { .name-text.show-acct {
display: inline-block; display: inline-block;

View file

@ -2,7 +2,6 @@ import './name-text.css';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { api } from '../utils/api';
import states from '../utils/states'; import states from '../utils/states';
import Avatar from './avatar'; import Avatar from './avatar';
@ -21,60 +20,42 @@ function NameText({
external, external,
onClick, onClick,
}) { }) {
const { const { acct, avatar, avatarStatic, id, url, displayName, emojis, bot } =
acct, account;
avatar, let { username } = account;
avatarStatic,
id,
url,
displayName,
emojis,
bot,
username,
} = account;
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct]; const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
if (!instance) instance = api().instance;
const trimmedUsername = username.toLowerCase().trim(); const trimmedUsername = username.toLowerCase().trim();
const trimmedDisplayName = (displayName || '').toLowerCase().trim(); const trimmedDisplayName = (displayName || '').toLowerCase().trim();
const shortenedDisplayName = trimmedDisplayName const shortenedDisplayName = trimmedDisplayName
.replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1 .replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
.replace(/\s+/g, ''); // E.g. "My name" === "myname" .replace(/\s+/g, ''); // E.g. "My name" === "myname"
const shortenedAlphaNumericDisplayName = shortenedDisplayName.replace( const shortenedAlphaNumericDisplayName = shortenedDisplayName.replace(
/[^a-z0-9@\.]/gi, /[^a-z0-9]/gi,
'', '',
); // Remove non-alphanumeric characters ); // Remove non-alphanumeric characters
const hideUsername = if (
(!short && !short &&
(trimmedUsername === trimmedDisplayName || (trimmedUsername === trimmedDisplayName ||
trimmedUsername === shortenedDisplayName || trimmedUsername === shortenedDisplayName ||
trimmedUsername === shortenedAlphaNumericDisplayName || trimmedUsername === shortenedAlphaNumericDisplayName ||
nameCollator.compare(trimmedUsername, shortenedDisplayName) === 0)) || nameCollator.compare(trimmedUsername, shortenedDisplayName) === 0)
shortenedAlphaNumericDisplayName === acct.toLowerCase(); ) {
username = null;
}
return ( return (
<a <a
class={`name-text ${showAcct ? 'show-acct' : ''} ${short ? 'short' : ''}`} class={`name-text ${showAcct ? 'show-acct' : ''} ${short ? 'short' : ''}`}
href={url} href={url}
target={external ? '_blank' : null} target={external ? '_blank' : null}
title={ title={`${displayName ? `${displayName} ` : ''}@${acct}`}
displayName
? `${displayName} (${acct2 ? '' : '@'}${acct})`
: `${acct2 ? '' : '@'}${acct}`
}
onClick={(e) => { onClick={(e) => {
if (external) return; if (external) return;
if (e.shiftKey) return; // Save link? 🤷
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (onClick) return onClick(e); if (onClick) return onClick(e);
if (e.metaKey || e.ctrlKey || e.shiftKey || e.which === 2) {
const internalURL = `#/${instance}/a/${id}`;
window.open(internalURL, '_blank');
return;
}
states.showAccount = { states.showAccount = {
account, account,
instance, instance,
@ -88,13 +69,13 @@ function NameText({
)} )}
{displayName && !short ? ( {displayName && !short ? (
<> <>
<b dir="auto"> <b>
<EmojiText text={displayName} emojis={emojis} /> <EmojiText text={displayName} emojis={emojis} />
</b> </b>
{!showAcct && !hideUsername ? ( {!showAcct && username ? (
<> <>
{' '} {' '}
<i class="bidi-isolate">@{username}</i> <i>@{username}</i>
</> </>
) : ' '} ) : ' '}
<i class="instance">{acct2}</i> <i class="instance">{acct2}</i>
@ -107,10 +88,9 @@ function NameText({
{showAcct && ( {showAcct && (
<> <>
<br /> <br />
<i class="bidi-isolate"> <i>
{acct2 ? '' : '@'} @{acct1}
{acct1} <span class="ib">{acct2}</span>
{!!acct2 && <span class="ib">{acct2}</span>}
</i> </i>
</> </>
)} )}

View file

@ -35,15 +35,11 @@
} }
.nav-menu section:last-child { .nav-menu section:last-child {
background-image: linear-gradient( background-image: linear-gradient(
var(--to-forward), to right,
var(--divider-color) 1px, var(--divider-color) 1px,
transparent 1px transparent 1px
), ),
linear-gradient( linear-gradient(to bottom left, var(--bg-blur-color), transparent),
to bottom var(--backward),
var(--bg-blur-color),
transparent
),
url(../assets/phanpy-bg.svg); url(../assets/phanpy-bg.svg);
background-repeat: no-repeat; background-repeat: no-repeat;
/* background-size: auto, auto, 200%; */ /* background-size: auto, auto, 200%; */
@ -53,8 +49,8 @@
position: sticky; position: sticky;
top: 0; top: 0;
animation: phanpying 0.2s ease-in-out both; animation: phanpying 0.2s ease-in-out both;
border-start-end-radius: inherit; border-top-right-radius: inherit;
border-end-end-radius: inherit; border-bottom-right-radius: inherit;
margin-bottom: 0; margin-bottom: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -92,7 +88,3 @@
.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,35 +1,40 @@
import './nav-menu.css'; import './nav-menu.css';
import { ControlledMenu, FocusableItem, MenuDivider, MenuItem } from '@szhsin/react-menu'; import {
ControlledMenu,
MenuDivider,
MenuItem,
SubMenu,
} from '@szhsin/react-menu';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, 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';
import { getCurrentAccountID } from '../utils/store-utils';
import supports from '../utils/supports';
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';
import { accountsIsDtth, gtsDtthSettings } from '../utils/dtth'; import { accountsIsDtth, gtsDtthSettings } from '../utils/dtth';
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, moreThanOneAccount] = useMemo(() => { const [currentAccount, setCurrentAccount] = useState();
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
useEffect(() => {
const accounts = store.local.getJSON('accounts') || []; const accounts = store.local.getJSON('accounts') || [];
const acc = const acc = accounts.find(
accounts.find((account) => account.info.id === getCurrentAccountID()) || (account) => account.info.id === store.session.get('currentAccount'),
accounts[0]; );
return [acc, accounts.length > 1]; if (acc) setCurrentAccount(acc);
setMoreThanOneAccount(accounts.length > 1);
}, []); }, []);
// Home = Following // Home = Following
@ -85,15 +90,6 @@ function NavMenu(props) {
return results; return results;
} }
const supportsLists = supports('@mastodon/lists');
const [lists, setLists] = useState([]);
useEffect(() => {
if (!supportsLists) return;
if (menuState === 'open') {
getLists().then(setLists);
}
}, [menuState === 'open']);
const buttonClickTS = useRef(); const buttonClickTS = useRef();
return ( return (
<> <>
@ -102,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' : ''
} ${menuState === 'open' ? 'active' : ''}`} } ${open ? 'active' : ''}`}
style={{ position: 'relative' }} style={{ position: 'relative' }}
onClick={() => { onClick={() => {
buttonClickTS.current = Date.now(); buttonClickTS.current = Date.now();
@ -190,11 +186,9 @@ function NavMenu(props) {
<Icon icon="history2" size="l" /> <Icon icon="history2" size="l" />
<span>Catch-up</span> <span>Catch-up</span>
</MenuLink> </MenuLink>
{supports('@mastodon/mentions') && ( <MenuLink to="/mentions">
<MenuLink to="/mentions"> <Icon icon="at" size="l" /> <span>Mentions</span>
<Icon icon="at" size="l" /> <span>Mentions</span> </MenuLink>
</MenuLink>
)}
<MenuLink to="/notifications"> <MenuLink to="/notifications">
<Icon icon="notification" size="l" /> <span>Notifications</span> <Icon icon="notification" size="l" /> <span>Notifications</span>
{snapStates.notificationsShowNew && ( {snapStates.notificationsShowNew && (
@ -211,49 +205,16 @@ function NavMenu(props) {
</MenuLink> </MenuLink>
)} )}
{currentAccount && accountsIsDtth(currentAccount) && {currentAccount && accountsIsDtth(currentAccount) &&
<FocusableItem title="Takes you to DTTHDon settings"> <MenuLink to={gtsDtthSettings} target='_blank' title="Takes you to DTTHDon settings">
<a href={gtsDtthSettings} target='_blank'><Icon icon="user-setting" size="l" /> <span>User Settings&hellip;</span></a> <Icon icon="user-setting" size="l" /> <span>User Settings&hellip;</span>
</FocusableItem>} </MenuLink>}
{lists?.length > 0 ? ( <MenuLink to="/l">
<SubMenu2 <Icon icon="list" size="l" /> <span>Lists</span>
menuClassName="nav-submenu" </MenuLink>
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>
) : (
supportsLists && (
<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>
<SubMenu2 <SubMenu
menuClassName="nav-submenu"
overflow="auto" overflow="auto"
gap={-8} gap={-8}
label={ label={
@ -267,17 +228,11 @@ 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="/fh"> <MenuLink to="/ft">
<Icon icon="hashtag" size="l" />{' '} <Icon icon="hashtag" size="l" />{' '}
<span>Followed Hashtags</span> <span>Followed Hashtags</span>
</MenuLink> </MenuLink>
<MenuDivider /> <MenuDivider />
{supports('@mastodon/filters') && (
<MenuLink to="/ft">
<Icon icon="filters" size="l" />
Filters
</MenuLink>
)}
<MenuItem <MenuItem
onClick={() => { onClick={() => {
states.showGenericAccounts = { states.showGenericAccounts = {
@ -303,7 +258,7 @@ function NavMenu(props) {
<Icon icon="block" size="l" /> <Icon icon="block" size="l" />
Blocked users&hellip; Blocked users&hellip;
</MenuItem>{' '} </MenuItem>{' '}
</SubMenu2> </SubMenu>
<MenuDivider /> <MenuDivider />
<MenuItem <MenuItem
onClick={() => { onClick={() => {

View file

@ -2,13 +2,11 @@ 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, { statusKey } from '../utils/states'; import states from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
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';
@ -27,10 +25,6 @@ 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',
moderation_warning: 'alert',
emoji_reaction: 'emoji2',
'pleroma:emoji_reaction': 'emoji2',
}; };
/* /*
@ -46,28 +40,8 @@ poll = A poll you have voted in or created has ended
update = A status you interacted with has been edited update = A status you interacted with has been edited
admin.sign_up = Someone signed up (optionally sent to admins) 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
severed_relationships = Severed relationships
moderation_warning = Moderation warning
*/ */
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.',
@ -89,50 +63,9 @@ 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>.
</>
),
moderation_warning: <b>Moderation warning</b>,
emoji_reaction: emojiText,
'pleroma:emoji_reaction': emojiText,
}; };
// account_suspension, domain_block, user_domain_block const AVATARS_LIMIT = 50;
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 MODERATION_WARNING_TEXT = {
none: 'Your account has received a moderation warning.',
disable: 'Your account has been disabled.',
mark_statuses_as_sensitive:
'Some of your posts have been marked as sensitive.',
delete_statuses: 'Some of your posts have been deleted.',
sensitive: 'Your posts will be marked as sensitive from now on.',
silence: 'Your account has been limited.',
suspend: 'Your account has been suspended.',
};
const AVATARS_LIMIT = 30;
function Notification({ function Notification({
notification, notification,
@ -140,28 +73,14 @@ function Notification({
isStatic, isStatic,
disableContextMenu, disableContextMenu,
}) { }) {
const { const { id, status, account, report, _accounts, _statuses } = notification;
id,
status,
account,
report,
event,
moderation_warning,
// Client-side grouped notification
_ids,
_accounts,
_statuses,
// Server-side grouped notification
sampleAccounts,
notificationsCount,
} = 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
const actualStatus = status?.reblog || status; const actualStatus = status?.reblog || status;
const actualStatusID = actualStatus?.id; const actualStatusID = actualStatus?.id;
const currentAccount = getCurrentAccountID(); const currentAccount = store.session.get('currentAccount');
const isSelf = currentAccount === account?.id; const isSelf = currentAccount === account?.id;
const isVoted = status?.poll?.voted; const isVoted = status?.poll?.voted;
const isReplyToOthers = const isReplyToOthers =
@ -172,14 +91,12 @@ function Notification({
let favsCount = 0; let favsCount = 0;
let reblogsCount = 0; let reblogsCount = 0;
if (type === 'favourite+reblog') { if (type === 'favourite+reblog') {
if (_accounts) { for (const account of _accounts) {
for (const account of _accounts) { if (account._types?.includes('favourite')) {
if (account._types?.includes('favourite')) { favsCount++;
favsCount++; }
} if (account._types?.includes('reblog')) {
if (account._types?.includes('reblog')) { reblogsCount++;
reblogsCount++;
}
} }
} }
if (!reblogsCount && favsCount) type = 'favourite'; if (!reblogsCount && favsCount) type = 'favourite';
@ -211,30 +128,13 @@ function Notification({
if (typeof text === 'function') { if (typeof text === 'function') {
const count = _statuses?.length || _accounts?.length; const count = _statuses?.length || _accounts?.length;
if (type === 'admin.report') { if (count) {
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);
} }
} }
@ -259,7 +159,6 @@ 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),
}; };
}; };
@ -268,7 +167,7 @@ function Notification({
return ( return (
<div <div
class={`notification notification-${type}`} class={`notification notification-${type}`}
data-notification-id={_ids || id} data-notification-id={id}
tabIndex="0" tabIndex="0"
> >
<div <div
@ -292,7 +191,7 @@ function Notification({
{type !== 'mention' && ( {type !== 'mention' && (
<> <>
<p> <p>
{!/poll|update|severed_relationships/i.test(type) && ( {!/poll|update/i.test(type) && (
<> <>
{_accounts?.length > 1 ? ( {_accounts?.length > 1 ? (
<> <>
@ -303,21 +202,10 @@ function Notification({
people people
</b>{' '} </b>{' '}
</> </>
) : notificationsCount > 1 ? (
<>
<b>
<span title={notificationsCount}>
{shortenNumber(notificationsCount)}
</span>{' '}
people
</b>{' '}
</>
) : ( ) : (
account && ( <>
<> <NameText account={account} showAvatar />{' '}
<NameText account={account} showAvatar />{' '} </>
</>
)
)} )}
</> </>
)} )}
@ -336,37 +224,6 @@ 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>
)}
{type === 'moderation_warning' && !!moderation_warning && (
<div>
{MODERATION_WARNING_TEXT[moderation_warning.action]}
<br />
<a
href={`/disputes/strikes/${moderation_warning.id}`}
target="_blank"
rel="noopener noreferrer"
>
Learn more <Icon icon="external" size="s" />
</a>
.
</div>
)}
</> </>
)} )}
{_accounts?.length > 1 && ( {_accounts?.length > 1 && (
@ -390,7 +247,11 @@ function Notification({
? 'xxl' ? 'xxl'
: _accounts.length < 20 : _accounts.length < 20
? 'xl' ? 'xl'
: 'l' : _accounts.length < 30
? 'l'
: _accounts.length < 40
? 'm'
: 's' // My god, this person is popular!
} }
key={account.id} key={account.id}
alt={`${account.displayName} @${account.acct}`} alt={`${account.displayName} @${account.acct}`}
@ -421,54 +282,6 @@ function Notification({
</button> </button>
</p> </p>
)} )}
{!_accounts?.length && sampleAccounts?.length > 1 && (
<p class="avatars-stack">
{sampleAccounts.map((account) => (
<Fragment key={account.id}>
<a
key={account.id}
href={account.url}
rel="noopener noreferrer"
class="account-avatar-stack"
onClick={(e) => {
e.preventDefault();
states.showAccount = account;
}}
>
<Avatar
url={account.avatarStatic}
size="xxl"
key={account.id}
alt={`${account.displayName} @${account.acct}`}
squircle={account?.bot}
/>
{/* {type === 'favourite+reblog' && (
<div class="account-sub-icons">
{account._types.map((type) => (
<Icon
icon={NOTIFICATION_ICONS[type]}
size="s"
class={`${type}-icon`}
/>
))}
</div>
)} */}
</a>{' '}
</Fragment>
))}
{notificationsCount > sampleAccounts.length && (
<Link
to={
instance ? `/${instance}/s/${status.id}` : `/s/${status.id}`
}
class="button small plain centered"
>
+{notificationsCount - sampleAccounts.length}
<Icon icon="chevron-right" />
</Link>
)}
</p>
)}
{_statuses?.length > 1 && ( {_statuses?.length > 1 && (
<ul class="notification-group-statuses"> <ul class="notification-group-statuses">
{_statuses.map((status) => ( {_statuses.map((status) => (

View file

@ -187,6 +187,9 @@ export default function Poll({
type="button" type="button"
class="plain small" class="plain small"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
style={{
marginLeft: -8,
}}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
setUIState('loading'); setUIState('loading');

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 { useEffect, useMemo, useReducer } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
dayjs.extend(dayjsTwitter); dayjs.extend(dayjsTwitter);
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
@ -18,51 +18,22 @@ 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, dt, title] = useMemo(() => { const dateStr = useMemo(() => {
if (!date.isValid()) return ['' + datetime, '', ''];
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()) {
str = date.twitter(); return date.twitter();
} else { } else {
str = dtf.format(date.toDate()); return dtf.format(date.toDate());
} }
} }
if (!str) str = date.fromNow(); return date.fromNow();
return [str, date.toISOString(), date.format('LLLL')]; }, [date, format]);
}, [date, format, renderCount]); const dt = useMemo(() => date.toISOString(), [date]);
const title = useMemo(() => date.format('LLLL'), [date]);
useEffect(() => {
if (!date.isValid()) return;
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

@ -26,8 +26,6 @@
background-color: var(--bg-blur-color); background-color: var(--bg-blur-color);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
padding: 16px; padding: 16px;
padding: calc(var(--sai-top, 0) + 16px) calc(var(--sai-right, 0) + 16px)
16px calc(var(--sai-left, 0) + 16px);
display: flex; display: flex;
gap: 8px; gap: 8px;
justify-content: space-between; justify-content: space-between;
@ -43,8 +41,6 @@
main { main {
padding: 0 16px 16px; padding: 0 16px 16px;
padding: 0 calc(var(--sai-right, 0) + 16px)
calc(var(--sai-bottom, 0) + 16px) calc(var(--sai-left, 0) + 16px);
/* display: flex; /* display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; */ gap: 16px; */
@ -92,7 +88,7 @@
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
position: absolute; position: absolute;
inset-inline-end: 32px; right: 32px;
margin-top: -48px; margin-top: -48px;
animation: rubber-stamp 0.3s ease-in both; animation: rubber-stamp 0.3s ease-in both;
position: absolute; position: absolute;
@ -148,7 +144,7 @@
} }
.report-rules { .report-rules {
margin-inline-start: 1.75em; margin-left: 1.75em;
} }
} }

View file

@ -232,8 +232,8 @@ function ReportModal({ account, post, onClose }) {
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
/> />
</section> </section>
{!!domain && domain !== currentDomain && ( <section>
<section> {domain !== currentDomain && (
<p> <p>
<label> <label>
<input <input
@ -247,8 +247,8 @@ function ReportModal({ account, post, onClose }) {
</span> </span>
</label> </label>
</p> </p>
</section> )}
)} </section>
<footer> <footer>
<button type="submit" disabled={uiState === 'loading'}> <button type="submit" disabled={uiState === 'loading'}>
Send Report Send Report

View file

@ -73,7 +73,7 @@ const SearchForm = forwardRef((props, ref) => {
autocomplete="off" autocomplete="off"
autocorrect="off" autocorrect="off"
autocapitalize="off" autocapitalize="off"
spellCheck="false" spellcheck="false"
onSearch={(e) => { onSearch={(e) => {
if (!e.target.value) { if (!e.target.value) {
setSearchParams({}); setSearchParams({});
@ -273,7 +273,6 @@ const SearchForm = forwardRef((props, ref) => {
class={`search-popover-item ${i === 0 ? 'focus' : ''}`} class={`search-popover-item ${i === 0 ? 'focus' : ''}`}
// hidden={hidden} // hidden={hidden}
onClick={(e) => { onClick={(e) => {
console.log('onClick', e);
props?.onSubmit?.(e); props?.onSubmit?.(e);
}} }}
> >

View file

@ -18,8 +18,8 @@
counter-increment: index; counter-increment: index;
display: inline-block; display: inline-block;
width: 1.2em; width: 1.2em;
text-align: end; text-align: right;
margin-inline-end: 8px; margin-right: 8px;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
font-size: 90%; font-size: 90%;
flex-shrink: 0; flex-shrink: 0;
@ -55,12 +55,12 @@
justify-content: center; justify-content: center;
} }
#shortcuts-settings-container .shortcuts-view-mode label:first-child { #shortcuts-settings-container .shortcuts-view-mode label:first-child {
border-start-start-radius: 16px; border-top-left-radius: 16px;
border-end-start-radius: 16px; border-bottom-left-radius: 16px;
} }
#shortcuts-settings-container .shortcuts-view-mode label:last-child { #shortcuts-settings-container .shortcuts-view-mode label:last-child {
border-start-end-radius: 16px; border-top-right-radius: 16px;
border-end-end-radius: 16px; border-bottom-right-radius: 16px;
} }
#shortcuts-settings-container .shortcuts-view-mode label img { #shortcuts-settings-container .shortcuts-view-mode label img {
max-height: 64px; max-height: 64px;
@ -114,7 +114,7 @@
} }
#shortcut-settings-form label > span:first-child { #shortcut-settings-form label > span:first-child {
flex-basis: 5em; flex-basis: 5em;
text-align: end; text-align: right;
} }
#shortcut-settings-form :is(input[type='text'], select) { #shortcut-settings-form :is(input[type='text'], select) {
flex-grow: 1; flex-grow: 1;
@ -185,8 +185,8 @@
counter-increment: index; counter-increment: index;
display: inline-block; display: inline-block;
width: 1.2em; width: 1.2em;
text-align: end; text-align: right;
margin-inline-end: 8px; margin-right: 8px;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
font-size: 90%; font-size: 90%;
flex-shrink: 0; flex-shrink: 0;

View file

@ -14,12 +14,10 @@ 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';
import store from '../utils/store'; import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
import AsyncText from './AsyncText'; import AsyncText from './AsyncText';
import Icon from './icon'; import Icon from './icon';
@ -45,7 +43,7 @@ const TYPES = [
const TYPE_TEXT = { const TYPE_TEXT = {
following: 'Home / Following', following: 'Home / Following',
notifications: 'Notifications', notifications: 'Notifications',
list: 'Lists', list: 'List',
public: 'Public (Local / Federated)', public: 'Public (Local / Federated)',
search: 'Search', search: 'Search',
'account-statuses': 'Account', 'account-statuses': 'Account',
@ -60,7 +58,6 @@ const TYPE_PARAMS = {
{ {
text: 'List ID', text: 'List ID',
name: 'id', name: 'id',
notRequired: true,
}, },
], ],
public: [ public: [
@ -125,6 +122,10 @@ 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;
@ -149,11 +150,10 @@ export const SHORTCUTS_META = {
icon: 'notification', icon: 'notification',
}, },
list: { list: {
id: ({ id }) => (id ? 'list' : 'lists'), id: 'list',
title: ({ id }) => (id ? getListTitle(id) : 'Lists'), title: fetchListTitle,
path: ({ id }) => (id ? `/l/${id}` : '/l'), path: ({ id }) => `/l/${id}`,
icon: 'list', icon: 'list',
excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []),
}, },
public: { public: {
id: 'public', id: 'public',
@ -496,8 +496,18 @@ 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.',
}; };
@ -522,7 +532,8 @@ function ShortcutForm({
if (currentType !== 'list') return; if (currentType !== 'list') return;
try { try {
setUIState('loading'); setUIState('loading');
const lists = await getLists(); const lists = await fetchLists();
lists.sort((a, b) => a.title.localeCompare(b.title));
setLists(lists); setLists(lists);
setUIState('default'); setUIState('default');
} catch (e) { } catch (e) {
@ -612,7 +623,6 @@ function ShortcutForm({
}} }}
defaultValue={editMode ? shortcut.type : undefined} defaultValue={editMode ? shortcut.type : undefined}
name="type" name="type"
dir="auto"
> >
<option></option> <option></option>
{TYPES.map((type) => ( {TYPES.map((type) => (
@ -633,9 +643,7 @@ function ShortcutForm({
required={!notRequired} required={!notRequired}
disabled={disabled || uiState === 'loading'} disabled={disabled || uiState === 'loading'}
defaultValue={editMode ? shortcut.id : undefined} defaultValue={editMode ? shortcut.id : undefined}
dir="auto"
> >
<option value=""></option>
{lists.map((list) => ( {lists.map((list) => (
<option value={list.id}>{list.title}</option> <option value={list.id}>{list.title}</option>
))} ))}
@ -663,9 +671,8 @@ function ShortcutForm({
} }
autocorrect="off" autocorrect="off"
autocapitalize="off" autocapitalize="off"
spellCheck={false} spellcheck={false}
pattern={pattern} pattern={pattern}
dir="auto"
/> />
{currentType === 'hashtag' && {currentType === 'hashtag' &&
followedHashtags.length > 0 && ( followedHashtags.length > 0 && (
@ -783,7 +790,6 @@ function ImportExport({ shortcuts, onClose }) {
onInput={(e) => { onInput={(e) => {
setImportShortcutStr(e.target.value); setImportShortcutStr(e.target.value);
}} }}
dir="auto"
/> />
{states.settings.shortcutSettingsCloudImportExport && ( {states.settings.shortcutSettingsCloudImportExport && (
<button <button
@ -792,7 +798,7 @@ function ImportExport({ shortcuts, onClose }) {
disabled={importUIState === 'cloud-downloading'} disabled={importUIState === 'cloud-downloading'}
onClick={async () => { onClick={async () => {
setImportUIState('cloud-downloading'); setImportUIState('cloud-downloading');
const currentAccount = getCurrentAccountID(); const currentAccount = store.session.get('currentAccount');
showToast( showToast(
'Downloading saved shortcuts from instance server…', 'Downloading saved shortcuts from instance server…',
); );
@ -1000,7 +1006,6 @@ function ImportExport({ shortcuts, onClose }) {
showToast('Unable to copy shortcuts'); showToast('Unable to copy shortcuts');
} }
}} }}
dir="auto"
/> />
</p> </p>
<p> <p>
@ -1049,7 +1054,7 @@ function ImportExport({ shortcuts, onClose }) {
disabled={importUIState === 'cloud-uploading'} disabled={importUIState === 'cloud-uploading'}
onClick={async () => { onClick={async () => {
setImportUIState('cloud-uploading'); setImportUIState('cloud-uploading');
const currentAccount = getCurrentAccountID(); const currentAccount = store.session.get('currentAccount');
try { try {
const relationships = const relationships =
await masto.v1.accounts.relationships.fetch({ await masto.v1.accounts.relationships.fetch({
@ -1060,16 +1065,16 @@ function ImportExport({ shortcuts, onClose }) {
const { note = '' } = relationship; const { note = '' } = relationship;
// const newNote = `${note}\n\n\n$<phanpy-shortcuts-settings>{shortcutsStr}</phanpy-shortcuts-settings>`; // const newNote = `${note}\n\n\n$<phanpy-shortcuts-settings>{shortcutsStr}</phanpy-shortcuts-settings>`;
let newNote = ''; let newNote = '';
const settingsJSON = JSON.stringify({
v: '1', // version
dt: Date.now(), // datetime stamp
data: shortcutsStr, // shortcuts settings string
});
if ( if (
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test( /<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test(
note, note,
) )
) { ) {
const settingsJSON = JSON.stringify({
v: '1', // version
dt: Date.now(), // datetime stamp
data: shortcutsStr, // shortcuts settings string
});
newNote = note.replace( newNote = note.replace(
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/, /<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/,
`<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`, `<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`,

View file

@ -2,8 +2,8 @@
position: fixed; position: fixed;
bottom: 16px; bottom: 16px;
bottom: max(16px, env(safe-area-inset-bottom)); bottom: max(16px, env(safe-area-inset-bottom));
inset-inline-start: 16px; left: 16px;
inset-inline-start: max(16px, env(safe-area-inset-left)); left: max(16px, env(safe-area-inset-left));
padding: 16px; padding: 16px;
background-color: var(--bg-faded-blur-color); background-color: var(--bg-faded-blur-color);
z-index: 101; z-index: 101;
@ -34,9 +34,9 @@
@media (min-width: calc(40em + 56px + 8px)) { @media (min-width: calc(40em + 56px + 8px)) {
#shortcuts-button { #shortcuts-button {
inset-inline-end: 16px; right: 16px;
inset-inline-end: max(16px, env(safe-area-inset-right)); right: max(16px, env(safe-area-inset-right));
inset-inline-start: auto; left: auto;
top: 16px; top: 16px;
top: max(16px, env(safe-area-inset-top)); top: max(16px, env(safe-area-inset-top));
bottom: auto; bottom: auto;

View file

@ -1,23 +1,21 @@
import './shortcuts.css'; import './shortcuts.css';
import { MenuDivider } from '@szhsin/react-menu'; import { Menu, MenuItem } from '@szhsin/react-menu';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useRef, useState } from 'preact/hooks'; import { useMemo, useRef } 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';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
import MenuLink from './menu-link';
import Menu2 from './menu2'; import Menu2 from './menu2';
import SubMenu2 from './submenu2'; import MenuLink from './menu-link';
function Shortcuts() { function Shortcuts() {
const { instance } = api(); const { instance } = api();
@ -36,48 +34,47 @@ function Shortcuts() {
const menuRef = useRef(); const menuRef = useRef();
const hasLists = useRef(false); const formattedShortcuts = useMemo(
const formattedShortcuts = shortcuts () =>
.map((pin, i) => { shortcuts
const { type, ...data } = pin; .map((pin, i) => {
if (!SHORTCUTS_META[type]) return null; const { type, ...data } = pin;
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type]; if (!SHORTCUTS_META[type]) return null;
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);
} }
if (id === 'lists') { return {
hasLists.current = true; id,
} path,
title,
return { subtitle,
id, icon,
path, };
title, })
subtitle, .filter(Boolean),
icon, [shortcuts],
}; );
})
.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) => {
@ -91,8 +88,6 @@ 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' ? (
@ -152,11 +147,6 @@ 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"
@ -181,35 +171,6 @@ 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}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,25 +0,0 @@
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,11 +1,5 @@
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
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';
@ -13,10 +7,8 @@ import { useSnapshot } from 'valtio';
import FilterContext from '../utils/filter-context'; import FilterContext from '../utils/filter-context';
import { filteredItems, isFiltered } from '../utils/filters'; import { filteredItems, isFiltered } from '../utils/filters';
import isRTL from '../utils/is-rtl';
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';
@ -56,11 +48,10 @@ function Timeline({
filterContext, filterContext,
showFollowedTags, showFollowedTags,
showReplyParent, showReplyParent,
clearWhenRefresh,
}) { }) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [uiState, setUIState] = useState('start'); const [uiState, setUIState] = useState('default');
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);
@ -68,20 +59,15 @@ 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 loadItemsTS = useRef(0); // Ensures only one loadItems at a time
const loadItems = useDebouncedCallback( const loadItems = useDebouncedCallback(
(firstLoad) => { (firstLoad) => {
setShowNew(false); setShowNew(false);
// if (uiState === 'loading') return; if (uiState === 'loading') return;
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
const ts = (loadItemsTS.current = Date.now());
let { done, value } = await fetchItems(firstLoad); let { done, value } = await fetchItems(firstLoad);
if (ts !== loadItemsTS.current) return;
if (Array.isArray(value)) { if (Array.isArray(value)) {
// Avoid grouping for pinned posts // Avoid grouping for pinned posts
const [pinnedPosts, otherPosts] = value.reduce( const [pinnedPosts, otherPosts] = value.reduce(
@ -125,10 +111,10 @@ function Timeline({
} }
})(); })();
}, },
1_000, 1500,
{ {
leading: true, leading: true,
// trailing: false, trailing: false,
}, },
); );
@ -214,8 +200,8 @@ function Timeline({
const oRef = useHotkeys(['enter', 'o'], () => { const oRef = useHotkeys(['enter', 'o'], () => {
// open active status // open active status
const activeItem = document.activeElement; const activeItem = document.activeElement.closest(itemsSelector);
if (activeItem?.matches(itemsSelector)) { if (activeItem) {
activeItem.click(); activeItem.click();
} }
}); });
@ -223,13 +209,17 @@ function Timeline({
const showNewPostsIndicator = const showNewPostsIndicator =
items.length > 0 && uiState !== 'loading' && showNew; items.length > 0 && uiState !== 'loading' && showNew;
const handleLoadNewPosts = useCallback(() => { const handleLoadNewPosts = useCallback(() => {
if (showNewPostsIndicator) loadItems(true); loadItems(true);
scrollableRef.current?.scrollTo({ scrollableRef.current?.scrollTo({
top: 0, top: 0,
behavior: 'smooth', behavior: 'smooth',
}); });
}, [loadItems, showNewPostsIndicator]); }, [loadItems]);
const dotRef = useHotkeys('.', handleLoadNewPosts); const dotRef = useHotkeys('.', () => {
if (showNewPostsIndicator) {
handleLoadNewPosts();
}
});
// const { // const {
// scrollDirection, // scrollDirection,
@ -278,18 +268,9 @@ function Timeline({
scrollableRef.current?.scrollTo({ top: 0 }); scrollableRef.current?.scrollTo({ top: 0 });
loadItems(true); loadItems(true);
}, []); }, []);
const firstLoad = useRef(true);
useEffect(() => { useEffect(() => {
if (firstLoad.current) {
firstLoad.current = false;
return;
}
if (clearWhenRefresh && items?.length) {
loadItems.cancel?.();
setItems([]);
}
loadItems(true); loadItems(true);
}, [clearWhenRefresh, refresh]); }, [refresh]);
// useEffect(() => { // useEffect(() => {
// if (reachStart) { // if (reachStart) {
@ -378,28 +359,14 @@ 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"
onClick={(e) => {
// If click on timeline item, unhide header
if (
headerRef.current &&
e.target.closest('.timeline-item, .timeline-item-alt')
) {
setTimeout(() => {
headerRef.current.hidden = false;
}, 250);
}
}}
> >
<div class="timeline-deck deck"> <div class="timeline-deck deck">
<header <header
@ -468,7 +435,6 @@ function Timeline({
view={view} view={view}
showFollowedTags={showFollowedTags} showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent} showReplyParent={showReplyParent}
mediaFirst={mediaFirst}
/> />
))} ))}
{showMore && {showMore &&
@ -480,14 +446,14 @@ function Timeline({
height: '20vh', height: '20vh',
}} }}
> >
<Status skeleton mediaFirst={mediaFirst} /> <Status skeleton />
</li> </li>
<li <li
style={{ style={{
height: '25vh', height: '25vh',
}} }}
> >
<Status skeleton mediaFirst={mediaFirst} /> <Status skeleton />
</li> </li>
</> </>
))} ))}
@ -527,14 +493,13 @@ function Timeline({
/> />
) : ( ) : (
<li key={i}> <li key={i}>
<Status skeleton mediaFirst={mediaFirst} /> <Status skeleton />
</li> </li>
), ),
)} )}
</ul> </ul>
) : ( ) : (
uiState !== 'error' && uiState !== 'error' && <p class="ui-state">{emptyText}</p>
uiState !== 'start' && <p class="ui-state">{emptyText}</p>
)} )}
{uiState === 'error' && ( {uiState === 'error' && (
<p class="ui-state"> <p class="ui-state">
@ -562,7 +527,6 @@ 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;
@ -571,18 +535,16 @@ const TimelineItem = memo(
const url = instance const url = instance
? `/${instance}/s/${actualStatusID}` ? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`; : `/s/${actualStatusID}`;
let title = '';
if (type === 'boosts') {
title = `${items.length} Boosts`;
} else if (type === 'pinned') {
title = 'Pinned posts';
}
const isCarousel = type === 'boosts' || type === 'pinned';
if (items) { if (items) {
let fItems = filteredItems(items, filterContext); const fItems = filteredItems(items, filterContext);
let title = '';
if (type === 'boosts') {
title = `${fItems.length} Boosts`;
} else if (type === 'pinned') {
title = 'Pinned posts';
}
const isCarousel = type === 'boosts' || type === 'pinned';
if (isCarousel) { if (isCarousel) {
const filteredItemsIDs = new Set();
// Here, we don't hide filtered posts, but we sort them last // Here, we don't hide filtered posts, but we sort them last
fItems.sort((a, b) => { fItems.sort((a, b) => {
// if (a._filtered && !b._filtered) { // if (a._filtered && !b._filtered) {
@ -593,8 +555,6 @@ const TimelineItem = memo(
// } // }
const aFiltered = isFiltered(a.filtered, filterContext); const aFiltered = isFiltered(a.filtered, filterContext);
const bFiltered = isFiltered(b.filtered, filterContext); const bFiltered = isFiltered(b.filtered, filterContext);
if (aFiltered) filteredItemsIDs.add(a.id);
if (bFiltered) filteredItemsIDs.add(b.id);
if (aFiltered && !bFiltered) { if (aFiltered && !bFiltered) {
return 1; return 1;
} }
@ -603,69 +563,11 @@ const TimelineItem = memo(
} }
return 0; return 0;
}); });
if (filteredItemsIDs.size >= 2) {
const GROUP_SIZE = 5;
// If 2 or more, group filtered items into one, limit to GROUP_SIZE in a group
const unfiltered = [];
const filtered = [];
fItems.forEach((item) => {
if (filteredItemsIDs.has(item.id)) {
filtered.push(item);
} else {
unfiltered.push(item);
}
});
const filteredItems = [];
for (let i = 0; i < filtered.length; i += GROUP_SIZE) {
filteredItems.push({
_grouped: true,
posts: filtered.slice(i, i + GROUP_SIZE),
});
}
fItems = unfiltered.concat(filteredItems);
}
return ( return (
<li key={`timeline-${statusID}`} class="timeline-item-carousel"> <li key={`timeline-${statusID}`} class="timeline-item-carousel">
<StatusCarousel title={title} class={`${type}-carousel`}> <StatusCarousel title={title} class={`${type}-carousel`}>
{fItems.map((item) => { {fItems.map((item) => {
const { id: statusID, reblog, _pinned, _grouped } = item; const { id: statusID, reblog, _pinned } = item;
if (_grouped) {
return (
<li key={statusID} class="timeline-item-carousel-group">
{item.posts.map((item) => {
const { id: statusID, reblog, _pinned } = item;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
if (_pinned) useItemID = false;
return (
<Link
class="status-carousel-link timeline-item-alt"
to={url}
>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
size="s"
/>
) : (
<Status
status={item}
instance={instance}
size="s"
/>
)}
</Link>
);
})}
</li>
);
}
const actualStatusID = reblog?.id || statusID; const actualStatusID = reblog?.id || statusID;
const url = instance const url = instance
? `/${instance}/s/${actualStatusID}` ? `/${instance}/s/${actualStatusID}`
@ -685,7 +587,6 @@ const TimelineItem = memo(
contentTextWeight contentTextWeight
enableCommentHint enableCommentHint
// allowFilters={allowFilters} // allowFilters={allowFilters}
mediaFirst={mediaFirst}
/> />
) : ( ) : (
<Status <Status
@ -695,7 +596,6 @@ const TimelineItem = memo(
contentTextWeight contentTextWeight
enableCommentHint enableCommentHint
// allowFilters={allowFilters} // allowFilters={allowFilters}
mediaFirst={mediaFirst}
/> />
)} )}
</Link> </Link>
@ -732,11 +632,7 @@ const TimelineItem = memo(
> >
<Link class="status-link timeline-item" to={url}> <Link class="status-link timeline-item" to={url}>
{showCompact ? ( {showCompact ? (
<TimelineStatusCompact <TimelineStatusCompact status={item} instance={instance} />
status={item}
instance={instance}
filterContext={filterContext}
/>
) : useItemID ? ( ) : useItemID ? (
<Status <Status
statusID={statusID} statusID={statusID}
@ -795,7 +691,6 @@ const TimelineItem = memo(
showFollowedTags={showFollowedTags} showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent} showReplyParent={showReplyParent}
// allowFilters={allowFilters} // allowFilters={allowFilters}
mediaFirst={mediaFirst}
/> />
) : ( ) : (
<Status <Status
@ -805,7 +700,6 @@ const TimelineItem = memo(
showFollowedTags={showFollowedTags} showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent} showReplyParent={showReplyParent}
// allowFilters={allowFilters} // allowFilters={allowFilters}
mediaFirst={mediaFirst}
/> />
)} )}
</Link> </Link>
@ -865,11 +759,8 @@ function StatusCarousel({ title, class: className, children }) {
class="small plain2" class="small plain2"
// disabled={reachStart} // disabled={reachStart}
onClick={() => { onClick={() => {
const left =
Math.min(320, carouselRef.current?.offsetWidth) *
(isRTL() ? 1 : -1);
carouselRef.current?.scrollBy({ carouselRef.current?.scrollBy({
left, left: -Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth', behavior: 'smooth',
}); });
}} }}
@ -882,11 +773,8 @@ function StatusCarousel({ title, class: className, children }) {
class="small plain2" class="small plain2"
// disabled={reachEnd} // disabled={reachEnd}
onClick={() => { onClick={() => {
const left =
Math.min(320, carouselRef.current?.offsetWidth) *
(isRTL() ? -1 : 1);
carouselRef.current?.scrollBy({ carouselRef.current?.scrollBy({
left, left: Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth', behavior: 'smooth',
}); });
}} }}
@ -916,12 +804,11 @@ function StatusCarousel({ title, class: className, children }) {
); );
} }
function TimelineStatusCompact({ status, instance, filterContext }) { function TimelineStatusCompact({ status, instance }) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { id, visibility, language } = status; const { id, visibility, language } = status;
const statusPeekText = statusPeek(status); const statusPeekText = statusPeek(status);
const sKey = statusKey(id, instance); const sKey = statusKey(id, instance);
const filterInfo = isFiltered(status.filtered, filterContext);
return ( return (
<article <article
class={`status compact-thread ${ class={`status compact-thread ${
@ -947,24 +834,13 @@ function TimelineStatusCompact({ status, instance, filterContext }) {
lang={language} lang={language}
dir="auto" dir="auto"
> >
{!!filterInfo ? ( {statusPeekText}
<b {status.sensitive && status.spoilerText && (
class="status-filtered-badge badge-meta horizontal"
title={filterInfo?.titlesStr || ''}
>
<span>Filtered</span>: <span>{filterInfo?.titlesStr || ''}</span>
</b>
) : (
<> <>
{statusPeekText} {' '}
{status.sensitive && status.spoilerText && ( <span class="spoiler-badge">
<> <Icon icon="eye-close" size="s" />
{' '} </span>
<span class="spoiler-badge">
<Icon icon="eye-close" size="s" />
</span>
</>
)}
</> </>
)} )}
</div> </div>

View file

@ -35,7 +35,7 @@
border-bottom: 0; border-bottom: 0;
margin-bottom: -1px; margin-bottom: -1px;
background-image: linear-gradient( background-image: linear-gradient(
to top var(--backward), to top left,
var(--bg-color) 50%, var(--bg-color) 50%,
var(--bg-faded-blur-color) var(--bg-faded-blur-color)
); );
@ -44,13 +44,12 @@
.status-translation-block .translated-block { .status-translation-block .translated-block {
border: 1px solid var(--outline-color); border: 1px solid var(--outline-color);
line-height: 1.3; line-height: 1.3;
border-radius: 8px; border-radius: 0 8px 8px 8px;
border-start-start-radius: 0;
margin: 0; margin: 0;
padding: 8px; padding: 8px;
background-color: var(--bg-color); background-color: var(--bg-color);
background-image: linear-gradient( background-image: linear-gradient(
to bottom var(--forward), to bottom right,
var(--bg-color), var(--bg-color),
var(--bg-faded-blur-color) var(--bg-faded-blur-color)
); );

View file

@ -10,7 +10,6 @@ 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;
@ -77,7 +76,6 @@ function TranslationBlock({
onTranslate, onTranslate,
text = '', text = '',
mini, mini,
autoDetected,
}) { }) {
const targetLang = getTranslateTargetLanguage(true); const targetLang = getTranslateTargetLanguage(true);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
@ -144,21 +142,23 @@ function TranslationBlock({
detectedLang !== targetLangText detectedLang !== targetLangText
) { ) {
return ( return (
<LazyShazam> <div class="shazam-container">
<div class="status-translation-block-mini"> <div class="shazam-container-inner">
<Icon <div class="status-translation-block-mini">
icon="translate" <Icon
alt={`Auto-translated from ${sourceLangText}`} icon="translate"
/> alt={`Auto-translated from ${sourceLangText}`}
<output />
lang={targetLang} <output
dir="auto" lang={targetLang}
title={pronunciationContent || ''} dir="auto"
> title={pronunciationContent || ''}
{translatedContent} >
</output> {translatedContent}
</output>
</div>
</div> </div>
</LazyShazam> </div>
); );
} }
return null; return null;
@ -188,9 +188,7 @@ function TranslationBlock({
{uiState === 'loading' {uiState === 'loading'
? 'Translating…' ? 'Translating…'
: sourceLanguage && sourceLangText && !detectedLang : sourceLanguage && sourceLangText && !detectedLang
? autoDetected ? `Translate from ${sourceLangText}`
? `Translate from ${sourceLangText} (auto-detected)`
: `Translate from ${sourceLangText}`
: `Translate`} : `Translate`}
</span> </span>
</button> </button>

View file

@ -1,12 +1,11 @@
import './index.css'; import './index.css';
import './app.css'; import './app.css';
import './polyfills';
import { render } from 'preact'; import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import ComposeSuspense from './components/compose-suspense'; import Compose from './components/compose';
import { initStates } from './utils/states';
import useTitle from './utils/useTitle'; import useTitle from './utils/useTitle';
if (window.opener) { if (window.opener) {
@ -28,10 +27,6 @@ function App() {
: 'Compose', : 'Compose',
); );
useEffect(() => {
initStates();
}, []);
useEffect(() => { useEffect(() => {
if (uiState === 'closed') { if (uiState === 'closed') {
try { try {
@ -62,7 +57,7 @@ function App() {
console.debug('OPEN COMPOSE'); console.debug('OPEN COMPOSE');
return ( return (
<ComposeSuspense <Compose
editStatus={editStatus} editStatus={editStatus}
replyToStatus={replyToStatus} replyToStatus={replyToStatus}
draftStatus={draftStatus} draftStatus={draftStatus}

View file

@ -1,8 +1,4 @@
{ {
"@mastodon/edit-media-attributes": ">=4.1", "@mastodon/edit-media-attributes": ">=4.1",
"@mastodon/list-exclusive": ">=4.2", "@mastodon/list-exclusive": ">=4.2"
"@mastodon/filtered-notifications": "~4.3 || >=4.3",
"@mastodon/fetch-multiple-statuses": "~4.3 || >=4.3",
"@mastodon/trending-link-posts": "~4.3 || >=4.3",
"@mastodon/grouped-notifications": "~4.3 || >=4.3"
} }

View file

@ -16,12 +16,6 @@
--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(
@ -29,18 +23,7 @@
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;
@ -108,19 +91,6 @@
--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;
--forward: right;
--backward: left;
--to-forward: to right;
--to-backward: to left;
&:dir(rtl) {
--forward: left;
--backward: right;
--to-forward: to left;
--to-backward: to right;
}
} }
@media (min-resolution: 2dppx) { @media (min-resolution: 2dppx) {
@ -257,7 +227,7 @@ button[hidden] {
} }
:is(button, .button):not(:disabled, .disabled):is(:hover, :focus) { :is(button, .button):not(:disabled, .disabled):is(:hover, :focus) {
cursor: pointer; cursor: pointer;
filter: brightness(1.05); filter: brightness(1.2);
} }
:is(button, .button):not(:disabled, .disabled):active { :is(button, .button):not(:disabled, .disabled):active {
filter: brightness(0.8); filter: brightness(0.8);
@ -358,7 +328,6 @@ button[hidden] {
} }
input[type='text'], input[type='text'],
input[type='search'],
textarea, textarea,
select { select {
color: var(--text-color); color: var(--text-color);
@ -368,7 +337,6 @@ 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);
@ -384,22 +352,16 @@ textarea:disabled {
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
} }
:is(input[type='text'], input[type='search'], textarea, select).block { :is(input[type='text'], textarea, select).block {
display: block; display: block;
width: 100%; width: 100%;
} }
:is(button, .button).small { button.small {
font-size: 90%; font-size: 90%;
padding: 4px 8px; padding: 4px 8px;
} }
.button.centered {
display: inline-flex;
justify-content: center;
align-items: center;
}
select.plain { select.plain {
border: 0; border: 0;
background-color: transparent; background-color: transparent;
@ -453,11 +415,6 @@ kbd {
display: initial; display: initial;
} }
.bidi-isolate {
direction: initial;
unicode-bidi: isolate;
}
/* KEYFRAMES */ /* KEYFRAMES */
@keyframes appear { @keyframes appear {
@ -569,9 +526,3 @@ kbd {
.shazam-container-horizontal[hidden] { .shazam-container-horizontal[hidden] {
grid-template-columns: 0fr; grid-template-columns: 0fr;
} }
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View file

@ -1,10 +1,10 @@
import './index.css'; import './index.css';
import './cloak-mode.css'; import './cloak-mode.css';
import './polyfills';
// 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';
@ -14,6 +14,19 @@ if (import.meta.env.DEV) {
import('preact/debug'); import('preact/debug');
} }
// AbortSignal.timeout polyfill
// Temporary fix from https://github.com/mo/abortcontroller-polyfill/issues/73#issuecomment-1541180943
// Incorrect implementation, but should be good enough for now
if ('AbortSignal' in window) {
AbortSignal.timeout =
AbortSignal.timeout ||
((duration) => {
const controller = new AbortController();
setTimeout(() => controller.abort(), duration);
return controller.signal;
});
}
render( render(
<HashRouter> <HashRouter>
<App /> <App />

View file

@ -6,7 +6,6 @@ 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';
@ -19,8 +18,8 @@ import Timeline from '../components/timeline';
import { api } from '../utils/api'; import { api } from '../utils/api';
import pmem from '../utils/pmem'; import pmem from '../utils/pmem';
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
import states, { saveStatus } from '../utils/states'; import states from '../utils/states';
import { isMediaFirstInstance } from '../utils/store-utils'; import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const LIMIT = 20; const LIMIT = 20;
@ -68,8 +67,6 @@ function AccountStatuses() {
searchOffsetRef.current = 0; searchOffsetRef.current = 0;
}, allSearchParams); }, allSearchParams);
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
const sameCurrentInstance = useMemo( const sameCurrentInstance = useMemo(
() => instance === currentInstance, () => instance === currentInstance,
[instance, currentInstance], [instance, currentInstance],
@ -153,7 +150,7 @@ function AccountStatuses() {
} }
} }
let results = []; const results = [];
if (firstLoad) { if (firstLoad) {
const { value } = await masto.v1.accounts const { value } = await masto.v1.accounts
.$select(id) .$select(id)
@ -188,32 +185,12 @@ function AccountStatuses() {
limit: LIMIT, limit: LIMIT,
exclude_replies: excludeReplies, exclude_replies: excludeReplies,
exclude_reblogs: excludeBoosts, exclude_reblogs: excludeBoosts,
only_media: media || undefined, only_media: media,
tagged, tagged,
}); });
} }
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) => {
@ -229,12 +206,8 @@ function AccountStatuses() {
const [featuredTags, setFeaturedTags] = useState([]); const [featuredTags, setFeaturedTags] = useState([]);
useTitle( useTitle(
account?.acct account?.acct
? `${ ? `${account?.displayName ? account.displayName + ' ' : ''}@${
account?.displayName account.acct
? `${account.displayName} (${/@/.test(account.acct) ? '' : '@'}${
account.acct
})`
: `${/@/.test(account.acct) ? '' : '@'}${account.acct}`
}${ }${
!excludeReplies !excludeReplies
? ' (+ Replies)' ? ' (+ Replies)'
@ -272,21 +245,17 @@ function AccountStatuses() {
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
// No need, because the whole filter bar is hidden try {
// TODO: Revisit this const featuredTags = await masto.v1.accounts
if (!mediaFirst) { .$select(id)
try { .featuredTags.list();
const featuredTags = await masto.v1.accounts console.log({ featuredTags });
.$select(id) setFeaturedTags(featuredTags);
.featuredTags.list(); } catch (e) {
console.log({ featuredTags }); console.error(e);
setFeaturedTags(featuredTags);
} catch (e) {
console.error(e);
}
} }
})(); })();
}, [id, mediaFirst]); }, [id]);
const { displayName, acct, emojis } = account || {}; const { displayName, acct, emojis } = account || {};
@ -305,126 +274,95 @@ function AccountStatuses() {
authenticated={authenticated} authenticated={authenticated}
standalone standalone
/> />
{!mediaFirst && ( <div
<div class="filter-bar"
class="filter-bar" ref={filterBarRef}
ref={filterBarRef} style={{
style={{ position: 'relative',
position: 'relative', }}
>
{filtered ? (
<Link
to={`/${instance}/a/${id}`}
class="insignificant filter-clear"
title="Clear filters"
key="clear-filters"
>
<Icon icon="x" size="l" />
</Link>
) : (
<Icon icon="filter" class="insignificant" size="l" />
)}
<Link
to={`/${instance}/a/${id}${excludeReplies ? '?replies=1' : ''}`}
onClick={() => {
if (excludeReplies) {
showToast('Showing post with replies');
}
}} }}
class={excludeReplies ? '' : 'is-active'}
> >
{filtered ? ( + Replies
<Link </Link>
to={`/${instance}/a/${id}`} <Link
class="insignificant filter-clear" to={`/${instance}/a/${id}${excludeBoosts ? '' : '?boosts=0'}`}
title="Clear filters" onClick={() => {
key="clear-filters" if (!excludeBoosts) {
> showToast('Showing posts without boosts');
<Icon icon="x" size="l" /> }
</Link> }}
) : ( class={!excludeBoosts ? '' : 'is-active'}
<Icon icon="filter" class="insignificant" size="l" /> >
)} - Boosts
</Link>
<Link
to={`/${instance}/a/${id}${media ? '' : '?media=1'}`}
onClick={() => {
if (!media) {
showToast('Showing posts with media');
}
}}
class={media ? 'is-active' : ''}
>
Media
</Link>
{featuredTags.map((tag) => (
<Link <Link
to={`/${instance}/a/${id}${excludeReplies ? '?replies=1' : ''}`} key={tag.id}
to={`/${instance}/a/${id}${
tagged === tag.name
? ''
: `?tagged=${encodeURIComponent(tag.name)}`
}`}
onClick={() => { onClick={() => {
if (excludeReplies) { if (tagged !== tag.name) {
showToast('Showing post with replies'); showToast(`Showing posts tagged with #${tag.name}`);
} }
}} }}
class={excludeReplies ? '' : 'is-active'} class={tagged === tag.name ? 'is-active' : ''}
> >
+ Replies <span>
<span class="more-insignificant">#</span>
{tag.name}
</span>
{
// The count differs based on instance 😅
}
{/* <span class="filter-count">{tag.statusesCount}</span> */}
</Link> </Link>
<Link ))}
to={`/${instance}/a/${id}${excludeBoosts ? '' : '?boosts=0'}`} {searchEnabled &&
onClick={() => { (supportsInputMonth ? (
if (!excludeBoosts) { <label class={`filter-field ${month ? 'is-active' : ''}`}>
showToast('Showing posts without boosts'); <Icon icon="month" size="l" />
} <input
}} type="month"
class={!excludeBoosts ? '' : 'is-active'}
>
- Boosts
</Link>
<Link
to={`/${instance}/a/${id}${media ? '' : '?media=1'}`}
onClick={() => {
if (!media) {
showToast('Showing posts with media');
}
}}
class={media ? 'is-active' : ''}
>
Media
</Link>
{featuredTags.map((tag) => (
<Link
key={tag.id}
to={`/${instance}/a/${id}${
tagged === tag.name
? ''
: `?tagged=${encodeURIComponent(tag.name)}`
}`}
onClick={() => {
if (tagged !== tag.name) {
showToast(`Showing posts tagged with #${tag.name}`);
}
}}
class={tagged === tag.name ? 'is-active' : ''}
>
<span>
<span class="more-insignificant">#</span>
{tag.name}
</span>
{
// The count differs based on instance 😅
}
{/* <span class="filter-count">{tag.statusesCount}</span> */}
</Link>
))}
{searchEnabled &&
(supportsInputMonth ? (
<label class={`filter-field ${month ? 'is-active' : ''}`}>
<Icon icon="month" size="l" />
<input
type="month"
disabled={!account?.acct}
value={month || ''}
min={MIN_YEAR_MONTH}
max={new Date().toISOString().slice(0, 7)}
onInput={(e) => {
const { value, validity } = e.currentTarget;
if (!validity.valid) return;
setSearchParams(
value
? {
month: value,
}
: {},
);
const [year, month] = value.split('-');
const monthIndex = parseInt(month, 10) - 1;
const date = new Date(year, monthIndex);
showToast(
`Showing posts in ${date.toLocaleString('default', {
month: 'long',
year: 'numeric',
})}`,
);
}}
/>
</label>
) : (
// Fallback to <select> for month and <input type="number"> for year
<MonthPicker
class={`filter-field ${month ? 'is-active' : ''}`}
disabled={!account?.acct} disabled={!account?.acct}
value={month || ''} value={month || ''}
min={MIN_YEAR_MONTH} min={MIN_YEAR_MONTH}
max={new Date().toISOString().slice(0, 7)} max={new Date().toISOString().slice(0, 7)}
onInput={(e) => { onInput={(e) => {
const { value, validity } = e; const { value, validity } = e.currentTarget;
if (!validity.valid) return; if (!validity.valid) return;
setSearchParams( setSearchParams(
value value
@ -433,11 +371,40 @@ function AccountStatuses() {
} }
: {}, : {},
); );
const [year, month] = value.split('-');
const monthIndex = parseInt(month, 10) - 1;
const date = new Date(year, monthIndex);
showToast(
`Showing posts in ${date.toLocaleString('default', {
month: 'long',
year: 'numeric',
})}`,
);
}} }}
/> />
))} </label>
</div> ) : (
)} // Fallback to <select> for month and <input type="number"> for year
<MonthPicker
class={`filter-field ${month ? 'is-active' : ''}`}
disabled={!account?.acct}
value={month || ''}
min={MIN_YEAR_MONTH}
max={new Date().toISOString().slice(0, 7)}
onInput={(e) => {
const { value, validity } = e;
if (!validity.valid) return;
setSearchParams(
value
? {
month: value,
}
: {},
);
}}
/>
))}
</div>
</> </>
); );
}, [ }, [
@ -466,7 +433,7 @@ function AccountStatuses() {
const accountInstance = useMemo(() => { const accountInstance = useMemo(() => {
if (!account?.url) return null; if (!account?.url) return null;
const domain = URL.parse(account.url).hostname; const domain = new URL(account.url).hostname;
return domain; return domain;
}, [account]); }, [account]);
const sameInstance = instance === accountInstance; const sameInstance = instance === accountInstance;
@ -500,7 +467,7 @@ function AccountStatuses() {
errorText="Unable to load posts" errorText="Unable to load posts"
fetchItems={fetchAccountStatuses} fetchItems={fetchAccountStatuses}
useItemID useItemID
view={media || mediaFirst ? 'media' : undefined} view={media ? 'media' : undefined}
boostsCarousel={snapStates.settings.boostsCarousel} boostsCarousel={snapStates.settings.boostsCarousel}
timelineStart={TimelineStart} timelineStart={TimelineStart}
refresh={[ refresh={[
@ -545,13 +512,7 @@ function AccountStatuses() {
> >
<Icon icon="transfer" />{' '} <Icon icon="transfer" />{' '}
<small class="menu-double-lines"> <small class="menu-double-lines">
Switch to account's instance{' '} Switch to account's instance (<b>{accountInstance}</b>)
{accountInstance ? (
<>
{' '}
(<b>{punycode.toUnicode(accountInstance)}</b>)
</>
) : null}
</small> </small>
</MenuItem> </MenuItem>
{!sameCurrentInstance && ( {!sameCurrentInstance && (

View file

@ -28,7 +28,7 @@
} }
#accounts-container section > ul > li .current { #accounts-container section > ul > li .current {
margin-inline-end: 8px; margin-right: 8px;
color: var(--green-color); color: var(--green-color);
opacity: 0.1; opacity: 0.1;
} }
@ -47,7 +47,7 @@
} }
#accounts-container .avatar { #accounts-container .avatar {
margin-inline-end: 8px; margin-right: 8px;
} }
#accounts-container .accounts-list li div { #accounts-container .accounts-list li div {

View file

@ -7,19 +7,18 @@ import { useReducer } from 'preact/hooks';
import Avatar from '../components/avatar'; import Avatar from '../components/avatar';
import Icon from '../components/icon'; import Icon from '../components/icon';
import Link from '../components/link'; import Link from '../components/link';
import MenuConfirm from '../components/menu-confirm';
import Menu2 from '../components/menu2'; import Menu2 from '../components/menu2';
import MenuConfirm from '../components/menu-confirm';
import NameText from '../components/name-text'; import NameText from '../components/name-text';
import { api } from '../utils/api'; import { api } from '../utils/api';
import states from '../utils/states'; import states from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import { getCurrentAccountID, setCurrentAccountID } from '../utils/store-utils';
function Accounts({ onClose }) { function Accounts({ onClose }) {
const { masto } = api(); const { masto } = api();
// Accounts // Accounts
const accounts = store.local.getJSON('accounts'); const accounts = store.local.getJSON('accounts');
const currentAccount = getCurrentAccountID(); const currentAccount = store.session.get('currentAccount');
const moreThanOneAccount = accounts.length > 1; const moreThanOneAccount = accounts.length > 1;
const [_, reload] = useReducer((x) => x + 1, 0); const [_, reload] = useReducer((x) => x + 1, 0);
@ -82,7 +81,7 @@ function Accounts({ onClose }) {
if (isCurrent) { if (isCurrent) {
states.showAccount = `${account.info.username}@${account.instanceURL}`; states.showAccount = `${account.info.username}@${account.instanceURL}`;
} else { } else {
setCurrentAccountID(account.info.id); store.session.set('currentAccount', account.info.id);
location.reload(); location.reload();
} }
}} }}

View file

@ -111,7 +111,7 @@
margin-bottom: 8px; margin-bottom: 8px;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
text-align: start; text-align: left;
justify-content: space-between; justify-content: space-between;
a { a {
@ -146,9 +146,6 @@
input[type='range'] { input[type='range'] {
accent-color: var(--link-color); accent-color: var(--link-color);
direction: rtl; direction: rtl;
&:dir(rtl) {
direction: ltr;
}
} }
} }
@ -254,7 +251,7 @@
overflow-y: hidden; overflow-y: hidden;
max-width: 100%; max-width: 100%;
mask-image: linear-gradient( mask-image: linear-gradient(
var(--to-forward), to right,
transparent, transparent,
black 16px calc(100% - 16px), black 16px calc(100% - 16px),
transparent transparent
@ -318,7 +315,7 @@
.count { .count {
font-size: 70%; font-size: 70%;
margin-inline-start: 4px; margin-left: 4px;
background-color: var(--bg-color); background-color: var(--bg-color);
padding: 4px 6px; padding: 4px 6px;
border-radius: 12px; border-radius: 12px;
@ -389,7 +386,7 @@
.count { .count {
position: absolute; position: absolute;
inset-inline-end: -4px; right: -4px;
top: -4px; top: -4px;
font-size: 10px; font-size: 10px;
background-color: var(--bg-color); background-color: var(--bg-color);
@ -409,7 +406,7 @@
overflow: hidden; overflow: hidden;
text-align: center; text-align: center;
mask-image: linear-gradient( mask-image: linear-gradient(
var(--to-forward), to right,
black calc(100% - 0.5em), black calc(100% - 0.5em),
transparent 100% transparent 100%
); );
@ -481,13 +478,13 @@
> li { > li {
&:first-child > a { &:first-child > a {
border-start-start-radius: var(--corner-radius); border-top-left-radius: var(--corner-radius);
border-start-end-radius: var(--corner-radius); border-top-right-radius: var(--corner-radius);
} }
&:last-child > a { &:last-child > a {
border-end-start-radius: var(--corner-radius); border-bottom-left-radius: var(--corner-radius);
border-end-end-radius: var(--corner-radius); border-bottom-right-radius: var(--corner-radius);
} }
} }
} }
@ -505,13 +502,13 @@
@media (min-width: 40em) { @media (min-width: 40em) {
&.separator + li a { &.separator + li a {
border-start-start-radius: var(--corner-radius); border-top-left-radius: var(--corner-radius);
border-start-end-radius: var(--corner-radius); border-top-right-radius: var(--corner-radius);
} }
&:has(+ .separator) a { &:has(+ .separator) a {
border-end-start-radius: var(--corner-radius); border-bottom-left-radius: var(--corner-radius);
border-end-end-radius: var(--corner-radius); border-bottom-right-radius: var(--corner-radius);
} }
} }
@ -528,11 +525,8 @@
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
box-shadow: 0 8px 16px -8px var(--drop-shadow-color), box-shadow: 0 8px 16px -8px var(--drop-shadow-color),
inset 0 1px var(--bg-color); inset 0 1px var(--bg-color);
text-shadow: 0 1px var(--bg-color);
}
&:hover:not(:focus-visible) {
outline: 1px solid var(--outline-color); outline: 1px solid var(--outline-color);
text-shadow: 0 1px var(--bg-color);
} }
&:active { &:active {
@ -575,12 +569,8 @@
'author meta' 'author meta'
'content content'; 'content content';
/* align-items: center; */ /* align-items: center; */
--bg-gradient-angle: 140deg;
&:dir(rtl) {
--bg-gradient-angle: -140deg;
}
background-image: linear-gradient( background-image: linear-gradient(
var(--bg-gradient-angle), 140deg,
var(--post-bg-color), var(--post-bg-color),
transparent min(160px, 50%) transparent min(160px, 50%)
); );
@ -621,7 +611,7 @@
} }
&.visibility-direct { &.visibility-direct {
--yellow-stripes: repeating-linear-gradient( --yellow-stripes: repeating-linear-gradient(
135deg, -45deg,
var(--reply-to-faded-color), var(--reply-to-faded-color),
var(--reply-to-faded-color) 10px, var(--reply-to-faded-color) 10px,
var(--reply-to-faded-color) 10px, var(--reply-to-faded-color) 10px,
@ -636,24 +626,10 @@
gap: 4px; gap: 4px;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
min-height: 24px;
> .avatar { .icon {
outline: 1px solid var(--bg-blur-color);
}
> .avatar ~ .avatar {
margin-inline-start: -8px;
}
> .icon {
color: var(--reblog-color); color: var(--reblog-color);
} }
> .name-text {
opacity: 0.75;
filter: grayscale(0.75);
}
} }
.post-author { .post-author {
@ -662,7 +638,7 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
mask-image: linear-gradient( mask-image: linear-gradient(
var(--to-forward), to right,
black calc(100% - 1em), black calc(100% - 1em),
transparent 100% transparent 100%
); );
@ -820,10 +796,6 @@
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;
}
} }
} }
@ -882,7 +854,6 @@
position: relative; position: relative;
z-index: 1; z-index: 1;
animation: position-object 5s ease-in-out 5; animation: position-object 5s ease-in-out 5;
animation-duration: var(--anim-duration, 5s);
/* @media (min-width: 40em) and (min-height: 600px) { /* @media (min-width: 40em) and (min-height: 600px) {
transform: scale(3); transform: scale(3);
@ -894,15 +865,12 @@
&:has(.post-peek-media), &:has(.post-peek-media),
.post-peek-media:first-child img { .post-peek-media:first-child img {
transform-origin: left center; transform-origin: left center;
:dir(rtl) & {
transform-origin: right center;
}
} }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.post-peek-media:not(:last-child) { .post-peek-media:not(:last-child) {
margin-inline-end: -24px; margin-right: -24px;
box-shadow: 0 0 0 2px var(--bg-blur-color); box-shadow: 0 0 0 2px var(--bg-blur-color);
} }
/* Max 10, I'm not going to code more than this */ /* Max 10, I'm not going to code more than this */
@ -1109,20 +1077,6 @@
dd { dd {
margin-block-end: 1em; margin-block-end: 1em;
margin-inline: 1em; margin-inline: 1em;
+ dd {
margin-block-start: -0.9em;
}
} }
} }
kbd {
border-radius: 4px;
display: inline-block;
padding: 0.2em 0.3em;
margin: 1px 0;
line-height: 1;
border: 1px solid var(--outline-color);
background-color: var(--bg-faded-color);
}
} }

View file

@ -13,7 +13,6 @@ 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';
@ -33,15 +32,14 @@ import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
import db from '../utils/db'; import db from '../utils/db';
import emojifyText from '../utils/emojify-text'; import emojifyText from '../utils/emojify-text';
import { isFiltered } from '../utils/filters'; import { isFiltered } from '../utils/filters';
import getHTMLText from '../utils/getHTMLText';
import htmlContentLength from '../utils/html-content-length'; import htmlContentLength from '../utils/html-content-length';
import niceDateTime from '../utils/nice-date-time'; import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
import states, { statusKey } from '../utils/states'; import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import store from '../utils/store'; import store from '../utils/store';
import { getCurrentAccountID, getCurrentAccountNS } from '../utils/store-utils'; import { getCurrentAccountNS } from '../utils/store-utils';
import supports from '../utils/supports';
import { assignFollowedTags } from '../utils/timeline-utils'; import { assignFollowedTags } from '../utils/timeline-utils';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
@ -113,12 +111,10 @@ function Catchup() {
const [showTopLinks, setShowTopLinks] = useState(false); const [showTopLinks, setShowTopLinks] = useState(false);
const currentAccount = useMemo(() => { const currentAccount = useMemo(() => {
return getCurrentAccountID(); return store.session.get('currentAccount');
}, []); }, []);
const isSelf = (accountID) => accountID === currentAccount; const isSelf = (accountID) => accountID === currentAccount;
const supportsPixelfed = supports('@pixelfed/home-include-reblogs');
async function fetchHome({ maxCreatedAt }) { async function fetchHome({ maxCreatedAt }) {
const maxCreatedAtDate = maxCreatedAt ? new Date(maxCreatedAt) : null; const maxCreatedAtDate = maxCreatedAt ? new Date(maxCreatedAt) : null;
console.debug('fetchHome', maxCreatedAtDate); console.debug('fetchHome', maxCreatedAtDate);
@ -126,13 +122,6 @@ function Catchup() {
const homeIterator = masto.v1.timelines.home.list({ limit: 40 }); const homeIterator = masto.v1.timelines.home.list({ limit: 40 });
mainloop: while (true) { mainloop: while (true) {
try { try {
if (supportsPixelfed && homeIterator.nextParams) {
if (typeof homeIterator.nextParams === 'string') {
homeIterator.nextParams += '&include_reblogs=true';
} else {
homeIterator.nextParams.include_reblogs = true;
}
}
const results = await homeIterator.next(); const results = await homeIterator.next();
const { value } = results; const { value } = results;
if (value?.length) { if (value?.length) {
@ -202,7 +191,6 @@ 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();
@ -441,28 +429,9 @@ function Catchup() {
return postFilterMatches; return postFilterMatches;
}); });
// Deduplicate boosts
const boostedPosts = {};
filteredPosts.forEach((post) => {
if (post.reblog) {
if (boostedPosts[post.reblog.id]) {
if (boostedPosts[post.reblog.id].__BOOSTERS) {
boostedPosts[post.reblog.id].__BOOSTERS.add(post.account);
} else {
boostedPosts[post.reblog.id].__BOOSTERS = new Set([post.account]);
}
post.__HIDDEN = true;
} else {
boostedPosts[post.reblog.id] = post;
}
}
});
if (selectedAuthor && authorCountsMap.has(selectedAuthor)) { if (selectedAuthor && authorCountsMap.has(selectedAuthor)) {
filteredPosts = filteredPosts.filter( filteredPosts = filteredPosts.filter(
(post) => (post) => post.account.id === selectedAuthor,
post.account.id === selectedAuthor ||
[...(post.__BOOSTERS || [])].find((a) => a.id === selectedAuthor),
); );
} }
@ -490,41 +459,39 @@ function Catchup() {
authorCountsList.forEach((authorID, index) => { authorCountsList.forEach((authorID, index) => {
authorIndices[authorID] = index; authorIndices[authorID] = index;
}); });
return filteredPosts return filteredPosts.sort((a, b) => {
.filter((post) => !post.__HIDDEN) if (groupBy === 'account') {
.sort((a, b) => { const aAccountID = a.account.id;
if (groupBy === 'account') { const bAccountID = b.account.id;
const aAccountID = a.account.id; const aIndex = authorIndices[aAccountID];
const bAccountID = b.account.id; const bIndex = authorIndices[bAccountID];
const aIndex = authorIndices[aAccountID]; const order = aIndex - bIndex;
const bIndex = authorIndices[bAccountID]; if (order !== 0) {
const order = aIndex - bIndex; return order;
if (order !== 0) {
return order;
}
} }
if (sortBy !== 'createdAt') { }
a = a.reblog || a; if (sortBy !== 'createdAt') {
b = b.reblog || b; a = a.reblog || a;
if (sortBy !== 'density' && a[sortBy] === b[sortBy]) { b = b.reblog || b;
return a.createdAt > b.createdAt ? 1 : -1; if (sortBy !== 'density' && a[sortBy] === b[sortBy]) {
} return a.createdAt > b.createdAt ? 1 : -1;
}
if (sortBy === 'density') {
const aDensity = postDensity(a);
const bDensity = postDensity(b);
if (sortOrder === 'asc') {
return aDensity > bDensity ? 1 : -1;
} else {
return bDensity > aDensity ? 1 : -1;
}
} }
}
if (sortBy === 'density') {
const aDensity = postDensity(a);
const bDensity = postDensity(b);
if (sortOrder === 'asc') { if (sortOrder === 'asc') {
return a[sortBy] > b[sortBy] ? 1 : -1; return aDensity > bDensity ? 1 : -1;
} else { } else {
return b[sortBy] > a[sortBy] ? 1 : -1; return bDensity > aDensity ? 1 : -1;
} }
}); }
if (sortOrder === 'asc') {
return a[sortBy] > b[sortBy] ? 1 : -1;
} else {
return b[sortBy] > a[sortBy] ? 1 : -1;
}
});
}, [filteredPosts, sortBy, sortOrder, groupBy, authorCountsList]); }, [filteredPosts, sortBy, sortOrder, groupBy, authorCountsList]);
const prevGroup = useRef(null); const prevGroup = useRef(null);
@ -622,46 +589,41 @@ function Catchup() {
authors, authors,
]); ]);
const prevSelectedAuthorMissing = useRef(false);
useEffect(() => { useEffect(() => {
// console.log({
// prevSelectedAuthorMissing,
// selectedAuthor,
// authors,
// });
let timer;
if (selectedAuthor) { if (selectedAuthor) {
if (authors[selectedAuthor]) { if (authors[selectedAuthor]) {
// Check if author is visible and within the scrollable area viewport if (prevSelectedAuthorMissing.current) {
const authorElement = authorsListParent.current.querySelector( timer = setTimeout(() => {
`[data-author="${selectedAuthor}"]`, authorsListParent.current
); .querySelector(`[data-author="${selectedAuthor}"]`)
const scrollableRect = ?.scrollIntoView({
authorsListParent.current?.getBoundingClientRect(); behavior: 'smooth',
const authorRect = authorElement?.getBoundingClientRect(); block: 'nearest',
console.log({ inline: 'center',
sLeft: scrollableRect.left, });
sRight: scrollableRect.right, }, 500);
aLeft: authorRect.left, prevSelectedAuthorMissing.current = false;
aRight: authorRect.right,
});
if (
authorRect.left < scrollableRect.left ||
authorRect.right > scrollableRect.right
) {
authorElement.scrollIntoView({
block: 'nearest',
inline: 'center',
behavior: 'smooth',
});
} else if (authorRect.top < 0) {
authorElement.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
});
} }
} else {
prevSelectedAuthorMissing.current = true;
} }
} }
return () => {
clearTimeout(timer);
};
}, [selectedAuthor, authors]); }, [selectedAuthor, authors]);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const itemsSelector = '.catchup-list > li > a'; const itemsSelector = '.catchup-list > li > a';
const jRef = useHotkeys( useHotkeys(
'j', 'j',
() => { () => {
const activeItem = document.activeElement.closest(itemsSelector); const activeItem = document.activeElement.closest(itemsSelector);
@ -701,121 +663,12 @@ function Catchup() {
}, },
{ {
preventDefault: true, preventDefault: true,
ignoreModifiers: true,
},
);
const kRef = useHotkeys(
'k',
() => {
const activeItem = document.activeElement.closest(itemsSelector);
const activeItemRect = activeItem?.getBoundingClientRect();
const allItems = Array.from(
scrollableRef.current.querySelectorAll(itemsSelector),
);
if (
activeItem &&
activeItemRect.top < scrollableRef.current.clientHeight &&
activeItemRect.bottom > 0
) {
const activeItemIndex = allItems.indexOf(activeItem);
let prevItem = allItems[activeItemIndex - 1];
if (prevItem) {
prevItem.focus();
prevItem.scrollIntoView({
block: 'center',
inline: 'center',
behavior: 'smooth',
});
}
} else {
const topmostItem = allItems.find((item) => {
const itemRect = item.getBoundingClientRect();
return itemRect.top >= 44 && itemRect.left >= 0;
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoView({
block: 'nearest',
inline: 'center',
behavior: 'smooth',
});
}
}
},
{
preventDefault: true,
ignoreModifiers: true,
},
);
const hlRef = useHotkeys(
'h, l',
(_, handler) => {
// Go next/prev selectedAuthor in authorCountsList list
const key = handler.keys[0];
if (selectedAuthor) {
const index = authorCountsList.indexOf(selectedAuthor);
if (key === 'h') {
if (index > 0 && index < authorCountsList.length) {
setSelectedAuthor(authorCountsList[index - 1]);
scrollableRef.current?.focus();
}
} else if (key === 'l') {
if (index < authorCountsList.length - 1 && index >= 0) {
setSelectedAuthor(authorCountsList[index + 1]);
scrollableRef.current?.focus();
}
}
} else if (key === 'l') {
setSelectedAuthor(authorCountsList[0]);
scrollableRef.current?.focus();
}
},
{
preventDefault: true,
ignoreModifiers: true,
enableOnFormTags: ['input'],
},
);
const escRef = useHotkeys(
'esc',
() => {
setSelectedAuthor(null);
scrollableRef.current?.focus();
},
{
preventDefault: true,
ignoreModifiers: true,
enableOnFormTags: ['input'],
},
);
const dotRef = useHotkeys(
'.',
() => {
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
},
{
preventDefault: true,
ignoreModifiers: true,
enableOnFormTags: ['input'],
}, },
); );
return ( return (
<div <div
ref={(node) => { ref={scrollableRef}
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
hlRef.current = node;
escRef.current = node;
}}
id="catchup-page" id="catchup-page"
class="deck-container" class="deck-container"
tabIndex="-1" tabIndex="-1"
@ -937,15 +790,7 @@ function Catchup() {
type="button" type="button"
onClick={() => { onClick={() => {
if (range < RANGES[RANGES.length - 1].value) { if (range < RANGES[RANGES.length - 1].value) {
let duration; const duration = range * 60 * 60 * 1000;
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();
@ -955,25 +800,11 @@ 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
@ -990,12 +821,10 @@ function Catchup() {
<Link to={`/catchup?id=${pc.id}`}> <Link to={`/catchup?id=${pc.id}`}>
<Icon icon="history2" />{' '} <Icon icon="history2" />{' '}
<span> <span>
{pc.startAt {formatRange(
? dtf.formatRange( new Date(pc.startAt),
new Date(pc.startAt), new Date(pc.endAt),
new Date(pc.endAt), )}
)
: `… – ${dtf.format(new Date(pc.endAt))}`}
</span> </span>
</Link>{' '} </Link>{' '}
<span> <span>
@ -1047,7 +876,7 @@ function Catchup() {
{posts.length > 0 && ( {posts.length > 0 && (
<p> <p>
<b class="ib"> <b class="ib">
{dtf.formatRange( {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),
)} )}
@ -1110,11 +939,9 @@ function Catchup() {
height, height,
publishedAt, publishedAt,
} = card; } = card;
const domain = punycode.toUnicode( const domain = new URL(url).hostname
URL.parse(url) .replace(/^www\./, '')
.hostname.replace(/^www\./, '') .replace(/\/$/, '');
.replace(/\/$/, ''),
);
let accentColor; let accentColor;
if (blurhash) { if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash); const averageColor = getBlurHashAverageColor(blurhash);
@ -1170,12 +997,7 @@ function Catchup() {
)} )}
</div> </div>
{!!title && ( {!!title && (
<h1 <h1 class="title" lang={language} dir="auto">
class="title"
lang={language}
dir="auto"
title={title}
>
{title} {title}
</h1> </h1>
)} )}
@ -1185,7 +1007,6 @@ function Catchup() {
class="description" class="description"
lang={language} lang={language}
dir="auto" dir="auto"
title={description}
> >
{description} {description}
</p> </p>
@ -1257,10 +1078,6 @@ function Catchup() {
} }
onChange={() => { onChange={() => {
setSelectedFilterCategory(label); setSelectedFilterCategory(label);
if (label === 'Boosts') {
setSortBy('reblogsCount');
setGroupBy(null);
}
// setSelectedAuthor(null); // setSelectedAuthor(null);
}} }}
/> />
@ -1303,7 +1120,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].acct})`} alt={`${authors[author].displayName} (@${authors[author].username})`}
/>{' '} />{' '}
<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>
@ -1513,25 +1330,6 @@ function Catchup() {
Posts are grouped by authors, sorted by posts count per Posts are grouped by authors, sorted by posts count per
author. author.
</dd> </dd>
<dt>Keyboard shortcuts</dt>
<dd>
<kbd>j</kbd>: Next post
</dd>
<dd>
<kbd>k</kbd>: Previous post
</dd>
<dd>
<kbd>l</kbd>: Next author
</dd>
<dd>
<kbd>h</kbd>: Previous author
</dd>
<dd>
<kbd>Enter</kbd>: Open post details
</dd>
<dd>
<kbd>.</kbd>: Scroll to top
</dd>
</dl> </dl>
</main> </main>
</div> </div>
@ -1553,7 +1351,6 @@ const PostLine = memo(
_followedTags: isFollowedTags, _followedTags: isFollowedTags,
_filtered: filterInfo, _filtered: filterInfo,
visibility, visibility,
__BOOSTERS,
} = post; } = post;
const isReplyTo = inReplyToId && inReplyToAccountId !== account.id; const isReplyTo = inReplyToId && inReplyToAccountId !== account.id;
const isFiltered = !!filterInfo; const isFiltered = !!filterInfo;
@ -1587,12 +1384,7 @@ const PostLine = memo(
<Avatar <Avatar
url={account.avatarStatic || account.avatar} url={account.avatarStatic || account.avatar}
squircle={account.bot} squircle={account.bot}
/> />{' '}
{__BOOSTERS?.size > 0
? [...__BOOSTERS].map((b) => (
<Avatar url={b.avatarStatic || b.avatar} squircle={b.bot} />
))
: ''}{' '}
<Icon icon="rocket" />{' '} <Icon icon="rocket" />{' '}
{/* <Avatar {/* <Avatar
url={reblog.account.avatarStatic || reblog.account.avatar} url={reblog.account.avatarStatic || reblog.account.avatar}
@ -1691,70 +1483,55 @@ function PostPeek({ post, filterInfo }) {
} = post; } = post;
const isThread = const isThread =
(inReplyToId && inReplyToAccountId === account.id) || !!_thread; (inReplyToId && inReplyToAccountId === account.id) || !!_thread;
const showMedia = !spoilerText && !sensitive;
const readingExpandSpoilers = useMemo(() => { const postText = content ? getHTMLText(content) : '';
const prefs = store.account.get('preferences') || {};
return !!prefs['reading:expand:spoilers'];
}, []);
// const readingExpandSpoilers = true;
const showMedia = readingExpandSpoilers || (!spoilerText && !sensitive);
const postText = content ? statusPeek(post) : '';
const showPostContent = !spoilerText || readingExpandSpoilers;
return ( return (
<div class="post-peek" title={!spoilerText ? postText : ''}> <div class="post-peek" title={!spoilerText ? postText : ''}>
<span class="post-peek-content"> <span class="post-peek-content">
{isThread && !showPostContent && (
<>
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
</>
)}
{!!filterInfo ? ( {!!filterInfo ? (
<span class="post-peek-filtered">
Filtered{filterInfo?.titlesStr ? `: ${filterInfo.titlesStr}` : ''}
</span>
) : (
<> <>
{!!spoilerText && ( {isThread && (
<span class="post-peek-spoiler"> <>
<Icon <span class="post-peek-tag post-peek-thread">Thread</span>{' '}
icon={`${readingExpandSpoilers ? 'eye-open' : 'eye-close'}`} </>
/>{' '}
{spoilerText}
</span>
)}
{showPostContent && (
<div class="post-peek-html">
{isThread && (
<>
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
</>
)}
{!!content && (
<div
dangerouslySetInnerHTML={{
__html: emojifyText(content, emojis),
}}
/>
)}
{!!poll?.options?.length &&
poll.options.map((o) => (
<div>
{poll.multiple ? '▪️' : '•'} {o.title}
</div>
))}
{!content &&
mediaAttachments?.length === 1 &&
mediaAttachments[0].description && (
<>
<span class="post-peek-tag post-peek-alt">ALT</span>{' '}
<div>{mediaAttachments[0].description}</div>
</>
)}
</div>
)} )}
<span class="post-peek-filtered">
Filtered{filterInfo?.titlesStr ? `: ${filterInfo.titlesStr}` : ''}
</span>
</> </>
) : !!spoilerText ? (
<>
{isThread && (
<>
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
</>
)}
<span class="post-peek-spoiler">
<Icon icon="eye-close" /> {spoilerText}
</span>
</>
) : (
<div class="post-peek-html">
{isThread && (
<>
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
</>
)}
{content ? (
<div
dangerouslySetInnerHTML={{
__html: emojifyText(content, emojis),
}}
/>
) : mediaAttachments?.length === 1 &&
mediaAttachments[0].description ? (
<>
<span class="post-peek-tag post-peek-alt">ALT</span>{' '}
<div>{mediaAttachments[0].description}</div>
</>
) : null}
</div>
)} )}
</span> </span>
{!filterInfo && ( {!filterInfo && (
@ -1769,12 +1546,6 @@ function PostPeek({ post, filterInfo }) {
? mediaAttachments.map((m) => { ? mediaAttachments.map((m) => {
const mediaURL = m.previewUrl || m.url; const mediaURL = m.previewUrl || m.url;
const remoteMediaURL = m.previewRemoteUrl || m.remoteUrl; const remoteMediaURL = m.previewRemoteUrl || m.remoteUrl;
const width = m.meta?.original
? m.meta.original.width
: m.meta?.small?.width || m.meta?.original?.width;
const height = m.meta?.original
? m.meta.original.height
: m.meta?.small?.height || m.meta?.original?.height;
return ( return (
<span key={m.id} class="post-peek-media"> <span key={m.id} class="post-peek-media">
{{ {{
@ -1792,12 +1563,6 @@ function PostPeek({ post, filterInfo }) {
e.target.src = remoteMediaURL; e.target.src = remoteMediaURL;
} }
}} }}
style={{
'--anim-duration': `${Math.min(
Math.max(Math.max(width, height) / 100, 5),
120,
)}s`,
}}
/> />
) : ( ) : (
<span class="post-peek-faux-media">🖼</span> <span class="post-peek-faux-media">🖼</span>
@ -1860,18 +1625,6 @@ function PostPeek({ post, filterInfo }) {
card.title || card.description || card.imageDescription card.title || card.description || card.imageDescription
} }
loading="lazy" loading="lazy"
style={{
'--anim-duration':
card.width &&
card.height &&
`${Math.min(
Math.max(
Math.max(card.width, card.height) / 100,
5,
),
120,
)}s`,
}}
/> />
) : ( ) : (
<span class="post-peek-faux-media">🔗</span> <span class="post-peek-faux-media">🔗</span>
@ -1915,6 +1668,9 @@ 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

View file

@ -1,149 +0,0 @@
#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;
}
}
}

View file

@ -1,588 +0,0 @@
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
if (hasExpiry) {
expiresIn = Math.floor(
(expiresAtDate - new Date()) / 1000,
);
} else {
expiresIn = null;
}
} 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
dir="auto"
/>
<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`, `/fh`); useTitle(`Followed Hashtags`, `/ft`);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [followedHashtags, setFollowedHashtags] = useState([]); const [followedHashtags, setFollowedHashtags] = useState([]);

View file

@ -4,8 +4,8 @@ import { useSnapshot } from 'valtio';
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 states, { getStatus, saveStatus } from '../utils/states'; import states from '../utils/states';
import supports from '../utils/supports'; import { getStatus, saveStatus } from '../utils/states';
import { import {
assignFollowedTags, assignFollowedTags,
clearFollowedTagsState, clearFollowedTagsState,
@ -23,19 +23,11 @@ function Following({ title, path, id, ...props }) {
const latestItem = useRef(); const latestItem = useRef();
console.debug('RENDER Following', title, id); console.debug('RENDER Following', title, id);
const supportsPixelfed = supports('@pixelfed/home-include-reblogs');
async function fetchHome(firstLoad) { async function fetchHome(firstLoad) {
if (firstLoad || !homeIterator.current) { if (firstLoad || !homeIterator.current) {
homeIterator.current = masto.v1.timelines.home.list({ limit: LIMIT }); homeIterator.current = masto.v1.timelines.home.list({ limit: LIMIT });
} }
if (supportsPixelfed && homeIterator.current?.nextParams) {
if (typeof homeIterator.current.nextParams === 'string') {
homeIterator.current.nextParams += '&include_reblogs=true';
} else {
homeIterator.current.nextParams.include_reblogs = true;
}
}
const results = await homeIterator.current.next(); const results = await homeIterator.current.next();
let { value } = results; let { value } = results;
if (value?.length) { if (value?.length) {
@ -71,18 +63,15 @@ function Following({ title, path, id, ...props }) {
async function checkForUpdates() { async function checkForUpdates() {
try { try {
const opts = { const results = await masto.v1.timelines.home
limit: 5, .list({
since_id: latestItem.current, limit: 5,
}; since_id: latestItem.current,
if (supports('@pixelfed/home-include-reblogs')) { })
opts.include_reblogs = true; .next();
}
const results = await masto.v1.timelines.home.list(opts).next();
let { value } = results; let { value } = results;
console.log('checkForUpdates', latestItem.current, value); console.log('checkForUpdates', latestItem.current, value);
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported if (value?.length) {
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

@ -5,19 +5,19 @@ import {
MenuHeader, MenuHeader,
MenuItem, MenuItem,
} from '@szhsin/react-menu'; } from '@szhsin/react-menu';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import Icon from '../components/icon'; import Icon from '../components/icon';
import MenuConfirm from '../components/menu-confirm';
import Menu2 from '../components/menu2'; import Menu2 from '../components/menu2';
import MenuConfirm from '../components/menu-confirm';
import { SHORTCUTS_LIMIT } from '../components/shortcuts-settings'; import { SHORTCUTS_LIMIT } from '../components/shortcuts-settings';
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 showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
import states, { saveStatus } from '../utils/states'; import states from '../utils/states';
import { isMediaFirstInstance } from '../utils/store-utils'; import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const LIMIT = 20; const LIMIT = 20;
@ -55,8 +55,6 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
useTitle(title, `/:instance?/t/:hashtag`); useTitle(title, `/:instance?/t/:hashtag`);
const latestItem = useRef(); const latestItem = useRef();
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
// const hashtagsIterator = useRef(); // const hashtagsIterator = useRef();
const maxID = useRef(undefined); const maxID = useRef(undefined);
async function fetchHashtags(firstLoad) { async function fetchHashtags(firstLoad) {
@ -75,7 +73,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
limit: LIMIT, limit: LIMIT,
any: hashtags.slice(1), any: hashtags.slice(1),
maxId: firstLoad ? undefined : maxID.current, maxId: firstLoad ? undefined : maxID.current,
onlyMedia: media ? true : undefined, onlyMedia: media,
}) })
.next(); .next();
let { value } = results; let { value } = results;
@ -87,7 +85,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
// value = filteredItems(value, 'public'); // value = filteredItems(value, 'public');
value.forEach((item) => { value.forEach((item) => {
saveStatus(item, instance, { saveStatus(item, instance, {
skipThreading: media || mediaFirst, // If media view, no need to form threads skipThreading: media, // If media view, no need to form threads
}); });
}); });
@ -111,9 +109,8 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
}) })
.next(); .next();
let { value } = results; let { value } = results;
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported value = filteredItems(value, 'public');
if (value?.length && !valueContainsLatestItem) { if (value?.length) {
value = filteredItems(value, 'public');
return true; return true;
} }
return false; return false;
@ -139,26 +136,6 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
const reachLimit = hashtags.length >= TOTAL_TAGS_LIMIT; const reachLimit = hashtags.length >= TOTAL_TAGS_LIMIT;
const [featuredUIState, setFeaturedUIState] = useState('default');
const [featuredTags, setFeaturedTags] = useState([]);
const [isFeaturedTag, setIsFeaturedTag] = useState(false);
useEffect(() => {
if (!authenticated) return;
(async () => {
try {
const featuredTags = await masto.v1.featuredTags.list();
setFeaturedTags(featuredTags);
setIsFeaturedTag(
featuredTags.some(
(tag) => tag.name.toLowerCase() === hashtag.toLowerCase(),
),
);
} catch (e) {
console.error(e);
}
})();
}, []);
return ( return (
<Timeline <Timeline
key={instance + hashtagTitle} key={instance + hashtagTitle}
@ -166,7 +143,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
titleComponent={ titleComponent={
!!instance && ( !!instance && (
<h1 class="header-double-lines"> <h1 class="header-double-lines">
<b dir="auto">{hashtagTitle}</b> <b>{hashtagTitle}</b>
<div>{instance}</div> <div>{instance}</div>
</h1> </h1>
) )
@ -178,7 +155,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
fetchItems={fetchHashtags} fetchItems={fetchHashtags}
checkForUpdates={checkForUpdates} checkForUpdates={checkForUpdates}
useItemID useItemID
view={media || mediaFirst ? 'media' : undefined} view={media ? 'media' : undefined}
refresh={media} refresh={media}
// allowFilters // allowFilters
filterContext="public" filterContext="public"
@ -252,93 +229,26 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
</> </>
)} )}
</MenuConfirm> </MenuConfirm>
<MenuItem
type="checkbox"
checked={isFeaturedTag}
disabled={featuredUIState === 'loading' || !authenticated}
onClick={() => {
setFeaturedUIState('loading');
if (isFeaturedTag) {
const featuredTagID = featuredTags.find(
(tag) => tag.name.toLowerCase() === hashtag.toLowerCase(),
).id;
if (featuredTagID) {
masto.v1.featuredTags
.$select(featuredTagID)
.remove()
.then(() => {
setIsFeaturedTag(false);
showToast('Unfeatured on profile');
setFeaturedTags(
featuredTags.filter(
(tag) => tag.id !== featuredTagID,
),
);
})
.catch((e) => {
console.error(e);
})
.finally(() => {
setFeaturedUIState('default');
});
} else {
showToast('Unable to unfeature on profile');
}
} else {
masto.v1.featuredTags
.create({
name: hashtag,
})
.then((value) => {
setIsFeaturedTag(true);
showToast('Featured on profile');
setFeaturedTags(featuredTags.concat(value));
})
.catch((e) => {
console.error(e);
})
.finally(() => {
setFeaturedUIState('default');
});
}
}}
>
{isFeaturedTag ? (
<>
<Icon icon="check-circle" />
<span>Featured on profile</span>
</>
) : (
<>
<Icon icon="check-circle" />
<span>Feature on profile</span>
</>
)}
</MenuItem>
<MenuDivider />
</>
)}
{!mediaFirst && (
<>
<MenuHeader className="plain">Filters</MenuHeader>
<MenuItem
type="checkbox"
checked={!!media}
onClick={() => {
if (media) {
searchParams.delete('media');
} else {
searchParams.set('media', '1');
}
setSearchParams(searchParams);
}}
>
<Icon icon="check-circle" />{' '}
<span class="menu-grow">Media only</span>
</MenuItem>
<MenuDivider /> <MenuDivider />
</> </>
)} )}
<MenuHeader className="plain">Filters</MenuHeader>
<MenuItem
type="checkbox"
checked={!!media}
onClick={() => {
if (media) {
searchParams.delete('media');
} else {
searchParams.set('media', '1');
}
setSearchParams(searchParams);
}}
>
<Icon icon="check-circle" />{' '}
<span class="menu-grow">Media only</span>
</MenuItem>
<MenuDivider />
<FocusableItem className="menu-field" disabled={reachLimit}> <FocusableItem className="menu-field" disabled={reachLimit}>
{({ ref }) => ( {({ ref }) => (
<form <form
@ -375,11 +285,10 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
required required
autocorrect="off" autocorrect="off"
autocapitalize="off" autocapitalize="off"
spellCheck={false} spellcheck={false}
// no spaces, no hashtags // no spaces, no hashtags
pattern="[^#][^\s#]+[^#]" pattern="[^#][^\s#]+[^#]"
disabled={reachLimit} disabled={reachLimit}
dir="auto"
/> />
</form> </form>
)} )}
@ -403,7 +312,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
}} }}
> >
<Icon icon="x" alt="Remove hashtag" class="danger-icon" /> <Icon icon="x" alt="Remove hashtag" class="danger-icon" />
<span class="bidi-isolate"> <span>
<span class="more-insignificant">#</span> <span class="more-insignificant">#</span>
{t} {t}
</span> </span>
@ -449,7 +358,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
} }
}} }}
> >
<Icon icon="shortcut" /> <span>Add to Shortcuts</span> <Icon icon="shortcut" /> <span>Add to Shorcuts</span>
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onClick={() => { onClick={() => {

View file

@ -12,15 +12,11 @@ import Loader from '../components/loader';
import Notification from '../components/notification'; import Notification from '../components/notification';
import { api } from '../utils/api'; import { api } from '../utils/api';
import db from '../utils/db'; import db from '../utils/db';
import { massageNotifications2 } from '../utils/group-notifications'; import groupNotifications from '../utils/group-notifications';
import states, { saveStatus } from '../utils/states'; import states, { saveStatus } from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils'; import { getCurrentAccountNS } from '../utils/store-utils';
import Following from './following'; import Following from './following';
import {
getGroupedNotifications,
mastoFetchNotifications,
} from './notifications';
function Home() { function Home() {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
@ -88,17 +84,20 @@ function NotificationsLink() {
); );
} }
const NOTIFICATIONS_LIMIT = 30;
const NOTIFICATIONS_DISPLAY_LIMIT = 5; const NOTIFICATIONS_DISPLAY_LIMIT = 5;
function NotificationsMenu({ anchorRef, state, onClose }) { function NotificationsMenu({ anchorRef, state, onClose }) {
const { masto, instance } = api(); const { masto, instance } = api();
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const notificationsIterator = mastoFetchNotifications(); const notificationsIterator = masto.v1.notifications.list({
limit: NOTIFICATIONS_LIMIT,
});
async function fetchNotifications() { async function fetchNotifications() {
const allNotifications = await notificationsIterator.next(); const allNotifications = await notificationsIterator.next();
const notifications = massageNotifications2(allNotifications.value); const notifications = allNotifications.value;
if (notifications?.length) { if (notifications?.length) {
notifications.forEach((notification) => { notifications.forEach((notification) => {
@ -107,16 +106,16 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
}); });
}); });
const groupedNotifications = getGroupedNotifications(notifications); const groupedNotifications = groupNotifications(notifications);
states.notificationsLast = groupedNotifications[0]; states.notificationsLast = notifications[0];
states.notifications = groupedNotifications; states.notifications = groupedNotifications;
// Update last read marker // Update last read marker
masto.v1.markers masto.v1.markers
.create({ .create({
notifications: { notifications: {
lastReadId: groupedNotifications[0].id, lastReadId: notifications[0].id,
}, },
}) })
.catch(() => {}); .catch(() => {});
@ -152,11 +151,8 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
if (state === 'open') loadNotifications(); if (state === 'open') loadNotifications();
}, [state]); }, [state]);
const menuRef = useRef();
return ( return (
<ControlledMenu <ControlledMenu
ref={menuRef}
menuClassName="notifications-menu" menuClassName="notifications-menu"
state={state} state={state}
anchorRef={anchorRef} anchorRef={anchorRef}
@ -164,11 +160,6 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
portal={{ portal={{
target: document.body, target: document.body,
}} }}
containerProps={{
onClick: () => {
menuRef.current?.closeMenu?.();
},
}}
overflow="auto" overflow="auto"
viewScroll="close" viewScroll="close"
position="anchor" position="anchor"
@ -185,7 +176,7 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
.slice(0, NOTIFICATIONS_DISPLAY_LIMIT) .slice(0, NOTIFICATIONS_DISPLAY_LIMIT)
.map((notification) => ( .map((notification) => (
<Notification <Notification
key={notification._ids || notification.id} key={notification.id}
instance={instance} instance={instance}
notification={notification} notification={notification}
disableContextMenu disableContextMenu

View file

@ -24,13 +24,11 @@ export default function HttpRoute() {
// Check if status returns 200 // Check if status returns 200
try { try {
const { instance, id } = statusObject; const { instance, id } = statusObject;
if (id) { const { masto } = api({ instance });
const { masto } = api({ instance }); const status = await masto.v1.statuses.$select(id).fetch();
const status = await masto.v1.statuses.$select(id).fetch(); if (status) {
if (status) { window.location.hash = statusURL + '?view=full';
window.location.hash = statusURL + '?view=full'; return;
return;
}
} }
} catch (e) {} } catch (e) {}

View file

@ -1,6 +1,6 @@
import './lists.css'; import './lists.css';
import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu'; import { Menu, 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';
@ -10,14 +10,12 @@ import AccountBlock from '../components/account-block';
import Icon from '../components/icon'; import Icon from '../components/icon';
import Link from '../components/link'; import Link from '../components/link';
import ListAddEdit from '../components/list-add-edit'; import ListAddEdit from '../components/list-add-edit';
import MenuConfirm from '../components/menu-confirm';
import MenuLink from '../components/menu-link';
import Menu2 from '../components/menu2'; import Menu2 from '../components/menu2';
import MenuConfirm from '../components/menu-confirm';
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';
@ -63,9 +61,8 @@ function List(props) {
since_id: latestItem.current, since_id: latestItem.current,
}); });
let { value } = results; let { value } = results;
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported value = filteredItems(value, 'home');
if (value?.length && !valueContainsLatestItem) { if (value?.length) {
value = filteredItems(value, 'home');
return true; return true;
} }
return false; return false;
@ -74,18 +71,13 @@ 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 getList(id); const list = await masto.v1.lists.$select(id).fetch();
setList(list); setList(list);
// setTitle(list.title); // setTitle(list.title);
} catch (e) { } catch (e) {
@ -115,32 +107,9 @@ 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,10 +8,11 @@ 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 { fetchLists } from '../utils/lists'; import { api } from '../utils/api';
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');
@ -21,7 +22,8 @@ function Lists() {
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
const lists = await fetchLists(); const lists = await masto.v1.lists.list();
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

@ -30,15 +30,14 @@
#instances-suggestions { #instances-suggestions {
margin: 0.2em 0 0; margin: 0.2em 0 0;
padding: 0; padding: 0 0 0 1.2em;
padding-inline-start: 1.2em;
list-style: none; list-style: none;
width: 90vw; width: 90vw;
max-width: 40em; max-width: 40em;
overflow: auto; overflow: auto;
white-space: nowrap; white-space: nowrap;
mask-image: linear-gradient( mask-image: linear-gradient(
var(--to-forward), to right,
transparent, transparent,
black 1.2em, black 1.2em,
black calc(100% - 5em), black calc(100% - 5em),

View file

@ -1,6 +1,5 @@
import './login.css'; import './login.css';
import Fuse from 'fuse.js';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
@ -29,14 +28,12 @@ function Login() {
); );
const [instancesList, setInstancesList] = useState([]); const [instancesList, setInstancesList] = useState([]);
const searcher = useRef();
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const res = await fetch(instancesListURL); const res = await fetch(instancesListURL);
const data = await res.json(); const data = await res.json();
setInstancesList(data); setInstancesList(data);
searcher.current = new Fuse(data);
} catch (e) { } catch (e) {
// Silently fail // Silently fail
console.error(e); console.error(e);
@ -94,11 +91,21 @@ function Login() {
!/[\s\/\\@]/.test(cleanInstanceText); !/[\s\/\\@]/.test(cleanInstanceText);
const instancesSuggestions = cleanInstanceText const instancesSuggestions = cleanInstanceText
? searcher.current ? instancesList
?.search(cleanInstanceText, { .filter((instance) => instance.includes(instanceText))
limit: 10, .sort((a, b) => {
// Move text that starts with instanceText to the start
const aStartsWith = a
.toLowerCase()
.startsWith(instanceText.toLowerCase());
const bStartsWith = b
.toLowerCase()
.startsWith(instanceText.toLowerCase());
if (aStartsWith && !bStartsWith) return -1;
if (!aStartsWith && bStartsWith) return 1;
return 0;
}) })
?.map((match) => match.item) .slice(0, 10)
: []; : [];
const selectedInstanceText = instanceTextLooksLikeDomain const selectedInstanceText = instanceTextLooksLikeDomain
@ -154,12 +161,11 @@ function Login() {
autocorrect="off" autocorrect="off"
autocapitalize="off" autocapitalize="off"
autocomplete="off" autocomplete="off"
spellCheck={false} spellcheck={false}
placeholder="instance domain" placeholder="instance domain"
onInput={(e) => { onInput={(e) => {
setInstanceText(e.target.value); setInstanceText(e.target.value);
}} }}
dir="auto"
/> />
{instancesSuggestions?.length > 0 ? ( {instancesSuggestions?.length > 0 ? (
<ul id="instances-suggestions"> <ul id="instances-suggestions">

View file

@ -4,7 +4,6 @@ import { useSearchParams } from 'react-router-dom';
import Link from '../components/link'; import Link from '../components/link';
import Timeline from '../components/timeline'; import Timeline from '../components/timeline';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { fixNotifications } from '../utils/group-notifications';
import { saveStatus } from '../utils/states'; import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
@ -31,8 +30,6 @@ function Mentions({ columnMode, ...props }) {
const results = await mentionsIterator.current.next(); const results = await mentionsIterator.current.next();
let { value } = results; let { value } = results;
if (value?.length) { if (value?.length) {
value = fixNotifications(value);
if (firstLoad) { if (firstLoad) {
latestItem.current = value[0].id; latestItem.current = value[0].id;
console.log('First load', latestItem.current); console.log('First load', latestItem.current);
@ -98,9 +95,7 @@ function Mentions({ columnMode, ...props }) {
latestConversationItem.current, latestConversationItem.current,
value, value,
); );
const valueContainsLatestItem = if (value?.length) {
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

@ -143,7 +143,6 @@
border-color: var(--reply-to-color); border-color: var(--reply-to-color);
box-shadow: 0 0 0 3px var(--reply-to-faded-color); box-shadow: 0 0 0 3px var(--reply-to-faded-color);
} }
.notification:focus-visible .status-link,
.notification .status-link:is(:hover, :focus) { .notification .status-link:is(:hover, :focus) {
background-color: var(--bg-blur-color); background-color: var(--bg-blur-color);
filter: saturate(1); filter: saturate(1);
@ -185,7 +184,7 @@
.notification-group-statuses > li:before { .notification-group-statuses > li:before {
content: counter(index); content: counter(index);
position: absolute; position: absolute;
inset-inline-start: 0; left: 0;
font-size: 10px; font-size: 10px;
padding: 8px; padding: 8px;
font-weight: bold; font-weight: bold;
@ -194,19 +193,16 @@
margin-top: -1px; margin-top: -1px;
} }
.notification-group-statuses > li:not(:last-child) .status-link { .notification-group-statuses > li:not(:last-child) .status-link {
border-end-start-radius: 0; border-bottom-left-radius: 0;
border-end-end-radius: 0; border-bottom-right-radius: 0;
} }
.notification-group-statuses > li:not(:first-child) .status-link { .notification-group-statuses > li:not(:first-child) .status-link {
border-start-start-radius: 0; border-top-left-radius: 0;
border-start-end-radius: 0; border-top-right-radius: 0;
} }
#mentions-option { #mentions-option {
float: right; float: right;
&:dir(rtl) {
float: left;
}
margin-top: 0.5em; margin-top: 0.5em;
} }
#mentions-option label { #mentions-option label {
@ -391,7 +387,7 @@
width: calc(100% - 16px); width: calc(100% - 16px);
} }
.announcements > ul > li:last-child { .announcements > ul > li:last-child {
border-inline-end: none; border-right: none;
} }
.announcements .announcement-block { .announcements .announcement-block {
padding: 16px; padding: 16px;
@ -424,145 +420,3 @@
color: var(--text-color); color: var(--text-color);
background-color: var(--link-faded-color); background-color: var(--link-faded-color);
} }
/* FILTERED NOTIFICATIONS */
.filtered-notifications {
padding-block-end: 16px;
summary {
padding: 8px 16px;
cursor: pointer;
font-weight: 600;
user-select: none;
margin: 16px 0 0;
color: var(--text-insignificant-color);
&::marker,
&::-webkit-details-marker {
color: var(--text-insignificant-color);
}
}
details[open] summary {
color: var(--text-color);
}
summary + ul {
}
ul {
list-style: none;
padding: 0;
margin: 0;
max-height: 50vh;
max-height: 50dvh;
overflow: auto;
border-top: 1px solid var(--outline-color);
border-bottom: 1px solid var(--outline-color);
background-color: var(--bg-faded-color);
@media (min-width: 40em) {
background-color: var(--bg-color);
border-radius: 16px;
border-width: 0;
}
li {
display: flex;
padding: 16px;
row-gap: 8px;
column-gap: 16px;
border-bottom: 1px solid var(--outline-color);
}
li:last-child {
border-bottom: none;
}
.request-notifcations {
min-width: 0;
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
.last-post {
max-width: 100%;
> .status-link {
border-radius: 8px;
overflow: hidden;
--max-height: 160px;
max-height: var(--max-height);
border: 1px solid var(--outline-color);
&:is(:hover, :focus-visible) {
border-color: var(--outline-hover-color);
}
.status {
mask-image: linear-gradient(
to bottom,
black calc(var(--max-height) / 2),
transparent calc(var(--max-height) - 8px)
);
font-size: calc(var(--text-size) * 0.9);
.content-container {
pointer-events: none;
filter: saturate(0.5);
}
}
}
}
.request-notifications-account {
display: flex;
align-items: center;
gap: 4px;
}
}
.notification-request-buttons {
grid-area: buttons;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
button {
max-width: 30vw;
}
.notification-request-states {
min-height: 32px;
text-align: center;
vertical-align: middle;
.icon {
margin-inline: 8px;
&.notification-accepted {
color: var(--green-color);
}
&.notification-dismissed {
color: var(--red-color);
}
}
}
}
}
}
#notifications-settings {
label {
display: flex;
gap: 8px;
align-items: center;
input[type='checkbox'] {
flex-shrink: 0;
}
}
}

View file

@ -3,7 +3,6 @@ import './notifications.css';
import { Fragment } from 'preact'; import { Fragment } from 'preact';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -14,76 +13,24 @@ import FollowRequestButtons from '../components/follow-request-buttons';
import Icon from '../components/icon'; import Icon from '../components/icon';
import Link from '../components/link'; import Link from '../components/link';
import Loader from '../components/loader'; import Loader from '../components/loader';
import Modal from '../components/modal';
import NavMenu from '../components/nav-menu'; import NavMenu from '../components/nav-menu';
import Notification from '../components/notification'; import Notification from '../components/notification';
import Status from '../components/status';
import { api } from '../utils/api'; import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content'; import enhanceContent from '../utils/enhance-content';
import groupNotifications, { import groupNotifications from '../utils/group-notifications';
groupNotifications2,
massageNotifications2,
} from '../utils/group-notifications';
import handleContentLinks from '../utils/handle-content-links'; import handleContentLinks from '../utils/handle-content-links';
import mem from '../utils/mem';
import niceDateTime from '../utils/nice-date-time'; import niceDateTime from '../utils/nice-date-time';
import { getRegistration } from '../utils/push-notifications'; import { getRegistration } from '../utils/push-notifications';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { saveStatus } from '../utils/states'; import states, { saveStatus } from '../utils/states';
import { getCurrentInstance } from '../utils/store-utils'; import { getCurrentInstance } from '../utils/store-utils';
import supports from '../utils/supports';
import usePageVisibility from '../utils/usePageVisibility'; import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll'; import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const NOTIFICATIONS_LIMIT = 80; const LIMIT = 30; // 30 is the maximum limit :(
const NOTIFICATIONS_GROUPED_LIMIT = 20;
const emptySearchParams = new URLSearchParams(); const emptySearchParams = new URLSearchParams();
const scrollIntoViewOptions = {
block: 'center',
inline: 'center',
behavior: 'smooth',
};
const memSupportsGroupedNotifications = mem(
() => supports('@mastodon/grouped-notifications'),
{
maxAge: 1000 * 60 * 5, // 5 minutes
},
);
export function mastoFetchNotifications(opts = {}) {
const { masto } = api();
if (
states.settings.groupedNotificationsAlpha &&
memSupportsGroupedNotifications()
) {
// https://github.com/mastodon/mastodon/pull/29889
return masto.v2_alpha.notifications.list({
limit: NOTIFICATIONS_GROUPED_LIMIT,
...opts,
});
} else {
return masto.v1.notifications.list({
limit: NOTIFICATIONS_LIMIT,
...opts,
});
}
}
export function getGroupedNotifications(notifications) {
if (
states.settings.groupedNotificationsAlpha &&
memSupportsGroupedNotifications()
) {
return groupNotifications2(notifications);
} else {
return groupNotifications(notifications);
}
}
function Notifications({ columnMode }) { function Notifications({ columnMode }) {
useTitle('Notifications', '/notifications'); useTitle('Notifications', '/notifications');
const { masto, instance } = api(); const { masto, instance } = api();
@ -109,19 +56,13 @@ function Notifications({ columnMode }) {
async function fetchNotifications(firstLoad) { async function fetchNotifications(firstLoad) {
if (firstLoad || !notificationsIterator.current) { if (firstLoad || !notificationsIterator.current) {
// Reset iterator // Reset iterator
notificationsIterator.current = mastoFetchNotifications({ notificationsIterator.current = masto.v1.notifications.list({
limit: LIMIT,
excludeTypes: ['follow_request'], excludeTypes: ['follow_request'],
}); });
} }
if (/max_id=($|&)/i.test(notificationsIterator.current?.nextParams)) {
// Pixelfed returns next paginationed link with empty max_id
// I assume, it's done (end of list)
return {
done: true,
};
}
const allNotifications = await notificationsIterator.current.next(); const allNotifications = await notificationsIterator.current.next();
const notifications = massageNotifications2(allNotifications.value); const notifications = allNotifications.value;
if (notifications?.length) { if (notifications?.length) {
notifications.forEach((notification) => { notifications.forEach((notification) => {
@ -130,43 +71,17 @@ function Notifications({ columnMode }) {
}); });
}); });
// TEST: Slot in a fake notification to test 'severed_relationships' const groupedNotifications = groupNotifications(notifications);
// notifications.unshift({
// id: '123123',
// type: 'severed_relationships',
// createdAt: '2024-03-22T19:20:08.316Z',
// event: {
// type: 'account_suspension',
// targetName: 'mastodon.dev',
// followersCount: 0,
// followingCount: 0,
// },
// });
// TEST: Slot in a fake notification to test 'moderation_warning'
// notifications.unshift({
// id: '123123',
// type: 'moderation_warning',
// createdAt: new Date().toISOString(),
// moderation_warning: {
// id: '1231234',
// action: 'mark_statuses_as_sensitive',
// },
// });
// console.log({ notifications });
const groupedNotifications = getGroupedNotifications(notifications);
if (firstLoad) { if (firstLoad) {
states.notificationsLast = groupedNotifications[0]; states.notificationsLast = notifications[0];
states.notifications = groupedNotifications; states.notifications = groupedNotifications;
// Update last read marker // Update last read marker
masto.v1.markers masto.v1.markers
.create({ .create({
notifications: { notifications: {
lastReadId: groupedNotifications[0].id, lastReadId: notifications[0].id,
}, },
}) })
.catch(() => {}); .catch(() => {});
@ -214,28 +129,6 @@ function Notifications({ columnMode }) {
} }
} }
const supportsFilteredNotifications = supports(
'@mastodon/filtered-notifications',
);
const [showNotificationsSettings, setShowNotificationsSettings] =
useState(false);
const [notificationsPolicy, setNotificationsPolicy] = useState({});
function fetchNotificationsPolicy() {
return masto.v1.notifications.policy.fetch().catch(() => {});
}
function loadNotificationsPolicy() {
fetchNotificationsPolicy()
.then((policy) => {
console.log('✨ Notifications policy', policy);
setNotificationsPolicy(policy);
})
.catch(() => {});
}
const [notificationsRequests, setNotificationsRequests] = useState(null);
function fetchNotificationsRequest() {
return masto.v1.notifications.requests.list();
}
const loadNotifications = (firstLoad) => { const loadNotifications = (firstLoad) => {
setShowNew(false); setShowNew(false);
setUIState('loading'); setUIState('loading');
@ -261,10 +154,6 @@ function Notifications({ columnMode }) {
setFollowRequests(requests); setFollowRequests(requests);
}) })
.catch(() => {}); .catch(() => {});
if (supportsFilteredNotifications) {
loadNotificationsPolicy();
}
} }
const { done } = await fetchNotificationsPromise; const { done } = await fetchNotificationsPromise;
@ -272,7 +161,6 @@ function Notifications({ columnMode }) {
setUIState('default'); setUIState('default');
} catch (e) { } catch (e) {
console.error(e);
setUIState('error'); setUIState('error');
} }
})(); })();
@ -321,6 +209,7 @@ 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) {
@ -331,21 +220,17 @@ function Notifications({ columnMode }) {
} else { } else {
lastHiddenTime.current = Date.now(); lastHiddenTime.current = Date.now();
} }
unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
if (v) {
loadUpdates();
}
setShowNew(v);
});
} }
return () => {
unsub?.();
};
}); });
const firstLoad = useRef(true);
useEffect(() => {
let unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
if (firstLoad.current) {
firstLoad.current = false;
return;
}
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);
@ -385,84 +270,11 @@ function Notifications({ columnMode }) {
// } // }
// }, [uiState]); // }, [uiState]);
const itemsSelector = '.notification';
const jRef = useHotkeys('j', () => {
const activeItem = document.activeElement.closest(itemsSelector);
const activeItemRect = activeItem?.getBoundingClientRect();
const allItems = Array.from(
scrollableRef.current.querySelectorAll(itemsSelector),
);
if (
activeItem &&
activeItemRect.top < scrollableRef.current.clientHeight &&
activeItemRect.bottom > 0
) {
const activeItemIndex = allItems.indexOf(activeItem);
let nextItem = allItems[activeItemIndex + 1];
if (nextItem) {
nextItem.focus();
nextItem.scrollIntoView(scrollIntoViewOptions);
}
} else {
const topmostItem = allItems.find((item) => {
const itemRect = item.getBoundingClientRect();
return itemRect.top >= 44 && itemRect.left >= 0;
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoView(scrollIntoViewOptions);
}
}
});
const kRef = useHotkeys('k', () => {
// focus on previous status after active item
const activeItem = document.activeElement.closest(itemsSelector);
const activeItemRect = activeItem?.getBoundingClientRect();
const allItems = Array.from(
scrollableRef.current.querySelectorAll(itemsSelector),
);
if (
activeItem &&
activeItemRect.top < scrollableRef.current.clientHeight &&
activeItemRect.bottom > 0
) {
const activeItemIndex = allItems.indexOf(activeItem);
let prevItem = allItems[activeItemIndex - 1];
if (prevItem) {
prevItem.focus();
prevItem.scrollIntoView(scrollIntoViewOptions);
}
} else {
const topmostItem = allItems.find((item) => {
const itemRect = item.getBoundingClientRect();
return itemRect.top >= 44 && itemRect.left >= 0;
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoView(scrollIntoViewOptions);
}
}
});
const oRef = useHotkeys(['enter', 'o'], () => {
const activeItem = document.activeElement.closest(itemsSelector);
const statusLink = activeItem?.querySelector('.status-link');
if (statusLink) {
statusLink.click();
}
});
return ( return (
<div <div
id="notifications-page" id="notifications-page"
class="deck-container" class="deck-container"
ref={(node) => { ref={scrollableRef}
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
oRef.current = node;
}}
tabIndex="-1" tabIndex="-1"
> >
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}> <div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
@ -489,17 +301,7 @@ function Notifications({ columnMode }) {
</div> </div>
<h1>Notifications</h1> <h1>Notifications</h1>
<div class="header-side"> <div class="header-side">
{supportsFilteredNotifications && ( {/* <Loader hidden={uiState !== 'loading'} /> */}
<button
type="button"
class="button plain4"
onClick={() => {
setShowNotificationsSettings(true);
}}
>
<Icon icon="settings" size="l" alt="Notifications settings" />
</button>
)}
</div> </div>
</div> </div>
{showNew && uiState !== 'loading' && ( {showNew && uiState !== 'loading' && (
@ -604,76 +406,6 @@ function Notifications({ columnMode }) {
)} )}
</div> </div>
)} )}
{supportsFilteredNotifications &&
notificationsPolicy?.summary?.pendingRequestsCount > 0 && (
<div class="shazam-container">
<div class="shazam-container-inner">
<div class="filtered-notifications">
<details
onToggle={async (e) => {
const { open } = e.target;
if (open) {
const requests = await fetchNotificationsRequest();
setNotificationsRequests(requests);
console.log({ open, requests });
}
}}
>
<summary>
Filtered notifications from{' '}
{notificationsPolicy.summary.pendingRequestsCount} people
</summary>
{!notificationsRequests ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : (
notificationsRequests?.length > 0 && (
<ul>
{notificationsRequests.map((request) => (
<li key={request.id}>
<div class="request-notifcations">
{!request.lastStatus?.id && (
<AccountBlock
useAvatarStatic
showStats
account={request.account}
/>
)}
{request.lastStatus?.id && (
<div class="last-post">
<Link
class="status-link"
to={`/${instance}/s/${request.lastStatus.id}`}
>
<Status
status={request.lastStatus}
size="s"
readOnly
/>
</Link>
</div>
)}
<NotificationRequestModalButton
request={request}
/>
</div>
<NotificationRequestButtons
request={request}
onChange={() => {
loadNotifications(true);
}}
/>
</li>
))}
</ul>
)
)}
</details>
</div>
</div>
</div>
)}
<div id="mentions-option"> <div id="mentions-option">
<label> <label>
<input <input
@ -687,7 +419,7 @@ function Notifications({ columnMode }) {
</label> </label>
</div> </div>
<h2 class="timeline-header">Today</h2> <h2 class="timeline-header">Today</h2>
{showTodayEmpty && ( {showTodayEmpty && !!snapStates.notifications.length && (
<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>
@ -717,12 +449,12 @@ function Notifications({ columnMode }) {
hideTime: true, hideTime: true,
}); });
return ( return (
<Fragment key={notification._ids || notification.id}> <Fragment key={notification.id}>
{differentDay && <h2 class="timeline-header">{heading}</h2>} {differentDay && <h2 class="timeline-header">{heading}</h2>}
<Notification <Notification
instance={instance} instance={instance}
notification={notification} notification={notification}
key={notification._ids || notification.id} key={notification.id}
/> />
</Fragment> </Fragment>
); );
@ -782,109 +514,6 @@ function Notifications({ columnMode }) {
</InView> </InView>
)} )}
</div> </div>
{supportsFilteredNotifications && showNotificationsSettings && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowNotificationsSettings(false);
}
}}
>
<div class="sheet" id="notifications-settings" tabIndex="-1">
<button
type="button"
class="sheet-close"
onClick={() => setShowNotificationsSettings(false)}
>
<Icon icon="x" />
</button>
<header>
<h2>Notifications settings</h2>
</header>
<main>
<form
onSubmit={(e) => {
e.preventDefault();
const {
filterNotFollowing,
filterNotFollowers,
filterNewAccounts,
filterPrivateMentions,
} = e.target;
const allFilters = {
filterNotFollowing: filterNotFollowing.checked,
filterNotFollowers: filterNotFollowers.checked,
filterNewAccounts: filterNewAccounts.checked,
filterPrivateMentions: filterPrivateMentions.checked,
};
setNotificationsPolicy({
...notificationsPolicy,
...allFilters,
});
setShowNotificationsSettings(false);
(async () => {
try {
await masto.v1.notifications.policy.update(allFilters);
showToast('Notifications settings updated');
} catch (e) {
console.error(e);
}
})();
}}
>
<p>Filter out notifications from people:</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterNotFollowing}
name="filterNotFollowing"
/>{' '}
You don't follow
</label>
</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterNotFollowers}
name="filterNotFollowers"
/>{' '}
Who don't follow you
</label>
</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterNewAccounts}
name="filterNewAccounts"
/>{' '}
With a new account
</label>
</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterPrivateMentions}
name="filterPrivateMentions"
/>{' '}
Who unsolicitedly private mention you
</label>
</p>
<p>
<button type="submit">Save</button>
</p>
</form>
</main>
</div>
</Modal>
)}
</div> </div>
); );
} }
@ -967,186 +596,4 @@ function AnnouncementBlock({ announcement }) {
); );
} }
function fetchNotficationsByAccount(accountID) {
const { masto } = api();
return masto.v1.notifications.list({
accountID,
});
}
function NotificationRequestModalButton({ request }) {
const { instance } = api();
const [uiState, setUIState] = useState('loading');
const { account, lastStatus } = request;
const [showModal, setShowModal] = useState(false);
const [notifications, setNotifications] = useState([]);
function onClose() {
setShowModal(false);
}
useEffect(() => {
if (!request?.account?.id) return;
if (!showModal) return;
setUIState('loading');
(async () => {
const notifs = await fetchNotficationsByAccount(request.account.id);
setNotifications(notifs || []);
setUIState('default');
})();
}, [showModal, request?.account?.id]);
return (
<>
<button
type="button"
class="plain4 request-notifications-account"
onClick={() => {
setShowModal(true);
}}
>
<Icon icon="notification" class="more-insignificant" />{' '}
<small>View notifications from @{account.username}</small>{' '}
<Icon icon="chevron-down" />
</button>
{showModal && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div class="sheet" tabIndex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<b>Notifications from @{account.username}</b>
</header>
<main>
{uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : (
notifications.map((notification) => (
<div
class="notification-peek"
onClick={(e) => {
const { target } = e;
// If button or links
if (
e.target.tagName === 'BUTTON' ||
e.target.tagName === 'A'
) {
onClose();
}
}}
>
<Notification
instance={instance}
notification={notification}
isStatic
/>
</div>
))
)}
</main>
</div>
</Modal>
)}
</>
);
}
function NotificationRequestButtons({ request, onChange }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const [requestState, setRequestState] = useState(null); // accept, dismiss
const hasRequestState = requestState !== null;
return (
<p class="notification-request-buttons">
<button
type="button"
disabled={uiState === 'loading' || hasRequestState}
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.notifications.requests
.$select(request.id)
.accept();
setRequestState('accept');
setUIState('default');
onChange({
request,
state: 'accept',
});
showToast(
`Notifications from @${request.account.username} will not be filtered from now on.`,
);
} catch (error) {
setUIState('error');
console.error(error);
showToast(`Unable to accept notification request`);
}
})();
}}
>
Allow
</button>{' '}
<button
type="button"
disabled={uiState === 'loading' || hasRequestState}
class="light danger"
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.notifications.requests
.$select(request.id)
.dismiss();
setRequestState('dismiss');
setUIState('default');
onChange({
request,
state: 'dismiss',
});
showToast(
`Notifications from @${request.account.username} will not show up in Filtered notifications from now on.`,
);
} catch (error) {
setUIState('error');
console.error(error);
showToast(`Unable to dismiss notification request`);
}
})();
}}
>
Dismiss
</button>
<span class="notification-request-states">
{uiState === 'loading' ? (
<Loader abrupt />
) : requestState === 'accept' ? (
<Icon
icon="check-circle"
alt="Accepted"
class="notification-accepted"
/>
) : (
requestState === 'dismiss' && (
<Icon
icon="x-circle"
alt="Dismissed"
class="notification-dismissed"
/>
)
)}
</span>
</p>
);
}
export default memo(Notifications); export default memo(Notifications);

View file

@ -8,8 +8,8 @@ import Menu2 from '../components/menu2';
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 states, { saveStatus } from '../utils/states'; import states from '../utils/states';
import supports from '../utils/supports'; import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const LIMIT = 20; const LIMIT = 20;
@ -30,14 +30,10 @@ function Public({ local, columnMode, ...props }) {
const publicIterator = useRef(); const publicIterator = useRef();
async function fetchPublic(firstLoad) { async function fetchPublic(firstLoad) {
if (firstLoad || !publicIterator.current) { if (firstLoad || !publicIterator.current) {
const opts = { publicIterator.current = masto.v1.timelines.public.list({
limit: LIMIT, limit: LIMIT,
local: isLocal || undefined, local: isLocal,
}; });
if (!isLocal && supports('@pixelfed/global-feed')) {
opts.remote = true;
}
publicIterator.current = masto.v1.timelines.public.list(opts);
} }
const results = await publicIterator.current.next(); const results = await publicIterator.current.next();
let { value } = results; let { value } = results;
@ -67,9 +63,8 @@ function Public({ local, columnMode, ...props }) {
}) })
.next(); .next();
let { value } = results; let { value } = results;
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported value = filteredItems(value, 'public');
if (value?.length && !valueContainsLatestItem) { if (value?.length) {
value = filteredItems(value, 'public');
return true; return true;
} }
return false; return false;

View file

@ -48,10 +48,10 @@
a { a {
.icon { .icon {
vertical-align: middle; vertical-align: middle;
transition: margin 0.2s; transition: transform 0.2s;
} }
&:hover .icon { &:hover .icon {
margin-inline-start: 4px; transform: translateX(4px);
} }
} }
} }
@ -101,8 +101,9 @@ ul.link-list.hashtag-list li a {
} }
.search-popover { .search-popover {
position: absolute; position: absolute;
inset-inline-start: 8px; left: 8px;
max-width: calc(100% - 16px); max-width: calc(100% - 16px);
/* right: 8px; */
background-color: var(--bg-color); background-color: var(--bg-color);
border: 1px solid var(--outline-color); border: 1px solid var(--outline-color);
box-shadow: 0 4px 24px var(--drop-shadow-color); box-shadow: 0 4px 24px var(--drop-shadow-color);
@ -117,8 +118,7 @@ ul.link-list.hashtag-list li a {
} }
.search-popover-item { .search-popover-item {
text-decoration: none; text-decoration: none;
padding: 8px; padding: 8px 16px 8px 8px;
padding-inline-end: 16px;
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
@ -132,15 +132,10 @@ ul.link-list.hashtag-list li a {
} }
.search-popover-item:is(:focus, .focus) { .search-popover-item:is(:focus, .focus) {
box-shadow: inset 4px 0 0 0 var(--button-bg-color); box-shadow: inset 4px 0 0 0 var(--button-bg-color);
:dir(rtl) & {
box-shadow: inset -4px 0 0 0 var(--button-bg-color);
}
} }
.search-popover-item :is(mark, q) { .search-popover-item :is(mark, q) {
color: var(--text-color); color: var(--text-color);
background-color: var(--link-bg-color); background-color: var(--link-bg-color);
unicode-bidi: isolate;
direction: initial;
} }
.search-popover-item:is(:hover, :focus, .focus) :is(mark, q) { .search-popover-item:is(:hover, :focus, .focus) :is(mark, q) {
background-color: var(--link-bg-color); background-color: var(--link-bg-color);

View file

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

View file

@ -36,12 +36,12 @@
border-bottom: var(--hairline-width) solid var(--outline-color); border-bottom: var(--hairline-width) solid var(--outline-color);
} }
#settings-container section > ul > li > div:last-child { #settings-container section > ul > li > div:last-child {
text-align: end; text-align: right;
} }
#settings-container section > ul > li .sub-section { #settings-container section > ul > li .sub-section {
text-align: start !important; text-align: left !important;
margin-top: 8px; margin-top: 8px;
margin-inline-start: 24px; margin-left: 24px;
} }
#settings-container section > ul > li .sub-section p { #settings-container section > ul > li .sub-section p {
margin-block: 0.5em; margin-block: 0.5em;
@ -121,11 +121,11 @@
grid-template-rows: 1fr 1fr; grid-template-rows: 1fr 1fr;
> span:first-child { > span:first-child {
text-align: start; text-align: left;
} }
> span:last-child { > span:last-child {
text-align: end; text-align: right;
} }
} }
} }

View file

@ -21,7 +21,6 @@ import {
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
import states from '../utils/states'; import states from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import supports from '../utils/supports';
const DEFAULT_TEXT_SIZE = 16; const DEFAULT_TEXT_SIZE = 16;
const TEXT_SIZES = [14, 15, 16, 17, 18, 19, 20]; const TEXT_SIZES = [14, 15, 16, 17, 18, 19, 20];
@ -29,7 +28,6 @@ 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 }) {
@ -435,37 +433,6 @@ 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>
@ -497,27 +464,6 @@ function Settings({ onClose }) {
</div> </div>
</li> </li>
)} )}
{authenticated && supports('@mastodon/grouped-notifications') && (
<li>
<label>
<input
type="checkbox"
checked={snapStates.settings.groupedNotificationsAlpha}
onChange={(e) => {
states.settings.groupedNotificationsAlpha =
e.target.checked;
}}
/>{' '}
Server-side grouped notifications
</label>
<div class="sub-section insignificant">
<small>
Alpha-stage feature. Potentially improved grouping window
but basic grouping logic.
</small>
</div>
</li>
)}
{authenticated && ( {authenticated && (
<li> <li>
<label> <label>
@ -748,10 +694,9 @@ 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) => {
@ -780,68 +725,65 @@ function PushNotificationsSection({ onClose }) {
<form <form
ref={pushFormRef} ref={pushFormRef}
onChange={() => { onChange={() => {
setTimeout(() => { const values = Object.fromEntries(new FormData(pushFormRef.current));
const values = Object.fromEntries(new FormData(pushFormRef.current)); const allowNotifications = !!values['policy-allow'];
const allowNotifications = !!values['policy-allow']; const params = {
const params = { policy: values.policy,
data: { data: {
policy: values.policy, alerts: {
alerts: { mention: !!values.mention,
mention: !!values.mention, favourite: !!values.favourite,
favourite: !!values.favourite, reblog: !!values.reblog,
reblog: !!values.reblog, follow: !!values.follow,
follow: !!values.follow, follow_request: !!values.followRequest,
follow_request: !!values.followRequest, poll: !!values.poll,
poll: !!values.poll, update: !!values.update,
update: !!values.update, status: !!values.status,
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 = const policyChanged = previousPolicyRef.current !== params.policy;
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 {
removeSubscription().catch((err) => { updateSubscription(params).catch((err) => {
console.warn(err); console.warn(err);
alert('Failed to remove subscription. Please try again.'); alert('Failed to update subscription. Please try again.');
}); });
} }
}, 100); } else {
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

@ -11,7 +11,7 @@
align-self: stretch; align-self: stretch;
} }
header h1 .deck-back { header h1 .deck-back {
margin-inline-start: -16px; margin-left: -16px;
} }
.button-refresh .icon { .button-refresh .icon {
@ -23,6 +23,12 @@
} }
} }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.hero-heading { .hero-heading {
font-size: var(--text-size); font-size: var(--text-size);
display: inline-block; display: inline-block;
@ -39,7 +45,7 @@
font-size: 70% !important; font-size: 70% !important;
& > .avatar ~ .avatar { & > .avatar ~ .avatar {
margin-inline-start: -4px; margin-left: -4px;
} }
} }
.ancestors-indicator:not([hidden]) { .ancestors-indicator:not([hidden]) {

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
? snapStates.statuses[statusKey(mediaStatusID, instance)]?.mediaAttachments ? mediaStatus?.mediaAttachments
: heroStatus?.mediaAttachments; : heroStatus?.mediaAttachments;
const handleMediaClose = useCallback(() => { const handleMediaClose = useCallback(() => {
@ -153,18 +153,6 @@ function StatusPage(params) {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [showMediaOnly]); }, [showMediaOnly]);
useEffect(() => {
const $deckContainers = document.querySelectorAll('.deck-container');
$deckContainers.forEach(($deckContainer) => {
$deckContainer.setAttribute('inert', '');
});
return () => {
$deckContainers.forEach(($deckContainer) => {
$deckContainer.removeAttribute('inert');
});
};
}, []);
return ( return (
<div class="deck-backdrop"> <div class="deck-backdrop">
{showMedia ? ( {showMedia ? (
@ -569,7 +557,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
if (!heroStatus) return; if (!heroStatus) return;
const { url } = heroStatus; const { url } = heroStatus;
if (!url) return; if (!url) return;
return URL.parse(url).hostname; return new URL(url).hostname;
}, [heroStatus]); }, [heroStatus]);
const postSameInstance = useMemo(() => { const postSameInstance = useMemo(() => {
if (!postInstance) return; if (!postInstance) return;
@ -984,18 +972,6 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
[statuses, limit, renderStatus], [statuses, limit, renderStatus],
); );
// If there's spoiler in hero status, auto-expand it
useEffect(() => {
let timer = setTimeout(() => {
if (!heroStatusRef.current) return;
const spoilerButton = heroStatusRef.current.querySelector(
'.spoiler-button:not(.spoiling), .spoiler-media-button:not(.spoiling)',
);
if (spoilerButton) spoilerButton.click();
}, 1000);
return () => clearTimeout(timer);
}, [id]);
return ( return (
<div <div
tabIndex="-1" tabIndex="-1"
@ -1232,7 +1208,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
{postInstance ? ( {postInstance ? (
<> <>
{' '} {' '}
(<b>{punycode.toUnicode(postInstance)}</b>) (<b>{postInstance}</b>)
</> </>
) : ( ) : (
'' ''
@ -1370,8 +1346,6 @@ function SubComments({
const detailsRef = useRef(); const detailsRef = useRef();
useLayoutEffect(() => { useLayoutEffect(() => {
function handleScroll(e) { function handleScroll(e) {
// NOTE: this scrollLeft works for RTL too
// Browsers do the magic for us
e.target.dataset.scrollLeft = e.target.scrollLeft; e.target.dataset.scrollLeft = e.target.scrollLeft;
} }
detailsRef.current?.addEventListener('scroll', handleScroll, { detailsRef.current?.addEventListener('scroll', handleScroll, {

View file

@ -1,55 +0,0 @@
#trending-page {
.timeline-header-block {
display: flex;
gap: 12px;
align-items: center;
padding: 16px;
&.blended {
background-image: linear-gradient(
to bottom,
var(--bg-faded-color),
transparent
);
}
@media (min-width: 40em) {
padding: 0 16px;
}
&.loading {
color: var(--text-insignificant-color);
}
p {
margin: 0;
padding: 0;
flex-grow: 1;
min-width: 0;
}
.link-text {
color: var(--text-insignificant-color);
display: block;
font-weight: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.9em;
}
}
.timeline {
transition: opacity 0.3s ease-in-out;
}
.timeline.loading {
pointer-events: none;
opacity: 0.2;
}
.timeline-link-mentions {
.status .card {
display: none;
}
}
}

View file

@ -1,16 +1,13 @@
import '../components/links-bar.css'; import '../components/links-bar.css';
import './trending.css';
import { MenuItem } from '@szhsin/react-menu'; import { MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash'; import { getBlurHashAverageColor } from 'fast-blurhash';
import { useEffect, 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';
import Icon from '../components/icon'; import Icon from '../components/icon';
import Link from '../components/link'; import Link from '../components/link';
import Loader from '../components/loader';
import Menu2 from '../components/menu2'; import Menu2 from '../components/menu2';
import RelativeTime from '../components/relative-time'; import RelativeTime from '../components/relative-time';
import Timeline from '../components/timeline'; import Timeline from '../components/timeline';
@ -19,46 +16,22 @@ import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
import { filteredItems } from '../utils/filters'; import { filteredItems } from '../utils/filters';
import pmem from '../utils/pmem'; import pmem from '../utils/pmem';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import states, { saveStatus } from '../utils/states'; import states from '../utils/states';
import supports from '../utils/supports'; import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const LIMIT = 20; const LIMIT = 20;
const TREND_CACHE_TIME = 10 * 60 * 1000; // 10 minutes
const fetchLinks = pmem( const fetchLinks = pmem(
(masto) => { (masto) => {
return masto.v1.trends.links.list().next(); return masto.v1.trends.links.list().next();
}, },
{ {
maxAge: TREND_CACHE_TIME, // News last much longer
maxAge: 10 * 60 * 1000, // 10 minutes
}, },
); );
const fetchHashtags = pmem(
(masto) => {
return masto.v1.trends.tags.list().next();
},
{
maxAge: TREND_CACHE_TIME,
},
);
function fetchTrendsStatuses(masto) {
if (supports('@pixelfed/trending')) {
return masto.pixelfed.v2.discover.posts.trending.list({
range: 'daily',
});
}
return masto.v1.trends.statuses.list({
limit: LIMIT,
});
}
function fetchLinkList(masto, params) {
return masto.v1.timelines.link.list(params);
}
function Trending({ columnMode, ...props }) { function Trending({ columnMode, ...props }) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const params = columnMode ? {} : useParams(); const params = columnMode ? {} : useParams();
@ -71,45 +44,39 @@ function Trending({ columnMode, ...props }) {
// const navigate = useNavigate(); // const navigate = useNavigate();
const latestItem = useRef(); const latestItem = useRef();
const sameCurrentInstance = instance === currentInstance;
const [hashtags, setHashtags] = useState([]); const [hashtags, setHashtags] = useState([]);
const [links, setLinks] = useState([]); const [links, setLinks] = useState([]);
const trendIterator = useRef(); const trendIterator = useRef();
async function fetchTrend(firstLoad) {
async function fetchTrends(firstLoad) {
console.log('fetchTrend', firstLoad);
if (firstLoad || !trendIterator.current) { if (firstLoad || !trendIterator.current) {
trendIterator.current = fetchTrendsStatuses(masto); trendIterator.current = masto.v1.trends.statuses.list({
limit: LIMIT,
});
// Get hashtags // Get hashtags
if (supports('@mastodon/trending-hashtags')) { try {
try { const iterator = masto.v1.trends.tags.list();
// const iterator = masto.v1.trends.tags.list(); const { value: tags } = await iterator.next();
const { value: tags } = await fetchHashtags(masto); console.log('tags', tags);
console.log('tags', tags); if (tags?.length) {
if (tags?.length) { setHashtags(tags);
setHashtags(tags);
}
} catch (e) {
console.error(e);
} }
} catch (e) {
console.error(e);
} }
// Get links // Get links
if (supports('@mastodon/trending-links')) { try {
try { const { value } = await fetchLinks(masto, instance);
const { value } = await fetchLinks(masto, instance); // 4 types available: link, photo, video, rich
// 4 types available: link, photo, video, rich // Only want links for now
// Only want links for now const links = value?.filter?.((link) => link.type === 'link');
const links = value?.filter?.((link) => link.type === 'link'); console.log('links', links);
console.log('links', links); if (links?.length) {
if (links?.length) { setLinks(links);
setLinks(links);
}
} catch (e) {
console.error(e);
} }
} catch (e) {
console.error(e);
} }
} }
const results = await trendIterator.current.next(); const results = await trendIterator.current.next();
@ -130,53 +97,6 @@ function Trending({ columnMode, ...props }) {
}; };
} }
// Link mentions
// https://github.com/mastodon/mastodon/pull/30381
const [currentLinkMentionsLoading, setCurrentLinkMentionsLoading] =
useState(false);
const currentLinkMentionsIterator = useRef();
const [currentLink, setCurrentLink] = useState(null);
const hasCurrentLink = !!currentLink;
const currentLinkRef = useRef();
const supportsTrendingLinkPosts =
sameCurrentInstance && supports('@mastodon/trending-hashtags');
useEffect(() => {
if (currentLink && currentLinkRef.current) {
currentLinkRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
}
}, [currentLink]);
const prevCurrentLink = useRef();
async function fetchLinkMentions(firstLoad) {
if (firstLoad || !currentLinkMentionsIterator.current) {
setCurrentLinkMentionsLoading(true);
currentLinkMentionsIterator.current = fetchLinkList(masto, {
url: currentLink,
});
}
prevCurrentLink.current = currentLink;
const results = await currentLinkMentionsIterator.current.next();
let { value } = results;
if (value?.length) {
value = filteredItems(value, 'public');
value.forEach((item) => {
saveStatus(item, instance);
});
}
if (prevCurrentLink.current === currentLink) {
setCurrentLinkMentionsLoading(false);
}
return {
...results,
value,
};
}
async function checkForUpdates() { async function checkForUpdates() {
try { try {
const results = await masto.v1.trends.statuses const results = await masto.v1.trends.statuses
@ -209,7 +129,7 @@ function Trending({ columnMode, ...props }) {
const total = history.reduce((acc, cur) => acc + +cur.uses, 0); const total = history.reduce((acc, cur) => acc + +cur.uses, 0);
return ( return (
<Link to={`/${instance}/t/${name}`} key={name}> <Link to={`/${instance}/t/${name}`} key={name}>
<span dir="auto"> <span>
<span class="more-insignificant">#</span> <span class="more-insignificant">#</span>
{name} {name}
</span> </span>
@ -241,11 +161,9 @@ function Trending({ columnMode, ...props }) {
url, url,
width, width,
} = link; } = link;
const domain = punycode.toUnicode( const domain = new URL(url).hostname
URL.parse(url) .replace(/^www\./, '')
.hostname.replace(/^www\./, '') .replace(/\/$/, '');
.replace(/\/$/, ''),
);
let accentColor; let accentColor;
if (blurhash) { if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash); const averageColor = getBlurHashAverageColor(blurhash);
@ -258,134 +176,67 @@ function Trending({ columnMode, ...props }) {
} }
return ( return (
<div key={url}> <a
<a key={url}
ref={currentLink === url ? currentLinkRef : null} href={url}
href={url} target="_blank"
target="_blank" rel="noopener noreferrer"
rel="noopener noreferrer" style={
class={ accentColor
hasCurrentLink ? {
? currentLink === url '--accent-color': `rgb(${accentColor.join(',')})`,
? 'active' '--accent-alpha-color': `rgba(${accentColor.join(
: 'inactive' ',',
: '' )}, 0.4)`,
} }
style={ : {}
accentColor }
? { >
'--accent-color': `rgb(${accentColor.join(',')})`, <article>
'--accent-alpha-color': `rgba(${accentColor.join( <figure>
',', <img
)}, 0.4)`, src={image}
} alt={imageDescription}
: {} width={width}
} height={height}
> loading="lazy"
<article> />
<figure> </figure>
<img <div class="article-body">
src={image} <header>
alt={imageDescription} <div class="article-meta">
width={width} <span class="domain">{domain}</span>{' '}
height={height} {!!publishedAt && <>&middot; </>}
loading="lazy" {!!publishedAt && (
/> <>
</figure> <RelativeTime
<div class="article-body"> datetime={publishedAt}
<header> format="micro"
<div class="article-meta"> />
<span class="domain">{domain}</span>{' '} </>
{!!publishedAt && <>&middot; </>}
{!!publishedAt && (
<>
<RelativeTime
datetime={publishedAt}
format="micro"
/>
</>
)}
</div>
{!!title && (
<h1
class="title"
lang={language}
dir="auto"
title={title}
>
{title}
</h1>
)} )}
</header> </div>
{!!description && ( {!!title && (
<p <h1 class="title" lang={language} dir="auto">
class="description" {title}
lang={language} </h1>
dir="auto"
title={description}
>
{description}
</p>
)} )}
</div> </header>
</article> {!!description && (
</a> <p class="description" lang={language} dir="auto">
{supportsTrendingLinkPosts && ( {description}
<button </p>
type="button" )}
class="small plain4 block" </div>
onClick={() => { </article>
setCurrentLink(url); </a>
}}
disabled={url === currentLink}
>
<Icon icon="comment2" /> <span>Mentions</span>{' '}
<Icon icon="chevron-down" />
</button>
)}
</div>
); );
})} })}
</div> </div>
)} )}
{supportsTrendingLinkPosts && !!links.length && (
<div
class={`timeline-header-block ${hasCurrentLink ? 'blended' : ''}`}
>
{hasCurrentLink ? (
<>
<div style={{ width: 50, flexShrink: 0, textAlign: 'center' }}>
{currentLinkMentionsLoading ? (
<Loader abrupt />
) : (
<button
type="button"
class="light"
onClick={() => {
setCurrentLink(null);
}}
>
<Icon icon="x" />
</button>
)}
</div>
<p>
Showing posts mentioning{' '}
<span class="link-text">
{currentLink
.replace(/^https?:\/\/(www\.)?/i, '')
.replace(/\/$/, '')}
</span>
</p>
</>
) : (
<p class="insignificant">Trending posts</p>
)}
</div>
)}
</> </>
); );
}, [hashtags, links, currentLink, currentLinkMentionsLoading]); }, [hashtags, links]);
return ( return (
<Timeline <Timeline
@ -401,8 +252,8 @@ function Trending({ columnMode, ...props }) {
instance={instance} instance={instance}
emptyText="No trending posts." emptyText="No trending posts."
errorText="Unable to load posts" errorText="Unable to load posts"
fetchItems={hasCurrentLink ? fetchLinkMentions : fetchTrends} fetchItems={fetchTrend}
checkForUpdates={hasCurrentLink ? undefined : checkForUpdates} checkForUpdates={checkForUpdates}
checkForUpdatesInterval={5 * 60 * 1000} // 5 minutes checkForUpdatesInterval={5 * 60 * 1000} // 5 minutes
useItemID useItemID
headerStart={<></>} headerStart={<></>}
@ -410,9 +261,6 @@ function Trending({ columnMode, ...props }) {
// allowFilters // allowFilters
filterContext="public" filterContext="public"
timelineStart={TimelineStart} timelineStart={TimelineStart}
refresh={currentLink}
clearWhenRefresh
view={hasCurrentLink ? 'link-mentions' : undefined}
headerEnd={ headerEnd={
<Menu2 <Menu2
portal portal

View file

@ -140,7 +140,7 @@
height: auto; height: auto;
max-height: none; max-height: none;
position: fixed; position: fixed;
inset-inline-start: 0; left: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 50%; width: 50%;
@ -153,9 +153,8 @@
} }
#why-container { #why-container {
padding: 32px; padding: 32px 32px 32px 8px;
padding-inline-start: 8px; margin-left: 50%;
margin-inline-start: 50%;
/* overflow: auto; /* overflow: auto;
mask-image: linear-gradient(to top, transparent 16px, black 64px); */ mask-image: linear-gradient(to top, transparent 16px, black 64px); */

View file

@ -1,24 +0,0 @@
// AbortSignal.timeout polyfill
// Temporary fix from https://github.com/mo/abortcontroller-polyfill/issues/73#issuecomment-1541180943
// Incorrect implementation, but should be good enough for now
if ('AbortSignal' in window) {
AbortSignal.timeout =
AbortSignal.timeout ||
((duration) => {
const controller = new AbortController();
setTimeout(() => controller.abort(), duration);
return controller.signal;
});
}
// URL.parse() polyfill
if ('URL' in window && typeof URL.parse !== 'function') {
URL.parse = function (url, base) {
if (!url) return null;
try {
return base ? new URL(url, base) : new URL(url);
} catch (e) {
return null;
}
};
}

Some files were not shown because too many files have changed in this diff Show more