kopia lustrzana https://github.com/cheeaun/phanpy
Porównaj commity
99 Commity
2024.03.28
...
main
Autor | SHA1 | Data |
---|---|---|
Lim Chee Aun | ac760265da | |
Lim Chee Aun | 98b0ccf032 | |
Lim Chee Aun | 90f06c511a | |
Lim Chee Aun | e7aad03279 | |
Lim Chee Aun | 1c6b0aa0d7 | |
Lim Chee Aun | 3e1b9ff53d | |
Lim Chee Aun | 5c9a47c31e | |
Lim Chee Aun | 65a4c3441c | |
Lim Chee Aun | 77bc06545c | |
Lim Chee Aun | 11e64a2cc4 | |
Lim Chee Aun | 5433e4e119 | |
Lim Chee Aun | c8dc32b884 | |
Lim Chee Aun | 1f29aee26e | |
Lim Chee Aun | daae055f4d | |
Chee Aun | 044f754d7e | |
Mick O'Brien | 5ae2058c07 | |
Lim Chee Aun | 7376cb1e99 | |
Lim Chee Aun | ffbae70178 | |
Lim Chee Aun | 9235d2c800 | |
Lim Chee Aun | 6ccefaebe1 | |
Lim Chee Aun | 5a448c8049 | |
Lim Chee Aun | 9bf77fa97a | |
Lim Chee Aun | b9058c6e3d | |
Lim Chee Aun | 55ad6500bc | |
Lim Chee Aun | f4b95d254c | |
Lim Chee Aun | effbe189e1 | |
Lim Chee Aun | 44e910b8c9 | |
Lim Chee Aun | a68dccd7cf | |
Lim Chee Aun | 9a6364a674 | |
Lim Chee Aun | e2f39596f0 | |
Lim Chee Aun | 701b9e99b3 | |
Lim Chee Aun | 294ab2bf00 | |
Lim Chee Aun | 304ce5a3e8 | |
Lim Chee Aun | 57390a291b | |
Lim Chee Aun | cd5920114f | |
Lim Chee Aun | 06c6360cae | |
Lim Chee Aun | afdfdb86da | |
Lim Chee Aun | 6f8f3e4fd0 | |
Lim Chee Aun | 342ff20986 | |
Lim Chee Aun | 94996d098e | |
Lim Chee Aun | c286562ee8 | |
Lim Chee Aun | 5babdc9d63 | |
Lim Chee Aun | 260bb8746d | |
Lim Chee Aun | 7be620808f | |
Lim Chee Aun | df3aca70fa | |
Lim Chee Aun | ec65163c89 | |
Lim Chee Aun | 6f22ec3842 | |
Lim Chee Aun | 2faf9b4c20 | |
Lim Chee Aun | 501e43207b | |
Lim Chee Aun | e782cc0dde | |
Lim Chee Aun | aefda31c2a | |
Lim Chee Aun | 9285a0ba9a | |
Chee Aun | 7fb56d9f6c | |
steve mookie kong | f7c69e56e9 | |
Lim Chee Aun | c3bcf3d595 | |
Lim Chee Aun | 0efa39b825 | |
Lim Chee Aun | a0d2037007 | |
Lim Chee Aun | 6e73728e2b | |
Lim Chee Aun | 60920966d6 | |
Lim Chee Aun | 5083463942 | |
Lim Chee Aun | 8b5fee3dfd | |
Lim Chee Aun | c9124bf150 | |
Lim Chee Aun | b85174155c | |
Lim Chee Aun | 5c9f6bae3c | |
Lim Chee Aun | 4e5940900e | |
Lim Chee Aun | 7fa0b4f076 | |
Lim Chee Aun | ecfcc68f15 | |
Lim Chee Aun | 015ed5e7eb | |
Lim Chee Aun | 2ad9706304 | |
Lim Chee Aun | 30382d088b | |
Lim Chee Aun | 80196f83ca | |
Lim Chee Aun | 419ad34250 | |
Lim Chee Aun | ed0d714cf2 | |
Lim Chee Aun | 708976a9e9 | |
Lim Chee Aun | d77ba19308 | |
Lim Chee Aun | b10e22a9a2 | |
Lim Chee Aun | 36d8b62e1e | |
Lim Chee Aun | 989e788d8e | |
Lim Chee Aun | ebd9f05f69 | |
Lim Chee Aun | 5246af4ae9 | |
Lim Chee Aun | e6ba72f4c8 | |
Lim Chee Aun | 960dff8b9e | |
Lim Chee Aun | e3c25d25ee | |
Lim Chee Aun | 090320150a | |
Lim Chee Aun | 7100937e79 | |
Lim Chee Aun | c18efef7b6 | |
Lim Chee Aun | ff336628f8 | |
Lim Chee Aun | 28882d98d9 | |
Lim Chee Aun | f6ad22e58f | |
Lim Chee Aun | aa664e15f6 | |
Chee Aun | f2f203c9d8 | |
snail-coupe | ae0e4a0792 | |
Lim Chee Aun | 4def6eef5a | |
Lim Chee Aun | 1004a5f176 | |
Lim Chee Aun | 2b6beee875 | |
Lim Chee Aun | e35e02593a | |
Lim Chee Aun | 5e56ba9fb9 | |
Lim Chee Aun | a7cc0785f9 | |
Lim Chee Aun | bb5d34c94c |
13
README.md
13
README.md
|
@ -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:
|
||||
|
||||
```bash
|
||||
PHANPY_APP_TITLE="Phanpy Dev" \
|
||||
PHANPY_CLIENT_NAME="Phanpy Dev" \
|
||||
PHANPY_WEBSITE="https://dev.phanpy.social" \
|
||||
npm run build
|
||||
```
|
||||
|
@ -179,6 +179,13 @@ 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)
|
||||
- List of fallback instances hard-coded in `/.env`
|
||||
- [↗️ 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
|
||||
|
||||
|
@ -201,6 +208,7 @@ These are self-hosted by other wonderful folks.
|
|||
- [phanpy.hear-me.social](https://phanpy.hear-me.social) by [@admin@hear-me.social](https://hear-me.social/@admin)
|
||||
- [phanpy.fulda.social](https://phanpy.fulda.social) by [@Ganneff@fulda.social](https://fulda.social/@Ganneff)
|
||||
- [phanpy.crmbl.uk](https://phanpy.crmbl.uk) by [@snail@crmbl.uk](https://mstdn.crmbl.uk/@snail)
|
||||
- [halo.mookiesplace.com](https://halo.mookiesplace.com) by [@mookie@mookiesplace.com](https://mookiesplace.com/@mookie)
|
||||
|
||||
> Note: Add yours by creating a pull request.
|
||||
|
||||
|
@ -236,6 +244,8 @@ And here I am. Building a Mastodon web client.
|
|||
|
||||
## Alternative web clients
|
||||
|
||||
- Phanpy forks ↓
|
||||
- [Agora](https://agorasocial.app/)
|
||||
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - forks ↓
|
||||
- [Semaphore](https://semaphore.social/)
|
||||
- [Enafore](https://enafore.social/)
|
||||
|
@ -252,6 +262,7 @@ And here I am. Building a Mastodon web client.
|
|||
- [Tusked](https://tusked.app/)
|
||||
- [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)
|
||||
|
||||
## 💁♂️ Notice to all other social media client developers
|
||||
|
|
Plik diff jest za duży
Load Diff
30
package.json
30
package.json
|
@ -12,32 +12,34 @@
|
|||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "~0.5.4",
|
||||
"@formatjs/intl-segmenter": "~11.5.5",
|
||||
"@formkit/auto-animate": "~0.8.1",
|
||||
"@formkit/auto-animate": "~0.8.2",
|
||||
"@github/text-expander-element": "~2.6.1",
|
||||
"@iconify-icons/mingcute": "~1.2.9",
|
||||
"@justinribeiro/lite-youtube": "~1.5.0",
|
||||
"@szhsin/react-menu": "~4.1.0",
|
||||
"@uidotdev/usehooks": "~2.4.1",
|
||||
"compare-versions": "~6.1.0",
|
||||
"dayjs": "~1.11.10",
|
||||
"dayjs": "~1.11.11",
|
||||
"dayjs-twitter": "~0.5.0",
|
||||
"fast-blurhash": "~1.1.2",
|
||||
"fast-equals": "~5.0.1",
|
||||
"fuse.js": "~7.0.0",
|
||||
"html-prettify": "^1.0.7",
|
||||
"idb-keyval": "~6.2.1",
|
||||
"just-debounce-it": "~3.2.0",
|
||||
"lz-string": "~1.5.0",
|
||||
"masto": "~6.7.0",
|
||||
"masto": "~6.7.7",
|
||||
"moize": "~6.1.6",
|
||||
"p-retry": "~6.2.0",
|
||||
"p-throttle": "~6.1.0",
|
||||
"preact": "~10.20.1",
|
||||
"preact": "~10.21.0",
|
||||
"punycode": "~2.3.1",
|
||||
"react-hotkeys-hook": "~4.5.0",
|
||||
"react-intersection-observer": "~9.8.1",
|
||||
"react-intersection-observer": "~9.10.2",
|
||||
"react-quick-pinch-zoom": "~5.1.0",
|
||||
"react-router-dom": "6.6.2",
|
||||
"string-length": "6.0.0",
|
||||
"swiped-events": "~1.1.9",
|
||||
"swiped-events": "~1.2.0",
|
||||
"toastify-js": "~1.12.0",
|
||||
"uid": "~2.0.2",
|
||||
"use-debounce": "~10.0.0",
|
||||
|
@ -49,18 +51,18 @@
|
|||
"@preact/preset-vite": "~2.8.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "~4.3.0",
|
||||
"postcss": "~8.4.38",
|
||||
"postcss-dark-theme-class": "~1.2.1",
|
||||
"postcss-preset-env": "~9.5.2",
|
||||
"postcss-dark-theme-class": "~1.3.0",
|
||||
"postcss-preset-env": "~9.5.11",
|
||||
"twitter-text": "~3.1.0",
|
||||
"vite": "~5.2.6",
|
||||
"vite": "~5.2.11",
|
||||
"vite-plugin-generate-file": "~0.1.1",
|
||||
"vite-plugin-html-config": "~1.0.11",
|
||||
"vite-plugin-pwa": "~0.19.7",
|
||||
"vite-plugin-pwa": "~0.20.0",
|
||||
"vite-plugin-remove-console": "~2.2.0",
|
||||
"workbox-cacheable-response": "~7.0.0",
|
||||
"workbox-expiration": "~7.0.0",
|
||||
"workbox-routing": "~7.0.0",
|
||||
"workbox-strategies": "~7.0.0"
|
||||
"workbox-cacheable-response": "~7.1.0",
|
||||
"workbox-expiration": "~7.1.0",
|
||||
"workbox-routing": "~7.1.0",
|
||||
"workbox-strategies": "~7.1.0"
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
|
|
40
src/app.css
40
src/app.css
|
@ -295,12 +295,47 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
video,
|
||||
img,
|
||||
audio {
|
||||
min-height: var(--pointer-min-dimension); /* for extreme dimensions */
|
||||
min-height: var(--min-dimension); /* for extreme dimensions */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deck-container-media-first {
|
||||
.timeline {
|
||||
> li:not(.timeline-item-carousel, .timeline-item-container) {
|
||||
&:has(.status-media-first) {
|
||||
@media (min-width: 40em) {
|
||||
width: fit-content;
|
||||
max-width: min(480px, 100%);
|
||||
}
|
||||
|
||||
background-color: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
margin-inline: auto !important;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-block: 32px;
|
||||
}
|
||||
|
||||
&:has(.skeleton) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(.media[data-orientation='landscape']) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.status-link:has(.status-media-first):hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline.grow {
|
||||
/* min-height: 100vh;
|
||||
min-height: 100dvh; */
|
||||
|
@ -1882,7 +1917,8 @@ body > .szh-menu-container {
|
|||
/* two columns only */
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.szh-menu .menu-horizontal:has(> .szh-menu__item:only-child) {
|
||||
.szh-menu .menu-horizontal:has(> .szh-menu__item:only-child),
|
||||
.szh-menu .menu-horizontal:has(> .szh-menu__submenu:only-child) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.szh-menu .menu-horizontal > .szh-menu__item:not(:only-child):first-child,
|
||||
|
|
25
src/app.jsx
25
src/app.jsx
|
@ -1,7 +1,6 @@
|
|||
import './app.css';
|
||||
|
||||
import debounce from 'just-debounce-it';
|
||||
import { lazy, Suspense } from 'preact/compat';
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
|
@ -18,14 +17,14 @@ import ComposeButton from './components/compose-button';
|
|||
import { ICONS } from './components/ICONS';
|
||||
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
|
||||
import Loader from './components/loader';
|
||||
// import Modals from './components/modals';
|
||||
import Modals from './components/modals';
|
||||
import NotificationService from './components/notification-service';
|
||||
import SearchCommand from './components/search-command';
|
||||
import Shortcuts from './components/shortcuts';
|
||||
import NotFound from './pages/404';
|
||||
import AccountStatuses from './pages/account-statuses';
|
||||
import Bookmarks from './pages/bookmarks';
|
||||
// import Catchup from './pages/catchup';
|
||||
import Catchup from './pages/catchup';
|
||||
import Favourites from './pages/favourites';
|
||||
import Filters from './pages/filters';
|
||||
import FollowedHashtags from './pages/followed-hashtags';
|
||||
|
@ -54,12 +53,9 @@ import { getAccessToken } from './utils/auth';
|
|||
import focusDeck from './utils/focus-deck';
|
||||
import states, { initStates, statusKey } from './utils/states';
|
||||
import store from './utils/store';
|
||||
import { getCurrentAccount } from './utils/store-utils';
|
||||
import { getCurrentAccount, setCurrentAccountID } from './utils/store-utils';
|
||||
import './utils/toast-alert';
|
||||
|
||||
const Catchup = lazy(() => import('./pages/catchup'));
|
||||
const Modals = lazy(() => import('./components/modals'));
|
||||
|
||||
window.__STATES__ = states;
|
||||
window.__STATES_STATS__ = () => {
|
||||
const keys = [
|
||||
|
@ -342,7 +338,7 @@ function App() {
|
|||
window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
|
||||
const account = getCurrentAccount();
|
||||
if (account) {
|
||||
store.session.set('currentAccount', account.info.id);
|
||||
setCurrentAccountID(account.info.id);
|
||||
const { client } = api({ account });
|
||||
const { instance } = client;
|
||||
// console.log('masto', masto);
|
||||
|
@ -387,9 +383,7 @@ function App() {
|
|||
)}
|
||||
{isLoggedIn && <ComposeButton />}
|
||||
{isLoggedIn && <Shortcuts />}
|
||||
<Suspense>
|
||||
<Modals />
|
||||
</Suspense>
|
||||
<Modals />
|
||||
{isLoggedIn && <NotificationService />}
|
||||
<BackgroundService isLoggedIn={isLoggedIn} />
|
||||
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
|
||||
|
@ -466,14 +460,7 @@ function SecondaryRoutes({ isLoggedIn }) {
|
|||
</Route>
|
||||
<Route path="/fh" element={<FollowedHashtags />} />
|
||||
<Route path="/ft" element={<Filters />} />
|
||||
<Route
|
||||
path="/catchup"
|
||||
element={
|
||||
<Suspense>
|
||||
<Catchup />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path="/catchup" element={<Catchup />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 641 223">
|
||||
<path fill="#aaa" d="M86 214c-9-1-17-4-24-8l-6-3-5-5-5-4-4-6-4-6-3-8-2-8v-27l2-9 3-9 4-6 4-6 5-5 5-5 7-3 6-4 7-2 7-2 12-1h12l7 1 8 2 7 4 7 3 5 5 5 4-10 10-10 9-4-3-10-5-5-1H88l-5 2-6 3-3 4-4 4-2 5-2 6v6l-1 7 1 7 2 7 3 5 2 4 4 3 4 3 5 2 6 2h9l10-1 5-2 6-3v-16H91v-27h59v54l-1 3-2 3-5 4-4 4-5 3-5 2-8 2-8 2-10 1H92l-6-1zm266-62V91h34v46h44V91h34v121h-34v-46h-44v46h-34v-61zm-182-1V90h34v121h-34v-60zm59-1V90h35l36 1 5 2c3 0 8 2 10 4l5 2 4 5 5 4 3 7 3 7 1 13v13l-4 6-3 7-4 4-5 5-5 2-5 3-6 2-5 1-18 1h-18v32h-34v-61zm67-2 3-2 2-4 2-5v-5l-2-4-2-4-3-2-3-3h-30v31h30l3-2zm226 39v-24l-8-12-18-28a1751 1751 0 0 0-20-31v-2h39l7 12 12 21 6 9 13-21 13-21h38v2l-41 61-7 10v48h-34v-24zM109 66l-4-1-5-5-5-4-1-5-3-9v-5l1-5c2-7 3-10 8-15l4-4 7-2 7-2h7l6 1 5 2 5 2 3 4 4 3 2 6 2 5v13l-2 5-2 6-4 4-3 3-5 2-4 2-9 1h-9l-5-2zm22-11 4-2 3-4 2-5V34l-2-4-2-4-3-2-4-3-5-1h-6l-4 2-5 2-2 4-3 5-1 3v4l1 5 2 5 2 2 5 3 4 2h10l4-2zM37 39V11h33l3 1 3 2 4 3 3 3 1 5 1 4v5l-1 4-3 4-3 5-4 1-3 2-11 1H49v16H37V39zm31 0 3-2 1-2 1-2v-4l-1-3-3-2-2-2H49v18h15l4-1zm107 25a512 512 0 0 0-19-53h14l4 14 6 19 1 4 1-1 7-19 5-17h9l6 19 7 18v-1l2-6 5-17 4-13h14v1l-4 12-16 41v2h-5l-5-1-6-15-6-15-1 1-3 7-6 15-2 8h-11l-1-3zm74-25V11h42v11h-29v2l-1 5v4h29v11h-28v11h2l15 1h13v11h-43V39zm55 0V11h33l5 3 5 2 2 4 2 5v10l-2 3-1 4-5 3-5 3 5 5 8 10 3 4h-14l-7-9-8-10h-9v19h-12V39zm33-3 2-3v-6l-3-3-2-3h-18v16h1v1h17l2-2zm26 3V11h42v11h-29l-1 6v5h29v11h-28v5l-1 5 1 1v1h30v11h-43V39zm54 0V11h17l18 1 4 2 5 3 2 4 3 4 2 6 1 6v5c-1 6-3 12-6 15l-3 4-5 3-5 2-17 1h-16V39zm33 14 5-5 2-3v-6l-1-6-1-3-1-3-4-3-3-2h-5l-6-1-3 1h-3v34h9l8-1 3-2zm50-14V11h34l5 2 4 2 2 3 2 3v9l-2 2-3 4-1 1 3 3 3 4 1 3 1 4-1 4-1 4-3 3-3 3-5 1-5 1h-31V39zm34 15 2-1v-6l-2-2-2-2h-20v13h20l2-2zm-3-22 4-2v-6l-2-1-2-2h-19v12h16l4-1zm42 24V45l-6-9-11-17-5-8h15l4 8 7 11 2 3 7-11 7-11h14l-11 16-11 17v23h-12V56z"/>
|
||||
</svg>
|
Po Szerokość: | Wysokość: | Rozmiar: 1.9 KiB |
|
@ -107,4 +107,5 @@ export const ICONS = {
|
|||
quote: () => import('@iconify-icons/mingcute/quote-left-line'),
|
||||
settings: () => import('@iconify-icons/mingcute/settings-6-line'),
|
||||
'heart-break': () => import('@iconify-icons/mingcute/heart-crack-line'),
|
||||
'user-x': () => import('@iconify-icons/mingcute/user-x-line'),
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import './account-info.css';
|
||||
|
||||
import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu';
|
||||
import { MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
@ -9,6 +9,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import punycode from 'punycode';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import enhanceContent from '../utils/enhance-content';
|
||||
|
@ -21,7 +22,8 @@ import shortenNumber from '../utils/shorten-number';
|
|||
import showToast from '../utils/show-toast';
|
||||
import states, { hideAllModals } from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import { updateAccount } from '../utils/store-utils';
|
||||
import { getCurrentAccountID, updateAccount } from '../utils/store-utils';
|
||||
import supports from '../utils/supports';
|
||||
|
||||
import AccountBlock from './account-block';
|
||||
import Avatar from './avatar';
|
||||
|
@ -32,7 +34,9 @@ import ListAddEdit from './list-add-edit';
|
|||
import Loader from './loader';
|
||||
import Menu2 from './menu2';
|
||||
import MenuConfirm from './menu-confirm';
|
||||
import MenuLink from './menu-link';
|
||||
import Modal from './modal';
|
||||
import SubMenu2 from './submenu2';
|
||||
import TranslationBlock from './translation-block';
|
||||
|
||||
const MUTE_DURATIONS = [
|
||||
|
@ -195,10 +199,7 @@ function AccountInfo({
|
|||
}
|
||||
}
|
||||
|
||||
const isSelf = useMemo(
|
||||
() => id === store.session.get('currentAccount'),
|
||||
[id],
|
||||
);
|
||||
const isSelf = useMemo(() => id === getCurrentAccountID(), [id]);
|
||||
|
||||
useEffect(() => {
|
||||
const infoHasEssentials = !!(
|
||||
|
@ -228,7 +229,7 @@ function AccountInfo({
|
|||
|
||||
const accountInstance = useMemo(() => {
|
||||
if (!url) return null;
|
||||
const domain = new URL(url).hostname;
|
||||
const domain = punycode.toUnicode(new URL(url).hostname);
|
||||
return domain;
|
||||
}, [url]);
|
||||
|
||||
|
@ -251,12 +252,13 @@ function AccountInfo({
|
|||
// On first load, fetch familiar followers, merge to top of results' `value`
|
||||
// Remove dups on every fetch
|
||||
if (firstLoad) {
|
||||
const familiarFollowers = await masto.v1.accounts.familiarFollowers.fetch(
|
||||
{
|
||||
let familiarFollowers = [];
|
||||
try {
|
||||
familiarFollowers = await masto.v1.accounts.familiarFollowers.fetch({
|
||||
id: [id],
|
||||
},
|
||||
);
|
||||
familiarFollowersCache.current = familiarFollowers[0].accounts;
|
||||
});
|
||||
} catch (e) {}
|
||||
familiarFollowersCache.current = familiarFollowers?.[0]?.accounts || [];
|
||||
newValue = [
|
||||
...familiarFollowersCache.current,
|
||||
...value.filter(
|
||||
|
@ -581,6 +583,15 @@ function AccountInfo({
|
|||
<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
|
||||
|
@ -659,6 +670,7 @@ function AccountInfo({
|
|||
// states.showAccount = false;
|
||||
setTimeout(() => {
|
||||
states.showGenericAccounts = {
|
||||
id: 'followers',
|
||||
heading: 'Followers',
|
||||
fetchAccounts: fetchFollowers,
|
||||
instance,
|
||||
|
@ -809,38 +821,40 @@ function AccountInfo({
|
|||
</div>
|
||||
</LinkOrDiv>
|
||||
)}
|
||||
<div class="account-metadata-box">
|
||||
<div
|
||||
class="shazam-container no-animation"
|
||||
hidden={!!postingStats}
|
||||
>
|
||||
<div class="shazam-container-inner">
|
||||
<button
|
||||
type="button"
|
||||
class="posting-stats-button"
|
||||
disabled={postingStatsUIState === 'loading'}
|
||||
onClick={() => {
|
||||
renderPostingStats();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class={`posting-stats-bar posting-stats-icon ${
|
||||
postingStatsUIState === 'loading' ? 'loading' : ''
|
||||
}`}
|
||||
style={{
|
||||
'--originals-percentage': '33%',
|
||||
'--replies-percentage': '66%',
|
||||
{!moved && (
|
||||
<div class="account-metadata-box">
|
||||
<div
|
||||
class="shazam-container no-animation"
|
||||
hidden={!!postingStats}
|
||||
>
|
||||
<div class="shazam-container-inner">
|
||||
<button
|
||||
type="button"
|
||||
class="posting-stats-button"
|
||||
disabled={postingStatsUIState === 'loading'}
|
||||
onClick={() => {
|
||||
renderPostingStats();
|
||||
}}
|
||||
/>
|
||||
View post stats{' '}
|
||||
{/* <Loader
|
||||
>
|
||||
<div
|
||||
class={`posting-stats-bar posting-stats-icon ${
|
||||
postingStatsUIState === 'loading' ? 'loading' : ''
|
||||
}`}
|
||||
style={{
|
||||
'--originals-percentage': '33%',
|
||||
'--replies-percentage': '66%',
|
||||
}}
|
||||
/>
|
||||
View post stats{' '}
|
||||
{/* <Loader
|
||||
abrupt
|
||||
hidden={postingStatsUIState !== 'loading'}
|
||||
/> */}
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<footer>
|
||||
<RelatedActions
|
||||
|
@ -904,7 +918,7 @@ function RelatedActions({
|
|||
|
||||
useEffect(() => {
|
||||
if (info) {
|
||||
const currentAccount = store.session.get('currentAccount');
|
||||
const currentAccount = getCurrentAccountID();
|
||||
let currentID;
|
||||
(async () => {
|
||||
if (sameInstance && authenticated) {
|
||||
|
@ -939,7 +953,7 @@ function RelatedActions({
|
|||
|
||||
accountID.current = currentID;
|
||||
|
||||
if (moved) return;
|
||||
// if (moved) return;
|
||||
|
||||
setRelationshipUIState('loading');
|
||||
|
||||
|
@ -1078,16 +1092,18 @@ function RelatedActions({
|
|||
<Icon icon="translate" />
|
||||
<span>Translate bio</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setShowPrivateNoteModal(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="pencil" />
|
||||
<span>
|
||||
{privateNote ? 'Edit private note' : 'Add private note'}
|
||||
</span>
|
||||
</MenuItem>
|
||||
{supports('@mastodon/profile-private-note') && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setShowPrivateNoteModal(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="pencil" />
|
||||
<span>
|
||||
{privateNote ? 'Edit private note' : 'Add private note'}
|
||||
</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{following && !!relationship && (
|
||||
<>
|
||||
<MenuItem
|
||||
|
@ -1270,7 +1286,7 @@ function RelatedActions({
|
|||
<span>Unmute @{username}</span>
|
||||
</MenuItem>
|
||||
) : (
|
||||
<SubMenu
|
||||
<SubMenu2
|
||||
menuClassName="menu-blur"
|
||||
openTrigger="clickOnly"
|
||||
direction="bottom"
|
||||
|
@ -1324,7 +1340,44 @@ function RelatedActions({
|
|||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
</SubMenu>
|
||||
</SubMenu2>
|
||||
)}
|
||||
{followedBy && (
|
||||
<MenuConfirm
|
||||
subMenu
|
||||
menuItemClassName="danger"
|
||||
confirmLabel={
|
||||
<>
|
||||
<Icon icon="user-x" />
|
||||
<span>Remove @{username} from followers?</span>
|
||||
</>
|
||||
}
|
||||
onClick={() => {
|
||||
setRelationshipUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const newRelationship = await currentMasto.v1.accounts
|
||||
.$select(currentInfo?.id || id)
|
||||
.removeFromFollowers();
|
||||
console.log(
|
||||
'removing from followers',
|
||||
newRelationship,
|
||||
);
|
||||
setRelationship(newRelationship);
|
||||
setRelationshipUIState('default');
|
||||
showToast(`@${username} removed from followers`);
|
||||
states.reloadGenericAccounts.id = 'followers';
|
||||
states.reloadGenericAccounts.counter++;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setRelationshipUIState('error');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<Icon icon="user-x" />
|
||||
<span>Remove follower…</span>
|
||||
</MenuConfirm>
|
||||
)}
|
||||
<MenuConfirm
|
||||
subMenu
|
||||
|
@ -1399,19 +1452,22 @@ function RelatedActions({
|
|||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{currentAuthenticated && isSelf && standalone && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setShowEditProfile(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="pencil" />
|
||||
<span>Edit profile</span>
|
||||
</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 && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
|
@ -1437,7 +1493,7 @@ function RelatedActions({
|
|||
{!relationship && relationshipUIState === 'loading' && (
|
||||
<Loader abrupt />
|
||||
)}
|
||||
{!!relationship && (
|
||||
{!!relationship && !moved && (
|
||||
<MenuConfirm
|
||||
confirm={following || requested}
|
||||
confirmLabel={
|
||||
|
@ -1596,7 +1652,7 @@ function niceAccountURL(url) {
|
|||
const path = pathname.replace(/\/$/, '').replace(/^\//, '');
|
||||
return (
|
||||
<>
|
||||
<span class="more-insignificant">{host}/</span>
|
||||
<span class="more-insignificant">{punycode.toUnicode(host)}/</span>
|
||||
<wbr />
|
||||
<span>{path}</span>
|
||||
</>
|
||||
|
|
|
@ -310,7 +310,7 @@
|
|||
|
||||
#compose-container .form-visibility-direct {
|
||||
--yellow-stripes: repeating-linear-gradient(
|
||||
-45deg,
|
||||
135deg,
|
||||
var(--reply-to-faded-color),
|
||||
var(--reply-to-faded-color) 10px,
|
||||
var(--reply-to-faded-color) 10px,
|
||||
|
@ -597,41 +597,123 @@
|
|||
#custom-emojis-sheet {
|
||||
max-height: 50vh;
|
||||
max-height: 50dvh;
|
||||
}
|
||||
#custom-emojis-sheet main {
|
||||
mask-image: none;
|
||||
}
|
||||
#custom-emojis-sheet .custom-emojis-list .section-header {
|
||||
font-size: 80%;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-insignificant-color);
|
||||
padding: 8px 0 4px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--bg-blur-color);
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
#custom-emojis-sheet .custom-emojis-list section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#custom-emojis-sheet .custom-emojis-list button {
|
||||
border-radius: 8px;
|
||||
background-image: radial-gradient(
|
||||
closest-side,
|
||||
var(--img-bg-color),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) {
|
||||
filter: none;
|
||||
background-color: var(--bg-faded-color);
|
||||
}
|
||||
#custom-emojis-sheet .custom-emojis-list button img {
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) img {
|
||||
transform: scale(1.5);
|
||||
|
||||
header {
|
||||
.loader-container {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 8px 0 0;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
mask-image: none;
|
||||
min-height: 40vh;
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
.custom-emojis-matches {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.custom-emojis-list {
|
||||
.section-header {
|
||||
font-size: 80%;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-insignificant-color);
|
||||
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 {
|
||||
|
@ -727,3 +809,165 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gif-shake {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.gif-picker-button {
|
||||
span {
|
||||
font-weight: bold;
|
||||
font-size: 11.5px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:is(:hover, :focus) {
|
||||
span {
|
||||
animation: gif-shake 0.3s 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#gif-picker-sheet {
|
||||
height: 50vh;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
input[type='search'] {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 2px,
|
||||
black 16px,
|
||||
black calc(100% - 16px),
|
||||
transparent calc(100% - 2px)
|
||||
);
|
||||
|
||||
@media (min-height: 480px) {
|
||||
overflow-y: auto;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.ui-state {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
ul {
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
padding: 8px 2px;
|
||||
margin: 0;
|
||||
|
||||
@media (min-height: 480px) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
padding: 4px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-faded-color);
|
||||
|
||||
@media (min-height: 480px) {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:is(:hover, :focus) {
|
||||
background-color: var(--link-bg-color);
|
||||
box-shadow: 0 0 0 2px var(--link-light-color);
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: var(--figure-width);
|
||||
max-width: 100%;
|
||||
|
||||
@media (min-height: 480px) {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
font-size: 0.8em;
|
||||
padding: 2px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-insignificant-color);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
background-color: var(--img-bg-color);
|
||||
border-radius: 4px;
|
||||
vertical-align: top;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
@media (min-height: 480px) {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,14 +3,24 @@ import './compose.css';
|
|||
import '@github/text-expander-element';
|
||||
import { MenuItem } from '@szhsin/react-menu';
|
||||
import { deepEqual } from 'fast-equals';
|
||||
import Fuse from 'fuse.js';
|
||||
import { memo } from 'preact/compat';
|
||||
import { forwardRef } from 'preact/compat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import stringLength from 'string-length';
|
||||
import { uid } from 'uid/single';
|
||||
import { useDebouncedCallback, useThrottledCallback } from 'use-debounce';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import poweredByGiphyURL from '../assets/powered-by-giphy.svg';
|
||||
|
||||
import Menu2 from '../components/menu2';
|
||||
import supportedLanguages from '../data/status-supported-languages';
|
||||
import urlRegex from '../data/url-regex';
|
||||
|
@ -19,6 +29,7 @@ import db from '../utils/db';
|
|||
import emojifyText from '../utils/emojify-text';
|
||||
import localeMatch from '../utils/locale-match';
|
||||
import openCompose from '../utils/open-compose';
|
||||
import pmem from '../utils/pmem';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import showToast from '../utils/show-toast';
|
||||
import states, { saveStatus } from '../utils/states';
|
||||
|
@ -41,7 +52,10 @@ import Loader from './loader';
|
|||
import Modal from './modal';
|
||||
import Status from './status';
|
||||
|
||||
const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env;
|
||||
const {
|
||||
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
|
||||
PHANPY_GIPHY_API_KEY: GIPHY_API_KEY,
|
||||
} = import.meta.env;
|
||||
|
||||
const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => {
|
||||
const [code, common, native] = l;
|
||||
|
@ -119,14 +133,14 @@ const MENTION_RE = new RegExp(
|
|||
|
||||
// AI-generated, all other regexes are too complicated
|
||||
const HASHTAG_RE = new RegExp(
|
||||
`(^|[^=\\/\\w])(#[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?)(?![\\/\\w])`,
|
||||
`(^|[^=\\/\\w])(#[a-z0-9_]+([a-z0-9_.]+[a-z0-9_]+)?)(?![\\/\\w])`,
|
||||
'ig',
|
||||
);
|
||||
|
||||
// https://github.com/mastodon/mastodon/blob/23e32a4b3031d1da8b911e0145d61b4dd47c4f96/app/models/custom_emoji.rb#L31
|
||||
const SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}';
|
||||
const SCAN_RE = new RegExp(
|
||||
`([^A-Za-z0-9_:\\n]|^)(:${SHORTCODE_RE_FRAGMENT}:)(?=[^A-Za-z0-9_:]|$)`,
|
||||
`(^|[^=\\/\\w])(:${SHORTCODE_RE_FRAGMENT}:)(?=[^A-Za-z0-9_:]|$)`,
|
||||
'g',
|
||||
);
|
||||
|
||||
|
@ -176,6 +190,8 @@ function highlightText(text, { maxCharacters = Infinity }) {
|
|||
|
||||
const rtf = new Intl.RelativeTimeFormat();
|
||||
|
||||
const CUSTOM_EMOJIS_COUNT = 100;
|
||||
|
||||
function Compose({
|
||||
onClose,
|
||||
replyToStatus,
|
||||
|
@ -610,6 +626,7 @@ function Compose({
|
|||
}, [mediaAttachments]);
|
||||
|
||||
const [showEmoji2Picker, setShowEmoji2Picker] = useState(false);
|
||||
const [showGIFPicker, setShowGIFPicker] = useState(false);
|
||||
|
||||
const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => {
|
||||
const topLanguages = [];
|
||||
|
@ -982,7 +999,11 @@ function Compose({
|
|||
} else {
|
||||
try {
|
||||
newStatus = await masto.v1.statuses.create(params, {
|
||||
idempotencyKey: UID.current,
|
||||
requestInit: {
|
||||
headers: {
|
||||
'Idempotency-Key': UID.current,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (_) {
|
||||
// If idempotency key fails, try again without it
|
||||
|
@ -1209,22 +1230,30 @@ function Compose({
|
|||
/>
|
||||
<Icon icon="attachment" />
|
||||
</label>{' '}
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-button"
|
||||
disabled={
|
||||
uiState === 'loading' || !!poll || !!mediaAttachments.length
|
||||
}
|
||||
onClick={() => {
|
||||
setPoll({
|
||||
options: ['', ''],
|
||||
expiresIn: 24 * 60 * 60, // 1 day
|
||||
multiple: false,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="poll" alt="Add poll" />
|
||||
</button>{' '}
|
||||
{/* If maxOptions is not defined or defined and is greater than 1, show poll button */}
|
||||
{maxOptions == null ||
|
||||
(maxOptions > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-button"
|
||||
disabled={
|
||||
uiState === 'loading' ||
|
||||
!!poll ||
|
||||
!!mediaAttachments.length
|
||||
}
|
||||
onClick={() => {
|
||||
setPoll({
|
||||
options: ['', ''],
|
||||
expiresIn: 24 * 60 * 60, // 1 day
|
||||
multiple: false,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="poll" alt="Add poll" />
|
||||
</button>{' '}
|
||||
</>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-button"
|
||||
|
@ -1235,6 +1264,18 @@ function Compose({
|
|||
>
|
||||
<Icon icon="emoji2" />
|
||||
</button>
|
||||
{!!states.settings.composerGIFPicker && (
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-button gif-picker-button"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
setShowGIFPicker(true);
|
||||
}}
|
||||
>
|
||||
<span>GIF</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<div class="spacer" />
|
||||
{uiState === 'loading' ? (
|
||||
|
@ -1319,6 +1360,64 @@ function Compose({
|
|||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{showGIFPicker && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowGIFPicker(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GIFPickerModal
|
||||
onClose={() => setShowGIFPicker(false)}
|
||||
onSelect={({ url, type, alt_text }) => {
|
||||
console.log('GIF URL', url);
|
||||
if (mediaAttachments.length >= maxMediaAttachments) {
|
||||
alert(
|
||||
`You can only attach up to ${maxMediaAttachments} files.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Download the GIF and insert it as media attachment
|
||||
(async () => {
|
||||
let theToast;
|
||||
try {
|
||||
theToast = showToast({
|
||||
text: 'Downloading GIF…',
|
||||
duration: -1,
|
||||
});
|
||||
const blob = await fetch(url, {
|
||||
referrerPolicy: 'no-referrer',
|
||||
}).then((res) => res.blob());
|
||||
const file = new File(
|
||||
[blob],
|
||||
type === 'video/mp4' ? 'video.mp4' : 'image.gif',
|
||||
{
|
||||
type,
|
||||
},
|
||||
);
|
||||
const newMediaAttachments = [
|
||||
...mediaAttachments,
|
||||
{
|
||||
file,
|
||||
type,
|
||||
size: file.size,
|
||||
id: null,
|
||||
description: alt_text || '',
|
||||
},
|
||||
];
|
||||
setMediaAttachments(newMediaAttachments);
|
||||
theToast?.hideToast?.();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
theToast?.hideToast?.();
|
||||
showToast('Failed to download GIF');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1335,25 +1434,40 @@ function autoResizeTextarea(textarea) {
|
|||
}
|
||||
}
|
||||
|
||||
async function _getCustomEmojis(instance, masto) {
|
||||
const emojis = await masto.v1.customEmojis.list();
|
||||
const visibleEmojis = emojis.filter((e) => e.visibleInPicker);
|
||||
const searcher = new Fuse(visibleEmojis, {
|
||||
keys: ['shortcode'],
|
||||
findAllMatches: true,
|
||||
});
|
||||
return [visibleEmojis, searcher];
|
||||
}
|
||||
const getCustomEmojis = pmem(_getCustomEmojis, {
|
||||
// Limit by time to reduce memory usage
|
||||
// Cached by instance
|
||||
matchesArg: (cacheKeyArg, keyArg) => cacheKeyArg.instance === keyArg.instance,
|
||||
maxAge: 30 * 60 * 1000, // 30 minutes
|
||||
});
|
||||
|
||||
const Textarea = forwardRef((props, ref) => {
|
||||
const { masto } = api();
|
||||
const { masto, instance } = api();
|
||||
const [text, setText] = useState(ref.current?.value || '');
|
||||
const { maxCharacters, performSearch = () => {}, ...textareaProps } = props;
|
||||
// const snapStates = useSnapshot(states);
|
||||
// const charCount = snapStates.composerCharacterCount;
|
||||
|
||||
const customEmojis = useRef();
|
||||
// const customEmojis = useRef();
|
||||
const searcherRef = useRef();
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const emojis = await masto.v1.customEmojis.list();
|
||||
console.log({ emojis });
|
||||
customEmojis.current = emojis;
|
||||
} catch (e) {
|
||||
// silent fail
|
||||
getCustomEmojis(instance, masto)
|
||||
.then((r) => {
|
||||
const [emojis, searcher] = r;
|
||||
searcherRef.current = searcher;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const textExpanderRef = useRef();
|
||||
|
@ -1379,23 +1493,26 @@ const Textarea = forwardRef((props, ref) => {
|
|||
// const emojis = customEmojis.current.filter((emoji) =>
|
||||
// emoji.shortcode.startsWith(text),
|
||||
// );
|
||||
const emojis = filterShortcodes(customEmojis.current, text);
|
||||
// const emojis = filterShortcodes(customEmojis.current, text);
|
||||
const results = searcherRef.current?.search(text, {
|
||||
limit: 5,
|
||||
});
|
||||
let html = '';
|
||||
emojis.forEach((emoji) => {
|
||||
results.forEach(({ item: emoji }) => {
|
||||
const { shortcode, url } = emoji;
|
||||
html += `
|
||||
<li role="option" data-value="${encodeHTML(shortcode)}">
|
||||
<img src="${encodeHTML(
|
||||
url,
|
||||
)}" width="16" height="16" alt="" loading="lazy" />
|
||||
:${encodeHTML(shortcode)}:
|
||||
${encodeHTML(shortcode)}
|
||||
</li>`;
|
||||
});
|
||||
// console.log({ emojis, html });
|
||||
menu.innerHTML = html;
|
||||
provide(
|
||||
Promise.resolve({
|
||||
matched: emojis.length > 0,
|
||||
matched: results.length > 0,
|
||||
fragment: menu,
|
||||
}),
|
||||
);
|
||||
|
@ -1711,6 +1828,9 @@ function MediaAttachment({
|
|||
onDescriptionChange,
|
||||
250,
|
||||
);
|
||||
useEffect(() => {
|
||||
debouncedOnDescriptionChange(description);
|
||||
}, [description, debouncedOnDescriptionChange]);
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const textareaRef = useRef(null);
|
||||
|
@ -1759,7 +1879,7 @@ function MediaAttachment({
|
|||
onInput={(e) => {
|
||||
const { value } = e.target;
|
||||
setDescription(value);
|
||||
debouncedOnDescriptionChange(value);
|
||||
// debouncedOnDescriptionChange(value);
|
||||
}}
|
||||
></textarea>
|
||||
)}
|
||||
|
@ -2094,38 +2214,19 @@ function CustomEmojisModal({
|
|||
}) {
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const customEmojisList = useRef([]);
|
||||
const [customEmojis, setCustomEmojis] = useState({});
|
||||
const [customEmojis, setCustomEmojis] = useState([]);
|
||||
const recentlyUsedCustomEmojis = useMemo(
|
||||
() => store.account.get('recentlyUsedCustomEmojis') || [],
|
||||
);
|
||||
const searcherRef = useRef();
|
||||
useEffect(() => {
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const emojis = await masto.v1.customEmojis.list();
|
||||
// Group emojis by category
|
||||
const emojisCat = {
|
||||
'--recent--': recentlyUsedCustomEmojis.filter((emoji) =>
|
||||
emojis.find((e) => e.shortcode === emoji.shortcode),
|
||||
),
|
||||
};
|
||||
const othersCat = [];
|
||||
emojis.forEach((emoji) => {
|
||||
if (!emoji.visibleInPicker) return;
|
||||
customEmojisList.current?.push?.(emoji);
|
||||
if (!emoji.category) {
|
||||
othersCat.push(emoji);
|
||||
return;
|
||||
}
|
||||
if (!emojisCat[emoji.category]) {
|
||||
emojisCat[emoji.category] = [];
|
||||
}
|
||||
emojisCat[emoji.category].push(emoji);
|
||||
});
|
||||
if (othersCat.length) {
|
||||
emojisCat['--others--'] = othersCat;
|
||||
}
|
||||
setCustomEmojis(emojisCat);
|
||||
const [emojis, searcher] = await getCustomEmojis(instance, masto);
|
||||
console.log('emojis', emojis);
|
||||
searcherRef.current = searcher;
|
||||
setCustomEmojis(emojis);
|
||||
setUIState('default');
|
||||
} catch (e) {
|
||||
setUIState('error');
|
||||
|
@ -2134,6 +2235,83 @@ function CustomEmojisModal({
|
|||
})();
|
||||
}, []);
|
||||
|
||||
const customEmojisCatList = useMemo(() => {
|
||||
// Group emojis by category
|
||||
const emojisCat = {
|
||||
'--recent--': recentlyUsedCustomEmojis.filter((emoji) =>
|
||||
customEmojis.find((e) => e.shortcode === emoji.shortcode),
|
||||
),
|
||||
};
|
||||
const othersCat = [];
|
||||
customEmojis.forEach((emoji) => {
|
||||
customEmojisList.current?.push?.(emoji);
|
||||
if (!emoji.category) {
|
||||
othersCat.push(emoji);
|
||||
return;
|
||||
}
|
||||
if (!emojisCat[emoji.category]) {
|
||||
emojisCat[emoji.category] = [];
|
||||
}
|
||||
emojisCat[emoji.category].push(emoji);
|
||||
});
|
||||
if (othersCat.length) {
|
||||
emojisCat['--others--'] = othersCat;
|
||||
}
|
||||
return emojisCat;
|
||||
}, [customEmojis]);
|
||||
|
||||
const scrollableRef = useRef();
|
||||
const [matches, setMatches] = useState(null);
|
||||
const onFind = useCallback(
|
||||
(e) => {
|
||||
const { value } = e.target;
|
||||
if (value) {
|
||||
const results = searcherRef.current?.search(value, {
|
||||
limit: CUSTOM_EMOJIS_COUNT,
|
||||
});
|
||||
setMatches(results.map((r) => r.item));
|
||||
scrollableRef.current?.scrollTo?.(0, 0);
|
||||
} else {
|
||||
setMatches(null);
|
||||
}
|
||||
},
|
||||
[customEmojis],
|
||||
);
|
||||
|
||||
const onSelectEmoji = useCallback(
|
||||
(emoji) => {
|
||||
onSelect?.(emoji);
|
||||
onClose?.();
|
||||
|
||||
queueMicrotask(() => {
|
||||
let recentlyUsedCustomEmojis =
|
||||
store.account.get('recentlyUsedCustomEmojis') || [];
|
||||
const recentlyUsedEmojiIndex = recentlyUsedCustomEmojis.findIndex(
|
||||
(e) => e.shortcode === emoji.shortcode,
|
||||
);
|
||||
if (recentlyUsedEmojiIndex !== -1) {
|
||||
// Move emoji to index 0
|
||||
recentlyUsedCustomEmojis.splice(recentlyUsedEmojiIndex, 1);
|
||||
recentlyUsedCustomEmojis.unshift(emoji);
|
||||
} else {
|
||||
recentlyUsedCustomEmojis.unshift(emoji);
|
||||
// Remove unavailable ones
|
||||
recentlyUsedCustomEmojis = recentlyUsedCustomEmojis.filter((e) =>
|
||||
customEmojisList.current?.find?.(
|
||||
(emoji) => emoji.shortcode === e.shortcode,
|
||||
),
|
||||
);
|
||||
// Limit to 10
|
||||
recentlyUsedCustomEmojis = recentlyUsedCustomEmojis.slice(0, 10);
|
||||
}
|
||||
|
||||
// Store back
|
||||
store.account.set('recentlyUsedCustomEmojis', recentlyUsedCustomEmojis);
|
||||
});
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div id="custom-emojis-sheet" class="sheet">
|
||||
{!!onClose && (
|
||||
|
@ -2142,102 +2320,388 @@ function CustomEmojisModal({
|
|||
</button>
|
||||
)}
|
||||
<header>
|
||||
<b>Custom emojis</b>{' '}
|
||||
{uiState === 'loading' ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<small class="insignificant"> • {instance}</small>
|
||||
)}
|
||||
</header>
|
||||
<main>
|
||||
<div class="custom-emojis-list">
|
||||
{uiState === 'error' && (
|
||||
<div class="ui-state">
|
||||
<p>Error loading custom emojis</p>
|
||||
</div>
|
||||
<div>
|
||||
<b>Custom emojis</b>{' '}
|
||||
{uiState === 'loading' ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<small class="insignificant"> • {instance}</small>
|
||||
)}
|
||||
{uiState === 'default' &&
|
||||
Object.entries(customEmojis).map(
|
||||
([category, emojis]) =>
|
||||
!!emojis?.length && (
|
||||
<>
|
||||
<div class="section-header">
|
||||
{{
|
||||
'--recent--': 'Recently used',
|
||||
'--others--': 'Others',
|
||||
}[category] || category}
|
||||
</div>
|
||||
<section>
|
||||
{emojis.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
class="plain4"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
requestAnimationFrame(() => {
|
||||
onSelect(`:${emoji.shortcode}:`);
|
||||
});
|
||||
let recentlyUsedCustomEmojis =
|
||||
store.account.get('recentlyUsedCustomEmojis') ||
|
||||
[];
|
||||
const recentlyUsedEmojiIndex =
|
||||
recentlyUsedCustomEmojis.findIndex(
|
||||
(e) => e.shortcode === emoji.shortcode,
|
||||
);
|
||||
if (recentlyUsedEmojiIndex !== -1) {
|
||||
// Move emoji to index 0
|
||||
recentlyUsedCustomEmojis.splice(
|
||||
recentlyUsedEmojiIndex,
|
||||
1,
|
||||
);
|
||||
recentlyUsedCustomEmojis.unshift(emoji);
|
||||
} else {
|
||||
recentlyUsedCustomEmojis.unshift(emoji);
|
||||
// Remove unavailable ones
|
||||
recentlyUsedCustomEmojis =
|
||||
recentlyUsedCustomEmojis.filter((e) =>
|
||||
customEmojisList.current?.find?.(
|
||||
(emoji) => emoji.shortcode === e.shortcode,
|
||||
),
|
||||
);
|
||||
// Limit to 10
|
||||
recentlyUsedCustomEmojis =
|
||||
recentlyUsedCustomEmojis.slice(0, 10);
|
||||
}
|
||||
|
||||
// Store back
|
||||
store.account.set(
|
||||
'recentlyUsedCustomEmojis',
|
||||
recentlyUsedCustomEmojis,
|
||||
);
|
||||
}}
|
||||
title={`:${emoji.shortcode}:`}
|
||||
>
|
||||
<picture>
|
||||
{!!emoji.staticUrl && (
|
||||
<source
|
||||
srcset={emoji.staticUrl}
|
||||
media="(prefers-reduced-motion: reduce)"
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
class="shortcode-emoji"
|
||||
src={emoji.url || emoji.staticUrl}
|
||||
alt={emoji.shortcode}
|
||||
width="16"
|
||||
height="16"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const emoji = matches[0];
|
||||
if (emoji) {
|
||||
onSelectEmoji(`:${emoji.shortcode}:`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search emoji"
|
||||
onInput={onFind}
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellCheck="false"
|
||||
dir="auto"
|
||||
/>
|
||||
</form>
|
||||
</header>
|
||||
<main ref={scrollableRef}>
|
||||
{matches !== null ? (
|
||||
<ul class="custom-emojis-matches custom-emojis-list">
|
||||
{matches.map((emoji) => (
|
||||
<li key={emoji.shortcode} class="custom-emojis-match">
|
||||
<CustomEmojiButton
|
||||
emoji={emoji}
|
||||
onClick={() => {
|
||||
onSelectEmoji(`:${emoji.shortcode}:`);
|
||||
}}
|
||||
showCode
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div class="custom-emojis-list">
|
||||
{uiState === 'error' && (
|
||||
<div class="ui-state">
|
||||
<p>Error loading custom emojis</p>
|
||||
</div>
|
||||
)}
|
||||
{uiState === 'default' &&
|
||||
Object.entries(customEmojisCatList).map(
|
||||
([category, emojis]) =>
|
||||
!!emojis?.length && (
|
||||
<>
|
||||
<div class="section-header">
|
||||
{{
|
||||
'--recent--': 'Recently used',
|
||||
'--others--': 'Others',
|
||||
}[category] || category}
|
||||
</div>
|
||||
<CustomEmojisList
|
||||
emojis={emojis}
|
||||
onSelect={onSelectEmoji}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomEmojisList = memo(({ emojis, onSelect }) => {
|
||||
const [max, setMax] = useState(CUSTOM_EMOJIS_COUNT);
|
||||
const showMore = emojis.length > max;
|
||||
return (
|
||||
<section>
|
||||
{emojis.slice(0, max).map((emoji) => (
|
||||
<CustomEmojiButton
|
||||
key={emoji.shortcode}
|
||||
emoji={emoji}
|
||||
onClick={() => {
|
||||
onSelect(`:${emoji.shortcode}:`);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{showMore && (
|
||||
<button
|
||||
type="button"
|
||||
class="plain small"
|
||||
onClick={() => setMax(max + CUSTOM_EMOJIS_COUNT)}
|
||||
>
|
||||
{(emojis.length - max).toLocaleString()} more…
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
});
|
||||
|
||||
const CustomEmojiButton = memo(({ emoji, onClick, showCode }) => {
|
||||
const addEdges = (e) => {
|
||||
// Add edge-left or edge-right class based on self position relative to scrollable parent
|
||||
// If near left edge, add edge-left, if near right edge, add edge-right
|
||||
const buffer = 88;
|
||||
const parent = e.currentTarget.closest('main');
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const selfRect = e.currentTarget.getBoundingClientRect();
|
||||
const targetClassList = e.currentTarget.classList;
|
||||
if (selfRect.left < rect.left + buffer) {
|
||||
targetClassList.add('edge-left');
|
||||
targetClassList.remove('edge-right');
|
||||
} else if (selfRect.right > rect.right - buffer) {
|
||||
targetClassList.add('edge-right');
|
||||
targetClassList.remove('edge-left');
|
||||
} else {
|
||||
targetClassList.remove('edge-left', 'edge-right');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="plain4"
|
||||
onClick={onClick}
|
||||
data-title={showCode ? undefined : emoji.shortcode}
|
||||
onPointerEnter={addEdges}
|
||||
onFocus={addEdges}
|
||||
>
|
||||
<picture>
|
||||
{!!emoji.staticUrl && (
|
||||
<source
|
||||
srcSet={emoji.staticUrl}
|
||||
media="(prefers-reduced-motion: reduce)"
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
className="shortcode-emoji"
|
||||
src={emoji.url || emoji.staticUrl}
|
||||
alt={emoji.shortcode}
|
||||
width="24"
|
||||
height="24"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
{showCode && (
|
||||
<>
|
||||
{' '}
|
||||
<code>{emoji.shortcode}</code>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
const GIFS_PER_PAGE = 20;
|
||||
function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [results, setResults] = useState([]);
|
||||
const formRef = useRef(null);
|
||||
const qRef = useRef(null);
|
||||
const currentOffset = useRef(0);
|
||||
const scrollableRef = useRef(null);
|
||||
|
||||
function fetchGIFs({ offset }) {
|
||||
console.log('fetchGIFs', { offset });
|
||||
if (!qRef.current?.value) return;
|
||||
setUIState('loading');
|
||||
scrollableRef.current?.scrollTo?.({
|
||||
top: 0,
|
||||
left: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
const query = {
|
||||
api_key: GIPHY_API_KEY,
|
||||
q: qRef.current.value,
|
||||
rating: 'g',
|
||||
limit: GIFS_PER_PAGE,
|
||||
bundle: 'messaging_non_clips',
|
||||
offset,
|
||||
};
|
||||
const response = await fetch(
|
||||
'https://api.giphy.com/v1/gifs/search?' + new URLSearchParams(query),
|
||||
{
|
||||
referrerPolicy: 'no-referrer',
|
||||
},
|
||||
).then((r) => r.json());
|
||||
currentOffset.current = response.pagination?.offset || 0;
|
||||
setResults(response);
|
||||
setUIState('results');
|
||||
} catch (e) {
|
||||
setUIState('error');
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
qRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const debouncedOnInput = useDebouncedCallback(() => {
|
||||
fetchGIFs({ offset: 0 });
|
||||
}, 1000);
|
||||
|
||||
return (
|
||||
<div id="gif-picker-sheet" class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
fetchGIFs({ offset: 0 });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={qRef}
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="Search GIFs"
|
||||
required
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellCheck="false"
|
||||
dir="auto"
|
||||
onInput={debouncedOnInput}
|
||||
/>
|
||||
<input
|
||||
type="image"
|
||||
class="powered-button"
|
||||
src={poweredByGiphyURL}
|
||||
width="86"
|
||||
height="30"
|
||||
/>
|
||||
</form>
|
||||
</header>
|
||||
<main ref={scrollableRef} class={uiState === 'loading' ? 'loading' : ''}>
|
||||
{uiState === 'default' && (
|
||||
<div class="ui-state">
|
||||
<p class="insignificant">Type to search GIFs</p>
|
||||
</div>
|
||||
)}
|
||||
{uiState === 'loading' && !results?.data?.length && (
|
||||
<div class="ui-state">
|
||||
<Loader abrupt />
|
||||
</div>
|
||||
)}
|
||||
{results?.data?.length > 0 ? (
|
||||
<>
|
||||
<ul>
|
||||
{results.data.map((gif) => {
|
||||
const { id, images, title, alt_text } = gif;
|
||||
const {
|
||||
fixed_height_small,
|
||||
fixed_height_downsampled,
|
||||
fixed_height,
|
||||
original,
|
||||
} = images;
|
||||
const theImage = fixed_height_small?.url
|
||||
? fixed_height_small
|
||||
: fixed_height_downsampled?.url
|
||||
? fixed_height_downsampled
|
||||
: fixed_height;
|
||||
let { url, webp, width, height } = theImage;
|
||||
if (+height > 100) {
|
||||
width = (width / height) * 100;
|
||||
height = 100;
|
||||
}
|
||||
const urlObj = new URL(url);
|
||||
const strippedURL = urlObj.origin + urlObj.pathname;
|
||||
let strippedWebP;
|
||||
if (webp) {
|
||||
const webpObj = new URL(webp);
|
||||
strippedWebP = webpObj.origin + webpObj.pathname;
|
||||
}
|
||||
return (
|
||||
<li key={id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const { mp4, url } = original;
|
||||
const theURL = mp4 || url;
|
||||
const urlObj = new URL(theURL);
|
||||
const strippedURL = urlObj.origin + urlObj.pathname;
|
||||
onClose();
|
||||
onSelect({
|
||||
url: strippedURL,
|
||||
type: mp4 ? 'video/mp4' : 'image/gif',
|
||||
alt_text: alt_text || title,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<figure
|
||||
style={{
|
||||
'--figure-width': width + 'px',
|
||||
// width: width + 'px'
|
||||
}}
|
||||
>
|
||||
<picture>
|
||||
{strippedWebP && (
|
||||
<source srcset={strippedWebP} type="image/webp" />
|
||||
)}
|
||||
<img
|
||||
src={strippedURL}
|
||||
width={width}
|
||||
height={height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt={alt_text}
|
||||
referrerpolicy="no-referrer"
|
||||
onLoad={(e) => {
|
||||
e.target.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
/>
|
||||
</picture>
|
||||
<figcaption>{alt_text || title}</figcaption>
|
||||
</figure>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p class="pagination">
|
||||
{results.pagination?.offset > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
class="light small"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
fetchGIFs({
|
||||
offset: results.pagination?.offset - GIFS_PER_PAGE,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="chevron-left" />
|
||||
<span>Previous</span>
|
||||
</button>
|
||||
)}
|
||||
<span />
|
||||
{results.pagination?.offset + results.pagination?.count <
|
||||
results.pagination?.total_count && (
|
||||
<button
|
||||
type="button"
|
||||
class="light small"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
fetchGIFs({
|
||||
offset: results.pagination?.offset + GIFS_PER_PAGE,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Next</span> <Icon icon="chevron-right" />
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
uiState === 'results' && (
|
||||
<div class="ui-state">
|
||||
<p>No results</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{uiState === 'error' && (
|
||||
<div class="ui-state">
|
||||
<p>Error loading GIFs</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
export default function CustomEmoji({ staticUrl, alt, url }) {
|
||||
return (
|
||||
<picture>
|
||||
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
|
||||
{staticUrl && (
|
||||
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
|
||||
)}
|
||||
<img
|
||||
key={alt}
|
||||
key={alt || url}
|
||||
src={url}
|
||||
alt={alt}
|
||||
class="shortcode-emoji emoji"
|
||||
|
|
|
@ -17,6 +17,21 @@
|
|||
);
|
||||
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 {
|
||||
|
|
|
@ -11,6 +11,7 @@ import useLocationChange from '../utils/useLocationChange';
|
|||
|
||||
import AccountBlock from './account-block';
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import Loader from './loader';
|
||||
import Status from './status';
|
||||
|
||||
|
@ -143,9 +144,12 @@ export default function GenericAccounts({
|
|||
</header>
|
||||
<main>
|
||||
{post && (
|
||||
<div class="post-preview">
|
||||
<Link
|
||||
to={`/${instance || currentInstance}/s/${post.id}`}
|
||||
class="post-preview"
|
||||
>
|
||||
<Status status={post} size="s" readOnly />
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{accounts.length > 0 ? (
|
||||
<>
|
||||
|
|
|
@ -6,6 +6,15 @@ 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>;
|
||||
|
|
|
@ -1,32 +1,45 @@
|
|||
/*
|
||||
Rendered but hidden. Only show when visible
|
||||
*/
|
||||
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
export default function LazyShazam({ children }) {
|
||||
// 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(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,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.bottom > 0) {
|
||||
setVisibleStart(true);
|
||||
if (rect.bottom > TOP) {
|
||||
if (rect.top < window.innerHeight) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
setVisibleStart(true);
|
||||
}
|
||||
if (id) shazamIDs[id] = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ import FilterContext from '../utils/filter-context';
|
|||
import { isFiltered } from '../utils/filters';
|
||||
import states, { statusKey } from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import { getCurrentAccountID } from '../utils/store-utils';
|
||||
|
||||
import Media from './media';
|
||||
|
||||
|
@ -88,7 +89,7 @@ function MediaPost({
|
|||
};
|
||||
|
||||
const currentAccount = useMemo(() => {
|
||||
return store.session.get('currentAccount');
|
||||
return getCurrentAccountID();
|
||||
}, []);
|
||||
const isSelf = useMemo(() => {
|
||||
return currentAccount && currentAccount === accountId;
|
||||
|
|
|
@ -9,12 +9,12 @@ import {
|
|||
} from 'preact/hooks';
|
||||
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
|
||||
|
||||
import formatDuration from '../utils/format-duration';
|
||||
import mem from '../utils/mem';
|
||||
import states from '../utils/states';
|
||||
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import { formatDuration } from './status';
|
||||
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
|
||||
|
||||
|
@ -74,7 +74,7 @@ function Media({
|
|||
altIndex,
|
||||
onClick = () => {},
|
||||
}) {
|
||||
const {
|
||||
let {
|
||||
blurhash,
|
||||
description,
|
||||
meta,
|
||||
|
@ -84,15 +84,27 @@ function Media({
|
|||
url,
|
||||
type,
|
||||
} = media;
|
||||
if (/no\-preview\./i.test(previewUrl)) {
|
||||
previewUrl = null;
|
||||
}
|
||||
const { original = {}, small, focus } = meta || {};
|
||||
|
||||
const width = showOriginal ? original?.width : small?.width;
|
||||
const height = showOriginal ? original?.height : small?.height;
|
||||
const width = showOriginal
|
||||
? original?.width
|
||||
: small?.width || original?.width;
|
||||
const height = showOriginal
|
||||
? original?.height
|
||||
: small?.height || original?.height;
|
||||
const mediaURL = showOriginal ? url : previewUrl || url;
|
||||
const remoteMediaURL = showOriginal
|
||||
? remoteUrl
|
||||
: previewRemoteUrl || remoteUrl;
|
||||
const orientation = width >= height ? 'landscape' : 'portrait';
|
||||
const hasDimensions = width && height;
|
||||
const orientation = hasDimensions
|
||||
? width > height
|
||||
? 'landscape'
|
||||
: 'portrait'
|
||||
: null;
|
||||
|
||||
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
|
||||
|
||||
|
@ -133,7 +145,8 @@ function Media({
|
|||
enabled: pinchZoomEnabled,
|
||||
draggableUnZoomed: false,
|
||||
inertiaFriction: 0.9,
|
||||
doubleTapZoomOutOnMaxScale: true,
|
||||
tapZoomFactor: 2,
|
||||
doubleTapToggleZoom: true,
|
||||
containerProps: {
|
||||
className: 'media-zoom',
|
||||
style: {
|
||||
|
@ -290,7 +303,11 @@ function Media({
|
|||
}}
|
||||
onError={(e) => {
|
||||
const { src } = e.target;
|
||||
if (src === mediaURL && mediaURL !== remoteMediaURL) {
|
||||
if (
|
||||
src === mediaURL &&
|
||||
remoteMediaURL &&
|
||||
mediaURL !== remoteMediaURL
|
||||
) {
|
||||
e.target.src = remoteMediaURL;
|
||||
}
|
||||
}}
|
||||
|
@ -321,6 +338,20 @@ function Media({
|
|||
onLoad={(e) => {
|
||||
// e.target.closest('.media-image').style.backgroundImage = '';
|
||||
e.target.dataset.loaded = true;
|
||||
if (!hasDimensions) {
|
||||
const $media = e.target.closest('.media');
|
||||
if ($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}`;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
const { src } = e.target;
|
||||
|
@ -338,6 +369,7 @@ function Media({
|
|||
</Figure>
|
||||
);
|
||||
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
|
||||
const hasDuration = original.duration > 0;
|
||||
const shortDuration = original.duration < 31;
|
||||
const isGIF = type === 'gifv' && shortDuration;
|
||||
// If GIF is too long, treat it as a video
|
||||
|
@ -356,7 +388,7 @@ function Media({
|
|||
data-orientation="${orientation}"
|
||||
preload="auto"
|
||||
autoplay
|
||||
muted="${isGIF}"
|
||||
${isGIF ? 'muted' : ''}
|
||||
${isGIF ? '' : 'controls'}
|
||||
playsinline
|
||||
loop="${loopable}"
|
||||
|
@ -473,14 +505,61 @@ function Media({
|
|||
/>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={showInlineDesc ? '' : description}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
loading="lazy"
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={showInlineDesc ? '' : description}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
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">
|
||||
<Icon icon="play" size="xl" />
|
||||
</div>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { MenuItem, SubMenu } from '@szhsin/react-menu';
|
||||
import { MenuItem } from '@szhsin/react-menu';
|
||||
import { cloneElement } from 'preact';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
import Menu2 from './menu2';
|
||||
import SubMenu2 from './submenu2';
|
||||
|
||||
function MenuConfirm({
|
||||
subMenu = false,
|
||||
|
@ -23,11 +23,9 @@ function MenuConfirm({
|
|||
}
|
||||
return children;
|
||||
}
|
||||
const Parent = subMenu ? SubMenu : Menu2;
|
||||
const menuRef = useRef();
|
||||
const Parent = subMenu ? SubMenu2 : Menu2;
|
||||
return (
|
||||
<Parent
|
||||
instanceRef={menuRef}
|
||||
openTrigger="clickOnly"
|
||||
direction="bottom"
|
||||
overflow="auto"
|
||||
|
@ -37,19 +35,6 @@ function MenuConfirm({
|
|||
{...restProps}
|
||||
menuButton={subMenu ? undefined : children}
|
||||
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}>
|
||||
{confirmLabel}
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
import './nav-menu.css';
|
||||
|
||||
import {
|
||||
ControlledMenu,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
SubMenu,
|
||||
} from '@szhsin/react-menu';
|
||||
import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useLongPress } from 'use-long-press';
|
||||
|
@ -16,10 +11,13 @@ import { getLists } from '../utils/lists';
|
|||
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
|
||||
import states from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import { getCurrentAccountID } from '../utils/store-utils';
|
||||
import supports from '../utils/supports';
|
||||
|
||||
import Avatar from './avatar';
|
||||
import Icon from './icon';
|
||||
import MenuLink from './menu-link';
|
||||
import SubMenu2 from './submenu2';
|
||||
|
||||
function NavMenu(props) {
|
||||
const snapStates = useSnapshot(states);
|
||||
|
@ -28,9 +26,8 @@ function NavMenu(props) {
|
|||
const [currentAccount, moreThanOneAccount] = useMemo(() => {
|
||||
const accounts = store.local.getJSON('accounts') || [];
|
||||
const acc =
|
||||
accounts.find(
|
||||
(account) => account.info.id === store.session.get('currentAccount'),
|
||||
) || accounts[0];
|
||||
accounts.find((account) => account.info.id === getCurrentAccountID()) ||
|
||||
accounts[0];
|
||||
return [acc, accounts.length > 1];
|
||||
}, []);
|
||||
|
||||
|
@ -87,8 +84,10 @@ function NavMenu(props) {
|
|||
return results;
|
||||
}
|
||||
|
||||
const supportsLists = supports('@mastodon/lists');
|
||||
const [lists, setLists] = useState([]);
|
||||
useEffect(() => {
|
||||
if (!supportsLists) return;
|
||||
if (menuState === 'open') {
|
||||
getLists().then(setLists);
|
||||
}
|
||||
|
@ -148,7 +147,7 @@ function NavMenu(props) {
|
|||
}}
|
||||
{...props}
|
||||
overflow="auto"
|
||||
// viewScroll="close"
|
||||
viewScroll="close"
|
||||
position="anchor"
|
||||
align="center"
|
||||
boundingBoxPadding={boundingBoxPadding}
|
||||
|
@ -190,9 +189,11 @@ function NavMenu(props) {
|
|||
<Icon icon="history2" size="l" />
|
||||
<span>Catch-up</span>
|
||||
</MenuLink>
|
||||
<MenuLink to="/mentions">
|
||||
<Icon icon="at" size="l" /> <span>Mentions</span>
|
||||
</MenuLink>
|
||||
{supports('@mastodon/mentions') && (
|
||||
<MenuLink to="/mentions">
|
||||
<Icon icon="at" size="l" /> <span>Mentions</span>
|
||||
</MenuLink>
|
||||
)}
|
||||
<MenuLink to="/notifications">
|
||||
<Icon icon="notification" size="l" /> <span>Notifications</span>
|
||||
{snapStates.notificationsShowNew && (
|
||||
|
@ -209,7 +210,7 @@ function NavMenu(props) {
|
|||
</MenuLink>
|
||||
)}
|
||||
{lists?.length > 0 ? (
|
||||
<SubMenu
|
||||
<SubMenu2
|
||||
menuClassName="nav-submenu"
|
||||
overflow="auto"
|
||||
gap={-8}
|
||||
|
@ -234,17 +235,19 @@ function NavMenu(props) {
|
|||
))}
|
||||
</>
|
||||
)}
|
||||
</SubMenu>
|
||||
</SubMenu2>
|
||||
) : (
|
||||
<MenuLink to="/l">
|
||||
<Icon icon="list" size="l" />
|
||||
<span>Lists</span>
|
||||
</MenuLink>
|
||||
supportsLists && (
|
||||
<MenuLink to="/l">
|
||||
<Icon icon="list" size="l" />
|
||||
<span>Lists</span>
|
||||
</MenuLink>
|
||||
)
|
||||
)}
|
||||
<MenuLink to="/b">
|
||||
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
|
||||
</MenuLink>
|
||||
<SubMenu
|
||||
<SubMenu2
|
||||
menuClassName="nav-submenu"
|
||||
overflow="auto"
|
||||
gap={-8}
|
||||
|
@ -264,10 +267,12 @@ function NavMenu(props) {
|
|||
<span>Followed Hashtags</span>
|
||||
</MenuLink>
|
||||
<MenuDivider />
|
||||
<MenuLink to="/ft">
|
||||
<Icon icon="filters" size="l" />
|
||||
Filters
|
||||
</MenuLink>
|
||||
{supports('@mastodon/filters') && (
|
||||
<MenuLink to="/ft">
|
||||
<Icon icon="filters" size="l" />
|
||||
Filters
|
||||
</MenuLink>
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showGenericAccounts = {
|
||||
|
@ -293,7 +298,7 @@ function NavMenu(props) {
|
|||
<Icon icon="block" size="l" />
|
||||
Blocked users…
|
||||
</MenuItem>{' '}
|
||||
</SubMenu>
|
||||
</SubMenu2>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
|
|
|
@ -4,6 +4,7 @@ import { memo } from 'preact/compat';
|
|||
import shortenNumber from '../utils/shorten-number';
|
||||
import states, { statusKey } from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import { getCurrentAccountID } from '../utils/store-utils';
|
||||
import useTruncated from '../utils/useTruncated';
|
||||
|
||||
import Avatar from './avatar';
|
||||
|
@ -27,6 +28,7 @@ const NOTIFICATION_ICONS = {
|
|||
'admin.signup': 'account-edit',
|
||||
'admin.report': 'account-warning',
|
||||
severed_relationships: 'heart-break',
|
||||
moderation_warning: 'alert',
|
||||
emoji_reaction: 'emoji2',
|
||||
'pleroma:emoji_reaction': 'emoji2',
|
||||
};
|
||||
|
@ -44,6 +46,8 @@ poll = A poll you have voted in or created has ended
|
|||
update = A status you interacted with has been edited
|
||||
admin.sign_up = Someone signed up (optionally sent to admins)
|
||||
admin.report = A new report has been filed
|
||||
severed_relationships = Severed relationships
|
||||
moderation_warning = Moderation warning
|
||||
*/
|
||||
|
||||
function emojiText(emoji, emoji_url) {
|
||||
|
@ -90,6 +94,7 @@ const contentText = {
|
|||
Lost connections with <i>{name}</i>.
|
||||
</>
|
||||
),
|
||||
moderation_warning: <b>Moderation warning</b>,
|
||||
emoji_reaction: emojiText,
|
||||
'pleroma:emoji_reaction': emojiText,
|
||||
};
|
||||
|
@ -116,6 +121,17 @@ const SEVERED_RELATIONSHIPS_TEXT = {
|
|||
),
|
||||
};
|
||||
|
||||
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 = 50;
|
||||
|
||||
function Notification({
|
||||
|
@ -124,15 +140,23 @@ function Notification({
|
|||
isStatic,
|
||||
disableContextMenu,
|
||||
}) {
|
||||
const { id, status, account, report, event, _accounts, _statuses } =
|
||||
notification;
|
||||
const {
|
||||
id,
|
||||
status,
|
||||
account,
|
||||
report,
|
||||
event,
|
||||
moderation_warning,
|
||||
_accounts,
|
||||
_statuses,
|
||||
} = notification;
|
||||
let { type } = notification;
|
||||
|
||||
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
|
||||
const actualStatus = status?.reblog || status;
|
||||
const actualStatusID = actualStatus?.id;
|
||||
|
||||
const currentAccount = store.session.get('currentAccount');
|
||||
const currentAccount = getCurrentAccountID();
|
||||
const isSelf = currentAccount === account?.id;
|
||||
const isVoted = status?.poll?.voted;
|
||||
const isReplyToOthers =
|
||||
|
@ -313,6 +337,20 @@ function Notification({
|
|||
.
|
||||
</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 && (
|
||||
|
|
|
@ -8,7 +8,7 @@ import dayjs from 'dayjs';
|
|||
import dayjsTwitter from 'dayjs-twitter';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { useEffect, useMemo, useReducer } from 'preact/hooks';
|
||||
|
||||
dayjs.extend(dayjsTwitter);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
@ -18,22 +18,51 @@ const dtf = new Intl.DateTimeFormat();
|
|||
|
||||
export default function RelativeTime({ datetime, format }) {
|
||||
if (!datetime) return null;
|
||||
const [renderCount, rerender] = useReducer((x) => x + 1, 0);
|
||||
const date = useMemo(() => dayjs(datetime), [datetime]);
|
||||
const dateStr = useMemo(() => {
|
||||
const [dateStr, dt, title] = useMemo(() => {
|
||||
if (!date.isValid()) return ['' + datetime, '', ''];
|
||||
let str;
|
||||
if (format === 'micro') {
|
||||
// If date <= 1 day ago or day is within this year
|
||||
const now = dayjs();
|
||||
const dayDiff = now.diff(date, 'day');
|
||||
if (dayDiff <= 1 || now.year() === date.year()) {
|
||||
return date.twitter();
|
||||
str = date.twitter();
|
||||
} else {
|
||||
return dtf.format(date.toDate());
|
||||
str = dtf.format(date.toDate());
|
||||
}
|
||||
}
|
||||
return date.fromNow();
|
||||
}, [date, format]);
|
||||
const dt = useMemo(() => date.toISOString(), [date]);
|
||||
const title = useMemo(() => date.format('LLLL'), [date]);
|
||||
if (!str) str = date.fromNow();
|
||||
return [str, date.toISOString(), date.format('LLLL')];
|
||||
}, [date, format, renderCount]);
|
||||
|
||||
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 (
|
||||
<time datetime={dt} title={title}>
|
||||
|
|
|
@ -19,6 +19,7 @@ import pmem from '../utils/pmem';
|
|||
import showToast from '../utils/show-toast';
|
||||
import states from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import { getCurrentAccountID } from '../utils/store-utils';
|
||||
|
||||
import AsyncText from './AsyncText';
|
||||
import Icon from './icon';
|
||||
|
@ -787,7 +788,7 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
disabled={importUIState === 'cloud-downloading'}
|
||||
onClick={async () => {
|
||||
setImportUIState('cloud-downloading');
|
||||
const currentAccount = store.session.get('currentAccount');
|
||||
const currentAccount = getCurrentAccountID();
|
||||
showToast(
|
||||
'Downloading saved shortcuts from instance server…',
|
||||
);
|
||||
|
@ -1043,7 +1044,7 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
disabled={importUIState === 'cloud-uploading'}
|
||||
onClick={async () => {
|
||||
setImportUIState('cloud-uploading');
|
||||
const currentAccount = store.session.get('currentAccount');
|
||||
const currentAccount = getCurrentAccountID();
|
||||
try {
|
||||
const relationships =
|
||||
await masto.v1.accounts.relationships.fetch({
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import './shortcuts.css';
|
||||
|
||||
import { MenuDivider, SubMenu } from '@szhsin/react-menu';
|
||||
import { MenuDivider } from '@szhsin/react-menu';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
@ -17,6 +17,7 @@ import Icon from './icon';
|
|||
import Link from './link';
|
||||
import Menu2 from './menu2';
|
||||
import MenuLink from './menu-link';
|
||||
import SubMenu2 from './submenu2';
|
||||
|
||||
function Shortcuts() {
|
||||
const { instance } = api();
|
||||
|
@ -182,7 +183,7 @@ function Shortcuts() {
|
|||
{formattedShortcuts.map(({ id, path, title, subtitle, icon }, i) => {
|
||||
if (id === 'lists') {
|
||||
return (
|
||||
<SubMenu
|
||||
<SubMenu2
|
||||
menuClassName="glass-menu"
|
||||
overflow="auto"
|
||||
gap={-8}
|
||||
|
@ -205,7 +206,7 @@ function Shortcuts() {
|
|||
<span>{list.title}</span>
|
||||
</MenuLink>
|
||||
))}
|
||||
</SubMenu>
|
||||
</SubMenu2>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -47,7 +47,7 @@
|
|||
}
|
||||
.visibility-direct {
|
||||
--yellow-stripes: repeating-linear-gradient(
|
||||
-45deg,
|
||||
135deg,
|
||||
var(--reply-to-faded-color),
|
||||
var(--reply-to-faded-color) 10px,
|
||||
var(--reply-to-faded-color) 10px,
|
||||
|
@ -160,7 +160,7 @@
|
|||
display: block;
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
&[data-read-more]:after {
|
||||
content: attr(data-read-more);
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
|
@ -365,6 +365,10 @@
|
|||
background-image: var(--yellow-stripes);
|
||||
}
|
||||
|
||||
.status-pre-meta + & {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
> * {
|
||||
opacity: 0.65;
|
||||
transition: opacity 1s ease-out;
|
||||
|
@ -565,8 +569,15 @@
|
|||
font-weight: bold;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
|
||||
&.horizontal {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
.status-filtered-badge.badge-meta {
|
||||
.status-filtered-badge:not(.horizontal).badge-meta {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
@ -580,10 +591,10 @@
|
|||
border-color: var(--text-color);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
.status-filtered-badge.badge-meta > span:first-child {
|
||||
.status-filtered-badge:not(.horizontal).badge-meta > span:first-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-filtered-badge.badge-meta > span + span {
|
||||
.status-filtered-badge:not(.horizontal).badge-meta > span + span {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
font-weight: normal;
|
||||
|
@ -597,6 +608,10 @@
|
|||
left: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.status-filtered-badge.horizontal.badge-meta > span + span {
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.status.large > .container > .content-container {
|
||||
margin-left: calc(-50px - 16px);
|
||||
|
@ -618,6 +633,7 @@
|
|||
~ *:not(
|
||||
.content.truncated,
|
||||
.media-container,
|
||||
.media-first-container,
|
||||
.card,
|
||||
.media-figure-multiple,
|
||||
.spoiler-media-button
|
||||
|
@ -638,6 +654,7 @@
|
|||
|
||||
~ *:not(
|
||||
.media-container,
|
||||
.media-first-container,
|
||||
.card,
|
||||
.media-figure-multiple,
|
||||
.spoiler-media-button
|
||||
|
@ -708,11 +725,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
~ :is(.media-container, .media-figure-multiple) .media {
|
||||
~ :is(.media-container, .media-first-container, .media-figure-multiple)
|
||||
.media {
|
||||
background-image: radial-gradient(
|
||||
circle at 50% 50%,
|
||||
var(--average-color, var(--bg-faded-color)),
|
||||
var(--bg-color) 20em
|
||||
var(--bg-color) 25em
|
||||
);
|
||||
|
||||
> *:not(.media-play, .alt-badge) {
|
||||
|
@ -790,7 +808,9 @@
|
|||
black 1.5em
|
||||
);
|
||||
}
|
||||
.timeline-deck .status:not(.truncated .status) .content.truncated:after {
|
||||
.timeline-deck
|
||||
.status:not(.truncated .status)
|
||||
.content.truncated[data-read-more]:after {
|
||||
content: attr(data-read-more);
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
|
@ -816,6 +836,12 @@
|
|||
.timeline-deck .status .content.truncated ~ .card {
|
||||
display: none;
|
||||
}
|
||||
.status .content .inner-content {
|
||||
> img[height] {
|
||||
height: auto;
|
||||
aspect-ratio: var(--original-aspect-ratio);
|
||||
}
|
||||
}
|
||||
.status .content .inner-content a:not(.mention, .has-url-text) {
|
||||
color: var(--link-text-color);
|
||||
}
|
||||
|
@ -908,7 +934,7 @@
|
|||
grid-auto-rows: 1fr;
|
||||
gap: 2px;
|
||||
/* height: 160px; */
|
||||
min-height: var(--pointer-min-dimension);
|
||||
min-height: var(--min-dimension);
|
||||
height: auto;
|
||||
max-height: max(160px, 33vh);
|
||||
}
|
||||
|
@ -1037,9 +1063,9 @@
|
|||
.status .media-container.media-eq1 .media {
|
||||
display: inline-block;
|
||||
max-width: 100% !important;
|
||||
min-width: var(--pointer-min-dimension);
|
||||
min-width: var(--min-dimension);
|
||||
/* width: auto; */
|
||||
min-height: var(--pointer-min-dimension);
|
||||
min-height: var(--min-dimension);
|
||||
/* --maxAspectHeight: max(160px, 33vh);
|
||||
--aspectWidth: calc(--width / --height * var(--maxAspectHeight)); */
|
||||
width: min(var(--aspectWidth), var(--width), 100%);
|
||||
|
@ -1300,7 +1326,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
|||
:is(.status, .media-post) .media-audio {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: var(--pointer-min-dimension);
|
||||
min-height: var(--min-dimension);
|
||||
background-image: radial-gradient(
|
||||
circle at center center,
|
||||
transparent,
|
||||
|
@ -1314,6 +1340,258 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
|||
background-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.status.skeleton .media-first-container {
|
||||
min-height: 320px;
|
||||
background-color: var(--outline-color);
|
||||
}
|
||||
|
||||
@keyframes media-carousel-slide {
|
||||
0% {
|
||||
transform: translateX(calc(var(--dots-count, 1) * 2.5px));
|
||||
}
|
||||
100% {
|
||||
transform: translateX(calc(var(--dots-count, 1) * -2.5px));
|
||||
}
|
||||
}
|
||||
|
||||
.status-media-first {
|
||||
timeline-scope: --media-carousel;
|
||||
|
||||
.meta-name {
|
||||
opacity: 0.65;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
|
||||
b + i {
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
:is(:hover, :focus) > & .meta-name {
|
||||
opacity: 1;
|
||||
b + i {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.media-first-spoiler-content {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:hover .media-first-spoiler-content {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-first-spoiler-button {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
.media-first-container {
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
margin-inline: -16px;
|
||||
|
||||
@media (min-width: 40em) {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.media-carousel-controls {
|
||||
flex-shrink: 0;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.carousel-indexer {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
color: var(--media-fg-color);
|
||||
background-color: var(--media-bg-color);
|
||||
padding: 2px 8px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.8em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.6;
|
||||
transition: opacity 1s ease-in-out 0.3s;
|
||||
border: var(--hairline-width) solid var(--media-outline-color);
|
||||
}
|
||||
|
||||
.media-carousel-button {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding-inline: 8px;
|
||||
margin-block: 3em;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.carousel-button {
|
||||
@media (pointer: coarse) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+ .carousel-button {
|
||||
left: auto;
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.carousel-button {
|
||||
filter: opacity(0);
|
||||
}
|
||||
&:hover .carousel-button {
|
||||
filter: opacity(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-first-carousel {
|
||||
display: flex;
|
||||
max-height: 80vh;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
user-select: none;
|
||||
scrollbar-width: none;
|
||||
/* border: var(--hairline-width) solid var(--outline-color);
|
||||
border-inline-width: 0;
|
||||
background-color: var(--bg-faded-color); */
|
||||
box-shadow: 0 0 0 var(--hairline-width) var(--outline-color);
|
||||
scroll-timeline: --media-carousel x;
|
||||
|
||||
@media (min-width: 40em) {
|
||||
/* margin-inline: 0; */
|
||||
/* border-radius: 4px; */
|
||||
/* border-inline-width: var(--hairline-width); */
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .media-first-item {
|
||||
scroll-snap-align: center;
|
||||
scroll-snap-stop: always;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:not(:only-child) {
|
||||
background-color: var(--bg-blur-color);
|
||||
/* box-shadow: inset 0 0 0 var(--hairline-width) var(--outline-color); */
|
||||
}
|
||||
|
||||
.media {
|
||||
/* background-color: var(--average-color, var(--bg-faded-color)); */
|
||||
width: var(--width, 100%);
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
min-height: var(--min-dimension);
|
||||
/* max-height: min(var(--height), 80vh); */
|
||||
|
||||
&:has(img:not([data-loaded='true'])) {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
object-fit: scale-down;
|
||||
animation: none;
|
||||
|
||||
&:not([data-loaded='true']) {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
:is(:hover, :focus) > & .carousel-indexer {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.media-carousel-dots {
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation: media-carousel-slide 1s linear both;
|
||||
animation-timeline: --media-carousel;
|
||||
}
|
||||
|
||||
.carousel-dot {
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--text-color);
|
||||
transition: all 0.3s ease-in-out;
|
||||
opacity: 0.3;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
background-color: var(--text-color);
|
||||
transform: scale(1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-first-content {
|
||||
margin-top: 8px;
|
||||
height: 1.75em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9em;
|
||||
mask-image: linear-gradient(to bottom, black 1.5em, transparent 1.75em);
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
|
||||
@media (min-width: 40em) {
|
||||
margin-inline: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
text-align: center;
|
||||
/* Brute force ellipsis */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
a {
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
:is(:hover, :focus) > & .media-first-content {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.status:not(.large) .hashtag-stuffing {
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
@ -1695,13 +1973,14 @@ a.card:is(:hover, :focus):visited {
|
|||
}
|
||||
.poll-label input:is([type='radio'], [type='checkbox']) {
|
||||
flex-shrink: 0;
|
||||
margin: 3px;
|
||||
min-height: 1em;
|
||||
margin: 0 3px;
|
||||
min-height: 0.9em;
|
||||
}
|
||||
.poll-option-votes {
|
||||
flex-shrink: 0;
|
||||
font-size: 90%;
|
||||
opacity: 0.75;
|
||||
line-height: 1;
|
||||
}
|
||||
.poll-option-leading .poll-option-votes {
|
||||
font-weight: bold;
|
||||
|
@ -2118,8 +2397,8 @@ a.card:is(:hover, :focus):visited {
|
|||
max-width: 100%;
|
||||
height: 1.2em;
|
||||
vertical-align: text-bottom;
|
||||
object-fit: cover;
|
||||
object-position: left;
|
||||
object-fit: contain;
|
||||
/* object-position: left; */
|
||||
}
|
||||
|
||||
/* EDIT HISTORY */
|
||||
|
@ -2288,7 +2567,7 @@ a.card:is(:hover, :focus):visited {
|
|||
mask-image: linear-gradient(to bottom, #000 80px, transparent);
|
||||
}
|
||||
|
||||
&:after {
|
||||
&[data-read-more]:after {
|
||||
content: attr(data-read-more);
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash';
|
||||
import { shallowEqual } from 'fast-equals';
|
||||
import prettify from 'html-prettify';
|
||||
import { Fragment } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
import {
|
||||
useCallback,
|
||||
|
@ -20,6 +21,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import punycode from 'punycode';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useLongPress } from 'use-long-press';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
@ -54,6 +56,8 @@ import { speak, supportsTTS } from '../utils/speech';
|
|||
import states, { getStatus, saveStatus, statusKey } from '../utils/states';
|
||||
import statusPeek from '../utils/status-peek';
|
||||
import store from '../utils/store';
|
||||
import { getCurrentAccountID } from '../utils/store-utils';
|
||||
import supports from '../utils/supports';
|
||||
import unfurlMastodonLink from '../utils/unfurl-link';
|
||||
import useTruncated from '../utils/useTruncated';
|
||||
import visibilityIconsMap from '../utils/visibility-icons-map';
|
||||
|
@ -147,6 +151,12 @@ const PostContent = memo(
|
|||
},
|
||||
);
|
||||
|
||||
const SIZE_CLASS = {
|
||||
s: 'small',
|
||||
m: 'medium',
|
||||
l: 'large',
|
||||
};
|
||||
|
||||
function Status({
|
||||
statusID,
|
||||
status,
|
||||
|
@ -168,15 +178,23 @@ function Status({
|
|||
allowContextMenu,
|
||||
showActionsBar,
|
||||
showReplyParent,
|
||||
mediaFirst,
|
||||
}) {
|
||||
if (skeleton) {
|
||||
return (
|
||||
<div class="status skeleton">
|
||||
<Avatar size="xxl" />
|
||||
<div
|
||||
class={`status skeleton ${
|
||||
mediaFirst ? 'status-media-first small' : ''
|
||||
}`}
|
||||
>
|
||||
{!mediaFirst && <Avatar size="xxl" />}
|
||||
<div class="container">
|
||||
<div class="meta">███ ████████</div>
|
||||
<div class="meta">
|
||||
{(size === 's' || mediaFirst) && <Avatar size="m" />} ███ ████████
|
||||
</div>
|
||||
<div class="content-container">
|
||||
<div class="content">
|
||||
{mediaFirst && <div class="media-first-container" />}
|
||||
<div class={`content ${mediaFirst ? 'media-first-content' : ''}`}>
|
||||
<p>████ ████████</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -246,8 +264,12 @@ function Status({
|
|||
emojiReactions,
|
||||
} = status;
|
||||
|
||||
// if (!mediaAttachments?.length) mediaFirst = false;
|
||||
const hasMediaAttachments = !!mediaAttachments?.length;
|
||||
if (mediaFirst && hasMediaAttachments) size = 's';
|
||||
|
||||
const currentAccount = useMemo(() => {
|
||||
return store.session.get('currentAccount');
|
||||
return getCurrentAccountID();
|
||||
}, []);
|
||||
const isSelf = useMemo(() => {
|
||||
return currentAccount && currentAccount === accountId;
|
||||
|
@ -353,6 +375,7 @@ function Status({
|
|||
size={size}
|
||||
contentTextWeight={contentTextWeight}
|
||||
readOnly={readOnly}
|
||||
mediaFirst={mediaFirst}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -377,14 +400,15 @@ function Status({
|
|||
contentTextWeight={contentTextWeight}
|
||||
readOnly={readOnly}
|
||||
enableCommentHint
|
||||
mediaFirst={mediaFirst}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check followedTags
|
||||
if (showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length) {
|
||||
return (
|
||||
const FollowedTagsParent = useCallback(
|
||||
({ children }) => (
|
||||
<div
|
||||
data-state-post-id={sKey}
|
||||
class="status-followed-tags"
|
||||
|
@ -402,18 +426,15 @@ function Status({
|
|||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Status
|
||||
status={statusID ? null : status}
|
||||
statusID={statusID ? status.id : null}
|
||||
instance={instance}
|
||||
size={size}
|
||||
contentTextWeight={contentTextWeight}
|
||||
readOnly={readOnly}
|
||||
enableCommentHint
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
),
|
||||
[sKey, instance, snapStates.statusFollowedTags[sKey]],
|
||||
);
|
||||
const StatusParent =
|
||||
showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length
|
||||
? FollowedTagsParent
|
||||
: Fragment;
|
||||
|
||||
const isSizeLarge = size === 'l';
|
||||
|
||||
|
@ -627,6 +648,7 @@ function Status({
|
|||
};
|
||||
|
||||
const bookmarkStatus = async () => {
|
||||
if (!supports('@mastodon/post-bookmark')) return;
|
||||
if (!sameInstance || !authenticated) {
|
||||
alert(unauthInteractionErrorMessage);
|
||||
return false;
|
||||
|
@ -814,13 +836,15 @@ function Status({
|
|||
: 'Like'}
|
||||
</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={bookmarkStatusNotify}
|
||||
className={`menu-bookmark ${bookmarked ? 'checked' : ''}`}
|
||||
>
|
||||
<Icon icon="bookmark" />
|
||||
<span>{bookmarked ? 'Unbookmark' : 'Bookmark'}</span>
|
||||
</MenuItem>
|
||||
{supports('@mastodon/post-bookmark') && (
|
||||
<MenuItem
|
||||
onClick={bookmarkStatusNotify}
|
||||
className={`menu-bookmark ${bookmarked ? 'checked' : ''}`}
|
||||
>
|
||||
<Icon icon="bookmark" />
|
||||
<span>{bookmarked ? 'Unbookmark' : 'Bookmark'}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
@ -847,56 +871,62 @@ function Status({
|
|||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{(enableTranslate || !language || differentLanguage) && <MenuDivider />}
|
||||
{enableTranslate ? (
|
||||
<div class={supportsTTS ? 'menu-horizontal' : ''}>
|
||||
<MenuItem
|
||||
disabled={forceTranslate}
|
||||
onClick={() => {
|
||||
setForceTranslate(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="translate" />
|
||||
<span>Translate</span>
|
||||
</MenuItem>
|
||||
{supportsTTS && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
const postText = getPostText(status);
|
||||
if (postText) {
|
||||
speak(postText, language);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="speak" />
|
||||
<span>Speak</span>
|
||||
</MenuItem>
|
||||
{!mediaFirst && (
|
||||
<>
|
||||
{(enableTranslate || !language || differentLanguage) && (
|
||||
<MenuDivider />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
(!language || differentLanguage) && (
|
||||
<div class={supportsTTS ? 'menu-horizontal' : ''}>
|
||||
<MenuLink
|
||||
to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`}
|
||||
>
|
||||
<Icon icon="translate" />
|
||||
<span>Translate</span>
|
||||
</MenuLink>
|
||||
{supportsTTS && (
|
||||
{enableTranslate ? (
|
||||
<div class={supportsTTS ? 'menu-horizontal' : ''}>
|
||||
<MenuItem
|
||||
disabled={forceTranslate}
|
||||
onClick={() => {
|
||||
const postText = getPostText(status);
|
||||
if (postText) {
|
||||
speak(postText, language);
|
||||
}
|
||||
setForceTranslate(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="speak" />
|
||||
<span>Speak</span>
|
||||
<Icon icon="translate" />
|
||||
<span>Translate</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
{supportsTTS && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
const postText = getPostText(status);
|
||||
if (postText) {
|
||||
speak(postText, language);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="speak" />
|
||||
<span>Speak</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
(!language || differentLanguage) && (
|
||||
<div class={supportsTTS ? 'menu-horizontal' : ''}>
|
||||
<MenuLink
|
||||
to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`}
|
||||
>
|
||||
<Icon icon="translate" />
|
||||
<span>Translate</span>
|
||||
</MenuLink>
|
||||
{supportsTTS && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
const postText = getPostText(status);
|
||||
if (postText) {
|
||||
speak(postText, language);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="speak" />
|
||||
<span>Speak</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{((!isSizeLarge && sameInstance) ||
|
||||
enableTranslate ||
|
||||
|
@ -1058,16 +1088,18 @@ function Status({
|
|||
)}
|
||||
{isSelf && (
|
||||
<div class="menu-horizontal">
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showCompose = {
|
||||
editStatus: status,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="pencil" />
|
||||
<span>Edit</span>
|
||||
</MenuItem>
|
||||
{supports('@mastodon/post-edit') && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showCompose = {
|
||||
editStatus: status,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="pencil" />
|
||||
<span>Edit</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{isSizeLarge && (
|
||||
<MenuConfirm
|
||||
subMenu
|
||||
|
@ -1348,7 +1380,7 @@ function Status({
|
|||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusParent>
|
||||
{showReplyParent && !!(inReplyToId && inReplyToAccountId) && (
|
||||
<StatusCompact sKey={sKey} />
|
||||
)}
|
||||
|
@ -1376,14 +1408,10 @@ function Status({
|
|||
? 'status-reply-to'
|
||||
: ''
|
||||
} visibility-${visibility} ${_pinned ? 'status-pinned' : ''} ${
|
||||
{
|
||||
s: 'small',
|
||||
m: 'medium',
|
||||
l: 'large',
|
||||
}[size]
|
||||
SIZE_CLASS[size]
|
||||
} ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''} ${
|
||||
isContextMenuOpen ? 'status-menu-open' : ''
|
||||
}`}
|
||||
} ${mediaFirst && hasMediaAttachments ? 'status-media-first' : ''}`}
|
||||
onMouseEnter={debugHover}
|
||||
onContextMenu={(e) => {
|
||||
if (!showContextMenu) return;
|
||||
|
@ -1711,188 +1739,253 @@ function Status({
|
|||
}
|
||||
}
|
||||
>
|
||||
{!!spoilerText && (
|
||||
{mediaFirst && hasMediaAttachments ? (
|
||||
<>
|
||||
<div
|
||||
class="content spoiler-content"
|
||||
lang={language}
|
||||
dir="auto"
|
||||
ref={spoilerContentRef}
|
||||
data-read-more={readMoreText}
|
||||
>
|
||||
<p>
|
||||
<EmojiText text={spoilerText} emojis={emojis} />
|
||||
</p>
|
||||
</div>
|
||||
{readingExpandSpoilers || previewMode ? (
|
||||
<div class="spoiler-divider">
|
||||
<Icon icon="eye-open" /> Content warning
|
||||
{(!!spoilerText || !!sensitive) && !readingExpandSpoilers && (
|
||||
<>
|
||||
{!!spoilerText && (
|
||||
<span
|
||||
class="spoiler-content media-first-spoiler-content"
|
||||
lang={language}
|
||||
dir="auto"
|
||||
ref={spoilerContentRef}
|
||||
data-read-more={readMoreText}
|
||||
>
|
||||
<EmojiText text={spoilerText} emojis={emojis} />{' '}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
class={`light spoiler-button media-first-spoiler-button ${
|
||||
showSpoiler ? 'spoiling' : ''
|
||||
}`}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (showSpoiler) {
|
||||
delete states.spoilers[id];
|
||||
if (!readingExpandSpoilers) {
|
||||
delete states.spoilersMedia[id];
|
||||
}
|
||||
} else {
|
||||
states.spoilers[id] = true;
|
||||
if (!readingExpandSpoilers) {
|
||||
states.spoilersMedia[id] = true;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
|
||||
{showSpoiler ? 'Show less' : 'Show content'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<MediaFirstContainer
|
||||
mediaAttachments={mediaAttachments}
|
||||
language={language}
|
||||
postID={id}
|
||||
instance={instance}
|
||||
/>
|
||||
{!!content && (
|
||||
<div class="media-first-content content" ref={contentRef}>
|
||||
<PostContent
|
||||
post={status}
|
||||
instance={instance}
|
||||
previewMode={previewMode}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
class={`light spoiler-button ${
|
||||
showSpoiler ? 'spoiling' : ''
|
||||
}`}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (showSpoiler) {
|
||||
delete states.spoilers[id];
|
||||
if (!readingExpandSpoilers) {
|
||||
delete states.spoilersMedia[id];
|
||||
}
|
||||
} else {
|
||||
states.spoilers[id] = true;
|
||||
if (!readingExpandSpoilers) {
|
||||
states.spoilersMedia[id] = true;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
|
||||
{showSpoiler ? 'Show less' : 'Show content'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!!content && (
|
||||
<div
|
||||
class="content"
|
||||
ref={contentRef}
|
||||
data-read-more={readMoreText}
|
||||
>
|
||||
<PostContent
|
||||
post={status}
|
||||
instance={instance}
|
||||
previewMode={previewMode}
|
||||
/>
|
||||
<QuoteStatuses id={id} instance={instance} level={quoted} />
|
||||
</div>
|
||||
)}
|
||||
{!!poll && (
|
||||
<Poll
|
||||
lang={language}
|
||||
poll={poll}
|
||||
readOnly={readOnly || !sameInstance || !authenticated}
|
||||
onUpdate={(newPoll) => {
|
||||
states.statuses[sKey].poll = newPoll;
|
||||
}}
|
||||
refresh={() => {
|
||||
return masto.v1.polls
|
||||
.$select(poll.id)
|
||||
.fetch()
|
||||
.then((pollResponse) => {
|
||||
states.statuses[sKey].poll = pollResponse;
|
||||
})
|
||||
.catch((e) => {}); // Silently fail
|
||||
}}
|
||||
votePoll={(choices) => {
|
||||
return masto.v1.polls
|
||||
.$select(poll.id)
|
||||
.votes.create({
|
||||
choices,
|
||||
})
|
||||
.then((pollResponse) => {
|
||||
states.statuses[sKey].poll = pollResponse;
|
||||
})
|
||||
.catch((e) => {}); // Silently fail
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(((enableTranslate || inlineTranslate) &&
|
||||
!!content.trim() &&
|
||||
!!getHTMLText(emojifyText(content, emojis)) &&
|
||||
differentLanguage) ||
|
||||
forceTranslate) && (
|
||||
<TranslationBlock
|
||||
forceTranslate={forceTranslate || inlineTranslate}
|
||||
mini={!isSizeLarge && !withinContext}
|
||||
sourceLanguage={language}
|
||||
text={getPostText(status)}
|
||||
/>
|
||||
)}
|
||||
{!previewMode &&
|
||||
sensitive &&
|
||||
!!mediaAttachments.length &&
|
||||
readingExpandMedia !== 'show_all' && (
|
||||
<button
|
||||
class={`plain spoiler-media-button ${
|
||||
showSpoilerMedia ? 'spoiling' : ''
|
||||
}`}
|
||||
type="button"
|
||||
hidden={!readingExpandSpoilers && !!spoilerText}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (showSpoilerMedia) {
|
||||
delete states.spoilersMedia[id];
|
||||
} else {
|
||||
states.spoilersMedia[id] = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon={showSpoilerMedia ? 'eye-open' : 'eye-close'} />{' '}
|
||||
{showSpoilerMedia ? 'Show less' : 'Show media'}
|
||||
</button>
|
||||
)}
|
||||
{!!mediaAttachments.length && (
|
||||
<MultipleMediaFigure
|
||||
lang={language}
|
||||
enabled={showMultipleMediaCaptions}
|
||||
captionChildren={captionChildren}
|
||||
>
|
||||
<div
|
||||
ref={mediaContainerRef}
|
||||
class={`media-container media-eq${mediaAttachments.length} ${
|
||||
mediaAttachments.length > 2 ? 'media-gt2' : ''
|
||||
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
|
||||
>
|
||||
{displayedMediaAttachments.map((media, i) => (
|
||||
<Media
|
||||
key={media.id}
|
||||
media={media}
|
||||
autoAnimate={isSizeLarge}
|
||||
showCaption={mediaAttachments.length === 1}
|
||||
allowLongerCaption={
|
||||
!content && mediaAttachments.length === 1
|
||||
}
|
||||
) : (
|
||||
<>
|
||||
{!!spoilerText && (
|
||||
<>
|
||||
<div
|
||||
class="content spoiler-content"
|
||||
lang={language}
|
||||
altIndex={
|
||||
showMultipleMediaCaptions &&
|
||||
!!media.description &&
|
||||
i + 1
|
||||
}
|
||||
to={`/${instance}/s/${id}?${
|
||||
withinContext ? 'media' : 'media-only'
|
||||
}=${i + 1}`}
|
||||
onClick={
|
||||
onMediaClick
|
||||
? (e) => {
|
||||
onMediaClick(e, i, media, status);
|
||||
dir="auto"
|
||||
ref={spoilerContentRef}
|
||||
data-read-more={readMoreText}
|
||||
>
|
||||
<p>
|
||||
<EmojiText text={spoilerText} emojis={emojis} />
|
||||
</p>
|
||||
</div>
|
||||
{readingExpandSpoilers || previewMode ? (
|
||||
<div class="spoiler-divider">
|
||||
<Icon icon="eye-open" /> Content warning
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
class={`light spoiler-button ${
|
||||
showSpoiler ? 'spoiling' : ''
|
||||
}`}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (showSpoiler) {
|
||||
delete states.spoilers[id];
|
||||
if (!readingExpandSpoilers) {
|
||||
delete states.spoilersMedia[id];
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
} else {
|
||||
states.spoilers[id] = true;
|
||||
if (!readingExpandSpoilers) {
|
||||
states.spoilersMedia[id] = true;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
|
||||
{showSpoiler ? 'Show less' : 'Show content'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!!content && (
|
||||
<div
|
||||
class="content"
|
||||
ref={contentRef}
|
||||
data-read-more={readMoreText}
|
||||
>
|
||||
<PostContent
|
||||
post={status}
|
||||
instance={instance}
|
||||
previewMode={previewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</MultipleMediaFigure>
|
||||
<QuoteStatuses id={id} instance={instance} level={quoted} />
|
||||
</div>
|
||||
)}
|
||||
{!!poll && (
|
||||
<Poll
|
||||
lang={language}
|
||||
poll={poll}
|
||||
readOnly={readOnly || !sameInstance || !authenticated}
|
||||
onUpdate={(newPoll) => {
|
||||
states.statuses[sKey].poll = newPoll;
|
||||
}}
|
||||
refresh={() => {
|
||||
return masto.v1.polls
|
||||
.$select(poll.id)
|
||||
.fetch()
|
||||
.then((pollResponse) => {
|
||||
states.statuses[sKey].poll = pollResponse;
|
||||
})
|
||||
.catch((e) => {}); // Silently fail
|
||||
}}
|
||||
votePoll={(choices) => {
|
||||
return masto.v1.polls
|
||||
.$select(poll.id)
|
||||
.votes.create({
|
||||
choices,
|
||||
})
|
||||
.then((pollResponse) => {
|
||||
states.statuses[sKey].poll = pollResponse;
|
||||
})
|
||||
.catch((e) => {}); // Silently fail
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(((enableTranslate || inlineTranslate) &&
|
||||
!!content.trim() &&
|
||||
!!getHTMLText(emojifyText(content, emojis)) &&
|
||||
differentLanguage) ||
|
||||
forceTranslate) && (
|
||||
<TranslationBlock
|
||||
forceTranslate={forceTranslate || inlineTranslate}
|
||||
mini={!isSizeLarge && !withinContext}
|
||||
sourceLanguage={language}
|
||||
text={getPostText(status)}
|
||||
/>
|
||||
)}
|
||||
{!previewMode &&
|
||||
sensitive &&
|
||||
!!mediaAttachments.length &&
|
||||
readingExpandMedia !== 'show_all' && (
|
||||
<button
|
||||
class={`plain spoiler-media-button ${
|
||||
showSpoilerMedia ? 'spoiling' : ''
|
||||
}`}
|
||||
type="button"
|
||||
hidden={!readingExpandSpoilers && !!spoilerText}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (showSpoilerMedia) {
|
||||
delete states.spoilersMedia[id];
|
||||
} else {
|
||||
states.spoilersMedia[id] = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={showSpoilerMedia ? 'eye-open' : 'eye-close'}
|
||||
/>{' '}
|
||||
{showSpoilerMedia ? 'Show less' : 'Show media'}
|
||||
</button>
|
||||
)}
|
||||
{!!mediaAttachments.length && (
|
||||
<MultipleMediaFigure
|
||||
lang={language}
|
||||
enabled={showMultipleMediaCaptions}
|
||||
captionChildren={captionChildren}
|
||||
>
|
||||
<div
|
||||
ref={mediaContainerRef}
|
||||
class={`media-container media-eq${
|
||||
mediaAttachments.length
|
||||
} ${mediaAttachments.length > 2 ? 'media-gt2' : ''} ${
|
||||
mediaAttachments.length > 4 ? 'media-gt4' : ''
|
||||
}`}
|
||||
>
|
||||
{displayedMediaAttachments.map((media, i) => (
|
||||
<Media
|
||||
key={media.id}
|
||||
media={media}
|
||||
autoAnimate={isSizeLarge}
|
||||
showCaption={mediaAttachments.length === 1}
|
||||
allowLongerCaption={
|
||||
!content && mediaAttachments.length === 1
|
||||
}
|
||||
lang={language}
|
||||
altIndex={
|
||||
showMultipleMediaCaptions &&
|
||||
!!media.description &&
|
||||
i + 1
|
||||
}
|
||||
to={`/${instance}/s/${id}?${
|
||||
withinContext ? 'media' : 'media-only'
|
||||
}=${i + 1}`}
|
||||
onClick={
|
||||
onMediaClick
|
||||
? (e) => {
|
||||
onMediaClick(e, i, media, status);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</MultipleMediaFigure>
|
||||
)}
|
||||
{!!card &&
|
||||
/^https/i.test(card?.url) &&
|
||||
!sensitive &&
|
||||
!spoilerText &&
|
||||
!poll &&
|
||||
!mediaAttachments.length &&
|
||||
!snapStates.statusQuotes[sKey] && (
|
||||
<Card
|
||||
card={card}
|
||||
selfReferential={
|
||||
card?.url === status.url || card?.url === status.uri
|
||||
}
|
||||
instance={currentInstance}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!!card &&
|
||||
/^https/i.test(card?.url) &&
|
||||
!sensitive &&
|
||||
!spoilerText &&
|
||||
!poll &&
|
||||
!mediaAttachments.length &&
|
||||
!snapStates.statusQuotes[sKey] && (
|
||||
<Card
|
||||
card={card}
|
||||
selfReferential={
|
||||
card?.url === status.url || card?.url === status.uri
|
||||
}
|
||||
instance={currentInstance}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isSizeLarge && showCommentCount && (
|
||||
<div class="content-comment-hint insignificant">
|
||||
|
@ -1942,7 +2035,24 @@ function Status({
|
|||
{!!emojiReactions?.length && (
|
||||
<div class="emoji-reactions">
|
||||
{emojiReactions.map((emojiReaction) => {
|
||||
const { name, count, me } = emojiReaction;
|
||||
const { name, count, me, url, staticUrl } = emojiReaction;
|
||||
if (url) {
|
||||
// Some servers return url and staticUrl
|
||||
return (
|
||||
<span
|
||||
class={`emoji-reaction tag ${
|
||||
me ? '' : 'insignificant'
|
||||
}`}
|
||||
>
|
||||
<CustomEmoji
|
||||
alt={name}
|
||||
url={url}
|
||||
staticUrl={staticUrl}
|
||||
/>{' '}
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const isShortCode = /^:.+?:$/.test(name);
|
||||
if (isShortCode) {
|
||||
const emoji = emojis.find(
|
||||
|
@ -1961,7 +2071,7 @@ function Status({
|
|||
alt={name}
|
||||
url={emoji.url}
|
||||
staticUrl={emoji.staticUrl}
|
||||
/>
|
||||
/>{' '}
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
|
@ -2059,16 +2169,18 @@ function Status({
|
|||
onClick={favouriteStatus}
|
||||
/>
|
||||
</div>
|
||||
<div class="action">
|
||||
<StatusButton
|
||||
checked={bookmarked}
|
||||
title={['Bookmark', 'Unbookmark']}
|
||||
alt={['Bookmark', 'Bookmarked']}
|
||||
class="bookmark-button"
|
||||
icon="bookmark"
|
||||
onClick={bookmarkStatus}
|
||||
/>
|
||||
</div>
|
||||
{supports('@mastodon/post-bookmark') && (
|
||||
<div class="action">
|
||||
<StatusButton
|
||||
checked={bookmarked}
|
||||
title={['Bookmark', 'Unbookmark']}
|
||||
alt={['Bookmark', 'Bookmarked']}
|
||||
class="bookmark-button"
|
||||
icon="bookmark"
|
||||
onClick={bookmarkStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Menu2
|
||||
portal={{
|
||||
target:
|
||||
|
@ -2136,7 +2248,7 @@ function Status({
|
|||
</Modal>
|
||||
)}
|
||||
</article>
|
||||
</>
|
||||
</StatusParent>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -2153,6 +2265,108 @@ function MultipleMediaFigure(props) {
|
|||
);
|
||||
}
|
||||
|
||||
function MediaFirstContainer(props) {
|
||||
const { mediaAttachments, language, postID, instance } = props;
|
||||
const moreThanOne = mediaAttachments.length > 1;
|
||||
|
||||
const carouselRef = useRef();
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let handleScroll = () => {
|
||||
const { clientWidth, scrollLeft } = carouselRef.current;
|
||||
const index = Math.round(scrollLeft / clientWidth);
|
||||
setCurrentIndex(index);
|
||||
};
|
||||
if (carouselRef.current) {
|
||||
carouselRef.current.addEventListener('scroll', handleScroll, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (carouselRef.current) {
|
||||
carouselRef.current.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="media-first-container">
|
||||
<div class="media-first-carousel" ref={carouselRef}>
|
||||
{mediaAttachments.map((media, i) => (
|
||||
<div class="media-first-item" key={media.id}>
|
||||
<Media
|
||||
media={media}
|
||||
lang={language}
|
||||
to={`/${instance}/s/${postID}?media=${i + 1}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{moreThanOne && (
|
||||
<div class="media-carousel-controls">
|
||||
<div class="carousel-indexer">
|
||||
{currentIndex + 1}/{mediaAttachments.length}
|
||||
</div>
|
||||
<label class="media-carousel-button">
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-button"
|
||||
hidden={currentIndex === 0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
carouselRef.current.focus();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * (currentIndex - 1),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-left" />
|
||||
</button>
|
||||
</label>
|
||||
<label class="media-carousel-button">
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-button"
|
||||
hidden={currentIndex === mediaAttachments.length - 1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
carouselRef.current.focus();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * (currentIndex + 1),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-right" />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{moreThanOne && (
|
||||
<div
|
||||
class="media-carousel-dots"
|
||||
style={{
|
||||
'--dots-count': mediaAttachments.length,
|
||||
}}
|
||||
>
|
||||
{mediaAttachments.map((media, i) => (
|
||||
<span
|
||||
key={media.id}
|
||||
class={`carousel-dot ${i === currentIndex ? 'active' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ card, selfReferential, instance }) {
|
||||
const snapStates = useSnapshot(states);
|
||||
const {
|
||||
|
@ -2231,9 +2445,9 @@ function Card({ card, selfReferential, instance }) {
|
|||
);
|
||||
|
||||
if (hasText && (image || (type === 'photo' && blurhash))) {
|
||||
const domain = new URL(url).hostname
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/$/, '');
|
||||
const domain = punycode.toUnicode(
|
||||
new URL(url).hostname.replace(/^www\./, '').replace(/\/$/, ''),
|
||||
);
|
||||
let blurhashImage;
|
||||
const rgbAverageColor =
|
||||
image && blurhash ? getBlurHashAverageColor(blurhash) : null;
|
||||
|
@ -2349,7 +2563,9 @@ function Card({ card, selfReferential, instance }) {
|
|||
// );
|
||||
}
|
||||
if (hasText && !image) {
|
||||
const domain = new URL(url).hostname.replace(/^www\./, '');
|
||||
const domain = punycode.toUnicode(
|
||||
new URL(url).hostname.replace(/^www\./, ''),
|
||||
);
|
||||
return (
|
||||
<a
|
||||
href={cardStatusURL || url}
|
||||
|
@ -2872,21 +3088,6 @@ function StatusButton({
|
|||
);
|
||||
}
|
||||
|
||||
export function formatDuration(time) {
|
||||
if (!time) return;
|
||||
let hours = Math.floor(time / 3600);
|
||||
let minutes = Math.floor((time % 3600) / 60);
|
||||
let seconds = Math.round(time % 60);
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
function nicePostURL(url) {
|
||||
if (!url) return;
|
||||
const urlObj = new URL(url);
|
||||
|
@ -2896,7 +3097,7 @@ function nicePostURL(url) {
|
|||
const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || [];
|
||||
return (
|
||||
<>
|
||||
{host}
|
||||
{punycode.toUnicode(host)}
|
||||
{username ? (
|
||||
<>
|
||||
/{username}
|
||||
|
@ -3136,7 +3337,7 @@ const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
|
|||
|
||||
return uniqueQuotes.map((q) => {
|
||||
return (
|
||||
<LazyShazam>
|
||||
<LazyShazam id={q.instance + q.id}>
|
||||
<Link
|
||||
key={q.instance + q.id}
|
||||
to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import { SubMenu } from '@szhsin/react-menu';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
export default function SubMenu2(props) {
|
||||
const menuRef = useRef();
|
||||
return (
|
||||
<SubMenu
|
||||
{...props}
|
||||
instanceRef={menuRef}
|
||||
// Test fix for bug; submenus not opening on Android
|
||||
itemProps={{
|
||||
onPointerMove: (e) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
menuRef.current?.openMenu?.();
|
||||
}
|
||||
},
|
||||
onPointerLeave: (e) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
menuRef.current?.openMenu?.();
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,5 +1,11 @@
|
|||
import { memo } from 'preact/compat';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
@ -9,6 +15,7 @@ import FilterContext from '../utils/filter-context';
|
|||
import { filteredItems, isFiltered } from '../utils/filters';
|
||||
import states, { statusKey } from '../utils/states';
|
||||
import statusPeek from '../utils/status-peek';
|
||||
import { isMediaFirstInstance } from '../utils/store-utils';
|
||||
import { groupBoosts, groupContext } from '../utils/timeline-utils';
|
||||
import useInterval from '../utils/useInterval';
|
||||
import usePageVisibility from '../utils/usePageVisibility';
|
||||
|
@ -51,7 +58,7 @@ function Timeline({
|
|||
}) {
|
||||
const snapStates = useSnapshot(states);
|
||||
const [items, setItems] = useState([]);
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [uiState, setUIState] = useState('start');
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
@ -59,6 +66,8 @@ function Timeline({
|
|||
|
||||
console.debug('RENDER Timeline', id, refresh);
|
||||
|
||||
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
|
||||
|
||||
const allowGrouping = view !== 'media';
|
||||
const loadItems = useDebouncedCallback(
|
||||
(firstLoad) => {
|
||||
|
@ -200,8 +209,8 @@ function Timeline({
|
|||
|
||||
const oRef = useHotkeys(['enter', 'o'], () => {
|
||||
// open active status
|
||||
const activeItem = document.activeElement.closest(itemsSelector);
|
||||
if (activeItem) {
|
||||
const activeItem = document.activeElement;
|
||||
if (activeItem?.matches(itemsSelector)) {
|
||||
activeItem.click();
|
||||
}
|
||||
});
|
||||
|
@ -355,7 +364,9 @@ function Timeline({
|
|||
<FilterContext.Provider value={filterContext}>
|
||||
<div
|
||||
id={`${id}-page`}
|
||||
class="deck-container"
|
||||
class={`deck-container ${
|
||||
mediaFirst ? 'deck-container-media-first' : ''
|
||||
}`}
|
||||
ref={(node) => {
|
||||
scrollableRef.current = node;
|
||||
jRef.current = node;
|
||||
|
@ -432,6 +443,7 @@ function Timeline({
|
|||
view={view}
|
||||
showFollowedTags={showFollowedTags}
|
||||
showReplyParent={showReplyParent}
|
||||
mediaFirst={mediaFirst}
|
||||
/>
|
||||
))}
|
||||
{showMore &&
|
||||
|
@ -443,14 +455,14 @@ function Timeline({
|
|||
height: '20vh',
|
||||
}}
|
||||
>
|
||||
<Status skeleton />
|
||||
<Status skeleton mediaFirst={mediaFirst} />
|
||||
</li>
|
||||
<li
|
||||
style={{
|
||||
height: '25vh',
|
||||
}}
|
||||
>
|
||||
<Status skeleton />
|
||||
<Status skeleton mediaFirst={mediaFirst} />
|
||||
</li>
|
||||
</>
|
||||
))}
|
||||
|
@ -490,13 +502,14 @@ function Timeline({
|
|||
/>
|
||||
) : (
|
||||
<li key={i}>
|
||||
<Status skeleton />
|
||||
<Status skeleton mediaFirst={mediaFirst} />
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
uiState !== 'error' && <p class="ui-state">{emptyText}</p>
|
||||
uiState !== 'error' &&
|
||||
uiState !== 'start' && <p class="ui-state">{emptyText}</p>
|
||||
)}
|
||||
{uiState === 'error' && (
|
||||
<p class="ui-state">
|
||||
|
@ -524,6 +537,7 @@ const TimelineItem = memo(
|
|||
view,
|
||||
showFollowedTags,
|
||||
showReplyParent,
|
||||
mediaFirst,
|
||||
}) => {
|
||||
console.debug('RENDER TimelineItem', status.id);
|
||||
const { id: statusID, reblog, items, type, _pinned } = status;
|
||||
|
@ -532,6 +546,7 @@ const TimelineItem = memo(
|
|||
const url = instance
|
||||
? `/${instance}/s/${actualStatusID}`
|
||||
: `/s/${actualStatusID}`;
|
||||
|
||||
if (items) {
|
||||
const fItems = filteredItems(items, filterContext);
|
||||
let title = '';
|
||||
|
@ -584,6 +599,7 @@ const TimelineItem = memo(
|
|||
contentTextWeight
|
||||
enableCommentHint
|
||||
// allowFilters={allowFilters}
|
||||
mediaFirst={mediaFirst}
|
||||
/>
|
||||
) : (
|
||||
<Status
|
||||
|
@ -593,6 +609,7 @@ const TimelineItem = memo(
|
|||
contentTextWeight
|
||||
enableCommentHint
|
||||
// allowFilters={allowFilters}
|
||||
mediaFirst={mediaFirst}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
|
@ -629,7 +646,11 @@ const TimelineItem = memo(
|
|||
>
|
||||
<Link class="status-link timeline-item" to={url}>
|
||||
{showCompact ? (
|
||||
<TimelineStatusCompact status={item} instance={instance} />
|
||||
<TimelineStatusCompact
|
||||
status={item}
|
||||
instance={instance}
|
||||
filterContext={filterContext}
|
||||
/>
|
||||
) : useItemID ? (
|
||||
<Status
|
||||
statusID={statusID}
|
||||
|
@ -688,6 +709,7 @@ const TimelineItem = memo(
|
|||
showFollowedTags={showFollowedTags}
|
||||
showReplyParent={showReplyParent}
|
||||
// allowFilters={allowFilters}
|
||||
mediaFirst={mediaFirst}
|
||||
/>
|
||||
) : (
|
||||
<Status
|
||||
|
@ -697,6 +719,7 @@ const TimelineItem = memo(
|
|||
showFollowedTags={showFollowedTags}
|
||||
showReplyParent={showReplyParent}
|
||||
// allowFilters={allowFilters}
|
||||
mediaFirst={mediaFirst}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
|
@ -801,11 +824,12 @@ function StatusCarousel({ title, class: className, children }) {
|
|||
);
|
||||
}
|
||||
|
||||
function TimelineStatusCompact({ status, instance }) {
|
||||
function TimelineStatusCompact({ status, instance, filterContext }) {
|
||||
const snapStates = useSnapshot(states);
|
||||
const { id, visibility, language } = status;
|
||||
const statusPeekText = statusPeek(status);
|
||||
const sKey = statusKey(id, instance);
|
||||
const filterInfo = isFiltered(status.filtered, filterContext);
|
||||
return (
|
||||
<article
|
||||
class={`status compact-thread ${
|
||||
|
@ -831,13 +855,24 @@ function TimelineStatusCompact({ status, instance }) {
|
|||
lang={language}
|
||||
dir="auto"
|
||||
>
|
||||
{statusPeekText}
|
||||
{status.sensitive && status.spoilerText && (
|
||||
{!!filterInfo ? (
|
||||
<b
|
||||
class="status-filtered-badge badge-meta horizontal"
|
||||
title={filterInfo?.titlesStr || ''}
|
||||
>
|
||||
<span>Filtered</span>: <span>{filterInfo?.titlesStr || ''}</span>
|
||||
</b>
|
||||
) : (
|
||||
<>
|
||||
{' '}
|
||||
<span class="spoiler-badge">
|
||||
<Icon icon="eye-close" size="s" />
|
||||
</span>
|
||||
{statusPeekText}
|
||||
{status.sensitive && status.spoilerText && (
|
||||
<>
|
||||
{' '}
|
||||
<span class="spoiler-badge">
|
||||
<Icon icon="eye-close" size="s" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -10,6 +10,7 @@ import localeCode2Text from '../utils/localeCode2Text';
|
|||
import pmem from '../utils/pmem';
|
||||
|
||||
import Icon from './icon';
|
||||
import LazyShazam from './lazy-shazam';
|
||||
import Loader from './loader';
|
||||
|
||||
const { PHANPY_LINGVA_INSTANCES } = import.meta.env;
|
||||
|
@ -142,23 +143,21 @@ function TranslationBlock({
|
|||
detectedLang !== targetLangText
|
||||
) {
|
||||
return (
|
||||
<div class="shazam-container">
|
||||
<div class="shazam-container-inner">
|
||||
<div class="status-translation-block-mini">
|
||||
<Icon
|
||||
icon="translate"
|
||||
alt={`Auto-translated from ${sourceLangText}`}
|
||||
/>
|
||||
<output
|
||||
lang={targetLang}
|
||||
dir="auto"
|
||||
title={pronunciationContent || ''}
|
||||
>
|
||||
{translatedContent}
|
||||
</output>
|
||||
</div>
|
||||
<LazyShazam>
|
||||
<div class="status-translation-block-mini">
|
||||
<Icon
|
||||
icon="translate"
|
||||
alt={`Auto-translated from ${sourceLangText}`}
|
||||
/>
|
||||
<output
|
||||
lang={targetLang}
|
||||
dir="auto"
|
||||
title={pronunciationContent || ''}
|
||||
>
|
||||
{translatedContent}
|
||||
</output>
|
||||
</div>
|
||||
</div>
|
||||
</LazyShazam>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -7,6 +7,7 @@ import { lazy } from 'preact/compat';
|
|||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import IntlSegmenterSuspense from './components/intl-segmenter-suspense';
|
||||
import { initStates } from './utils/states';
|
||||
// import Compose from './components/compose';
|
||||
import useTitle from './utils/useTitle';
|
||||
|
||||
|
@ -31,6 +32,10 @@ function App() {
|
|||
: 'Compose',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
initStates();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (uiState === 'closed') {
|
||||
try {
|
||||
|
|
|
@ -109,13 +109,7 @@
|
|||
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
|
||||
--spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
--pointer-min-dimension: 88px;
|
||||
}
|
||||
|
||||
@media (pointer: fine) {
|
||||
:root {
|
||||
--pointer-min-dimension: 44px;
|
||||
}
|
||||
--min-dimension: 88px;
|
||||
}
|
||||
|
||||
@media (min-resolution: 2dppx) {
|
||||
|
@ -353,6 +347,7 @@ button[hidden] {
|
|||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='search'],
|
||||
textarea,
|
||||
select {
|
||||
color: var(--text-color);
|
||||
|
@ -362,6 +357,7 @@ select {
|
|||
border-radius: 4px;
|
||||
}
|
||||
input[type='text']:focus,
|
||||
input[type='search']:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: var(--outline-color);
|
||||
|
@ -377,7 +373,7 @@ textarea:disabled {
|
|||
background-color: var(--bg-faded-color);
|
||||
}
|
||||
|
||||
:is(input[type='text'], textarea, select).block {
|
||||
:is(input[type='text'], input[type='search'], textarea, select).block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import punycode from 'punycode';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
|
@ -20,6 +21,7 @@ import pmem from '../utils/pmem';
|
|||
import showToast from '../utils/show-toast';
|
||||
import states from '../utils/states';
|
||||
import { saveStatus } from '../utils/states';
|
||||
import { isMediaFirstInstance } from '../utils/store-utils';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
const LIMIT = 20;
|
||||
|
@ -67,6 +69,8 @@ function AccountStatuses() {
|
|||
searchOffsetRef.current = 0;
|
||||
}, allSearchParams);
|
||||
|
||||
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
|
||||
|
||||
const sameCurrentInstance = useMemo(
|
||||
() => instance === currentInstance,
|
||||
[instance, currentInstance],
|
||||
|
@ -150,7 +154,7 @@ function AccountStatuses() {
|
|||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
let results = [];
|
||||
if (firstLoad) {
|
||||
const { value } = await masto.v1.accounts
|
||||
.$select(id)
|
||||
|
@ -185,12 +189,32 @@ function AccountStatuses() {
|
|||
limit: LIMIT,
|
||||
exclude_replies: excludeReplies,
|
||||
exclude_reblogs: excludeBoosts,
|
||||
only_media: media,
|
||||
only_media: media || undefined,
|
||||
tagged,
|
||||
});
|
||||
}
|
||||
const { value, done } = await accountStatusesIterator.current.next();
|
||||
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);
|
||||
|
||||
value.forEach((item) => {
|
||||
|
@ -249,17 +273,21 @@ function AccountStatuses() {
|
|||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
try {
|
||||
const featuredTags = await masto.v1.accounts
|
||||
.$select(id)
|
||||
.featuredTags.list();
|
||||
console.log({ featuredTags });
|
||||
setFeaturedTags(featuredTags);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// No need, because the whole filter bar is hidden
|
||||
// TODO: Revisit this
|
||||
if (!mediaFirst) {
|
||||
try {
|
||||
const featuredTags = await masto.v1.accounts
|
||||
.$select(id)
|
||||
.featuredTags.list();
|
||||
console.log({ featuredTags });
|
||||
setFeaturedTags(featuredTags);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
}, [id, mediaFirst]);
|
||||
|
||||
const { displayName, acct, emojis } = account || {};
|
||||
|
||||
|
@ -278,95 +306,126 @@ function AccountStatuses() {
|
|||
authenticated={authenticated}
|
||||
standalone
|
||||
/>
|
||||
<div
|
||||
class="filter-bar"
|
||||
ref={filterBarRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{filtered ? (
|
||||
{!mediaFirst && (
|
||||
<div
|
||||
class="filter-bar"
|
||||
ref={filterBarRef}
|
||||
style={{
|
||||
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}`}
|
||||
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'}
|
||||
>
|
||||
+ Replies
|
||||
</Link>
|
||||
<Link
|
||||
to={`/${instance}/a/${id}${excludeBoosts ? '' : '?boosts=0'}`}
|
||||
onClick={() => {
|
||||
if (!excludeBoosts) {
|
||||
showToast('Showing posts without boosts');
|
||||
}
|
||||
}}
|
||||
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)}`
|
||||
}`}
|
||||
to={`/${instance}/a/${id}${excludeReplies ? '?replies=1' : ''}`}
|
||||
onClick={() => {
|
||||
if (tagged !== tag.name) {
|
||||
showToast(`Showing posts tagged with #${tag.name}`);
|
||||
if (excludeReplies) {
|
||||
showToast('Showing post with replies');
|
||||
}
|
||||
}}
|
||||
class={tagged === tag.name ? 'is-active' : ''}
|
||||
class={excludeReplies ? '' : 'is-active'}
|
||||
>
|
||||
<span>
|
||||
<span class="more-insignificant">#</span>
|
||||
{tag.name}
|
||||
</span>
|
||||
{
|
||||
// The count differs based on instance 😅
|
||||
}
|
||||
{/* <span class="filter-count">{tag.statusesCount}</span> */}
|
||||
+ Replies
|
||||
</Link>
|
||||
))}
|
||||
{searchEnabled &&
|
||||
(supportsInputMonth ? (
|
||||
<label class={`filter-field ${month ? 'is-active' : ''}`}>
|
||||
<Icon icon="month" size="l" />
|
||||
<input
|
||||
type="month"
|
||||
<Link
|
||||
to={`/${instance}/a/${id}${excludeBoosts ? '' : '?boosts=0'}`}
|
||||
onClick={() => {
|
||||
if (!excludeBoosts) {
|
||||
showToast('Showing posts without boosts');
|
||||
}
|
||||
}}
|
||||
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}
|
||||
value={month || ''}
|
||||
min={MIN_YEAR_MONTH}
|
||||
max={new Date().toISOString().slice(0, 7)}
|
||||
onInput={(e) => {
|
||||
const { value, validity } = e.currentTarget;
|
||||
const { value, validity } = e;
|
||||
if (!validity.valid) return;
|
||||
setSearchParams(
|
||||
value
|
||||
|
@ -375,40 +434,11 @@ 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>
|
||||
) : (
|
||||
// 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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
|
@ -471,7 +501,7 @@ function AccountStatuses() {
|
|||
errorText="Unable to load posts"
|
||||
fetchItems={fetchAccountStatuses}
|
||||
useItemID
|
||||
view={media ? 'media' : undefined}
|
||||
view={media || mediaFirst ? 'media' : undefined}
|
||||
boostsCarousel={snapStates.settings.boostsCarousel}
|
||||
timelineStart={TimelineStart}
|
||||
refresh={[
|
||||
|
@ -516,7 +546,13 @@ function AccountStatuses() {
|
|||
>
|
||||
<Icon icon="transfer" />{' '}
|
||||
<small class="menu-double-lines">
|
||||
Switch to account's instance (<b>{accountInstance}</b>)
|
||||
Switch to account's instance{' '}
|
||||
{accountInstance ? (
|
||||
<>
|
||||
{' '}
|
||||
(<b>{punycode.toUnicode(accountInstance)}</b>)
|
||||
</>
|
||||
) : null}
|
||||
</small>
|
||||
</MenuItem>
|
||||
{!sameCurrentInstance && (
|
||||
|
|
|
@ -13,12 +13,13 @@ import NameText from '../components/name-text';
|
|||
import { api } from '../utils/api';
|
||||
import states from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import { getCurrentAccountID, setCurrentAccountID } from '../utils/store-utils';
|
||||
|
||||
function Accounts({ onClose }) {
|
||||
const { masto } = api();
|
||||
// Accounts
|
||||
const accounts = store.local.getJSON('accounts');
|
||||
const currentAccount = store.session.get('currentAccount');
|
||||
const currentAccount = getCurrentAccountID();
|
||||
const moreThanOneAccount = accounts.length > 1;
|
||||
|
||||
const [_, reload] = useReducer((x) => x + 1, 0);
|
||||
|
@ -81,7 +82,7 @@ function Accounts({ onClose }) {
|
|||
if (isCurrent) {
|
||||
states.showAccount = `${account.info.username}@${account.instanceURL}`;
|
||||
} else {
|
||||
store.session.set('currentAccount', account.info.id);
|
||||
setCurrentAccountID(account.info.id);
|
||||
location.reload();
|
||||
}
|
||||
}}
|
||||
|
|
|
@ -614,7 +614,7 @@
|
|||
}
|
||||
&.visibility-direct {
|
||||
--yellow-stripes: repeating-linear-gradient(
|
||||
-45deg,
|
||||
135deg,
|
||||
var(--reply-to-faded-color),
|
||||
var(--reply-to-faded-color) 10px,
|
||||
var(--reply-to-faded-color) 10px,
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import punycode from 'punycode';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { uid } from 'uid/single';
|
||||
|
@ -39,7 +40,7 @@ import showToast from '../utils/show-toast';
|
|||
import states, { statusKey } from '../utils/states';
|
||||
import statusPeek from '../utils/status-peek';
|
||||
import store from '../utils/store';
|
||||
import { getCurrentAccountNS } from '../utils/store-utils';
|
||||
import { getCurrentAccountID, getCurrentAccountNS } from '../utils/store-utils';
|
||||
import { assignFollowedTags } from '../utils/timeline-utils';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
|
@ -111,7 +112,7 @@ function Catchup() {
|
|||
const [showTopLinks, setShowTopLinks] = useState(false);
|
||||
|
||||
const currentAccount = useMemo(() => {
|
||||
return store.session.get('currentAccount');
|
||||
return getCurrentAccountID();
|
||||
}, []);
|
||||
const isSelf = (accountID) => accountID === currentAccount;
|
||||
|
||||
|
@ -191,6 +192,7 @@ function Catchup() {
|
|||
|
||||
const [posts, setPosts] = useState([]);
|
||||
const catchupRangeRef = useRef();
|
||||
const catchupLastRef = useRef();
|
||||
const NS = useMemo(() => getCurrentAccountNS(), []);
|
||||
const handleCatchupClick = useCallback(async ({ duration } = {}) => {
|
||||
const now = Date.now();
|
||||
|
@ -925,7 +927,15 @@ function Catchup() {
|
|||
type="button"
|
||||
onClick={() => {
|
||||
if (range < RANGES[RANGES.length - 1].value) {
|
||||
const duration = range * 60 * 60 * 1000;
|
||||
let duration;
|
||||
if (
|
||||
range === RANGES[RANGES.length - 1].value &&
|
||||
catchupLastRef.current?.checked
|
||||
) {
|
||||
duration = Date.now() - lastCatchupEndAt;
|
||||
} else {
|
||||
duration = range * 60 * 60 * 1000;
|
||||
}
|
||||
handleCatchupClick({ duration });
|
||||
} else {
|
||||
handleCatchupClick();
|
||||
|
@ -935,11 +945,25 @@ function Catchup() {
|
|||
Catch up
|
||||
</button>
|
||||
</div>
|
||||
{lastCatchupRange && range > lastCatchupRange && (
|
||||
{lastCatchupRange && range > lastCatchupRange ? (
|
||||
<p class="catchup-info">
|
||||
<Icon icon="info" /> Overlaps with your last catch-up
|
||||
</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">
|
||||
<small>
|
||||
Note: your instance might only show a maximum of 800 posts in
|
||||
|
@ -1076,9 +1100,11 @@ function Catchup() {
|
|||
height,
|
||||
publishedAt,
|
||||
} = card;
|
||||
const domain = new URL(url).hostname
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/$/, '');
|
||||
const domain = punycode.toUnicode(
|
||||
new URL(url).hostname
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/$/, ''),
|
||||
);
|
||||
let accentColor;
|
||||
if (blurhash) {
|
||||
const averageColor = getBlurHashAverageColor(blurhash);
|
||||
|
|
|
@ -286,7 +286,13 @@ function FiltersAddEdit({ filter, onClose }) {
|
|||
// Preserve existing expiry if not specified
|
||||
// Seconds from now to expiresAtDate
|
||||
// Other clients don't do this
|
||||
expiresIn = Math.floor((expiresAtDate - new Date()) / 1000);
|
||||
if (hasExpiry) {
|
||||
expiresIn = Math.floor(
|
||||
(expiresAtDate - new Date()) / 1000,
|
||||
);
|
||||
} else {
|
||||
expiresIn = null;
|
||||
}
|
||||
} else if (expiresIn === '0' || expiresIn === 0) {
|
||||
// 0 = Never
|
||||
expiresIn = null;
|
||||
|
|
|
@ -71,7 +71,8 @@ function Following({ title, path, id, ...props }) {
|
|||
.next();
|
||||
let { value } = results;
|
||||
console.log('checkForUpdates', latestItem.current, value);
|
||||
if (value?.length) {
|
||||
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
|
||||
if (value?.length && !valueContainsLatestItem) {
|
||||
latestItem.current = value[0].id;
|
||||
value = dedupeBoosts(value, instance);
|
||||
value = filteredItems(value, 'home');
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
MenuHeader,
|
||||
MenuItem,
|
||||
} from '@szhsin/react-menu';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import Icon from '../components/icon';
|
||||
|
@ -18,6 +18,7 @@ import { filteredItems } from '../utils/filters';
|
|||
import showToast from '../utils/show-toast';
|
||||
import states from '../utils/states';
|
||||
import { saveStatus } from '../utils/states';
|
||||
import { isMediaFirstInstance } from '../utils/store-utils';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
const LIMIT = 20;
|
||||
|
@ -55,6 +56,8 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
|||
useTitle(title, `/:instance?/t/:hashtag`);
|
||||
const latestItem = useRef();
|
||||
|
||||
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
|
||||
|
||||
// const hashtagsIterator = useRef();
|
||||
const maxID = useRef(undefined);
|
||||
async function fetchHashtags(firstLoad) {
|
||||
|
@ -73,7 +76,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
|||
limit: LIMIT,
|
||||
any: hashtags.slice(1),
|
||||
maxId: firstLoad ? undefined : maxID.current,
|
||||
onlyMedia: media,
|
||||
onlyMedia: media ? true : undefined,
|
||||
})
|
||||
.next();
|
||||
let { value } = results;
|
||||
|
@ -85,7 +88,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
|||
// value = filteredItems(value, 'public');
|
||||
value.forEach((item) => {
|
||||
saveStatus(item, instance, {
|
||||
skipThreading: media, // If media view, no need to form threads
|
||||
skipThreading: media || mediaFirst, // If media view, no need to form threads
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -109,8 +112,9 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
|||
})
|
||||
.next();
|
||||
let { value } = results;
|
||||
value = filteredItems(value, 'public');
|
||||
if (value?.length) {
|
||||
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
|
||||
if (value?.length && !valueContainsLatestItem) {
|
||||
value = filteredItems(value, 'public');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
@ -155,7 +159,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
|||
fetchItems={fetchHashtags}
|
||||
checkForUpdates={checkForUpdates}
|
||||
useItemID
|
||||
view={media ? 'media' : undefined}
|
||||
view={media || mediaFirst ? 'media' : undefined}
|
||||
refresh={media}
|
||||
// allowFilters
|
||||
filterContext="public"
|
||||
|
@ -232,23 +236,27 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
|||
<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 />
|
||||
{!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 />
|
||||
</>
|
||||
)}
|
||||
<FocusableItem className="menu-field" disabled={reachLimit}>
|
||||
{({ ref }) => (
|
||||
<form
|
||||
|
|
|
@ -63,8 +63,9 @@ function List(props) {
|
|||
since_id: latestItem.current,
|
||||
});
|
||||
let { value } = results;
|
||||
value = filteredItems(value, 'home');
|
||||
if (value?.length) {
|
||||
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
|
||||
if (value?.length && !valueContainsLatestItem) {
|
||||
value = filteredItems(value, 'home');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import './login.css';
|
||||
|
||||
import Fuse from 'fuse.js';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
|
@ -27,12 +28,14 @@ function Login() {
|
|||
);
|
||||
|
||||
const [instancesList, setInstancesList] = useState([]);
|
||||
const searcher = useRef();
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(instancesListURL);
|
||||
const data = await res.json();
|
||||
setInstancesList(data);
|
||||
searcher.current = new Fuse(data);
|
||||
} catch (e) {
|
||||
// Silently fail
|
||||
console.error(e);
|
||||
|
@ -90,21 +93,11 @@ function Login() {
|
|||
!/[\s\/\\@]/.test(cleanInstanceText);
|
||||
|
||||
const instancesSuggestions = cleanInstanceText
|
||||
? instancesList
|
||||
.filter((instance) => instance.includes(instanceText))
|
||||
.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;
|
||||
? searcher.current
|
||||
?.search(cleanInstanceText, {
|
||||
limit: 10,
|
||||
})
|
||||
.slice(0, 10)
|
||||
?.map((match) => match.item)
|
||||
: [];
|
||||
|
||||
const selectedInstanceText = instanceTextLooksLikeDomain
|
||||
|
|
|
@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router-dom';
|
|||
import Link from '../components/link';
|
||||
import Timeline from '../components/timeline';
|
||||
import { api } from '../utils/api';
|
||||
import { fixNotifications } from '../utils/group-notifications';
|
||||
import { saveStatus } from '../utils/states';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
|
@ -30,6 +31,8 @@ function Mentions({ columnMode, ...props }) {
|
|||
const results = await mentionsIterator.current.next();
|
||||
let { value } = results;
|
||||
if (value?.length) {
|
||||
value = fixNotifications(value);
|
||||
|
||||
if (firstLoad) {
|
||||
latestItem.current = value[0].id;
|
||||
console.log('First load', latestItem.current);
|
||||
|
@ -95,7 +98,9 @@ function Mentions({ columnMode, ...props }) {
|
|||
latestConversationItem.current,
|
||||
value,
|
||||
);
|
||||
if (value?.length) {
|
||||
const valueContainsLatestItem =
|
||||
value[0]?.id === latestConversationItem.current; // since_id might not be supported
|
||||
if (value?.length && !valueContainsLatestItem) {
|
||||
latestConversationItem.current = value[0].lastStatus.id;
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -72,6 +72,13 @@ function Notifications({ columnMode }) {
|
|||
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 notifications = allNotifications.value;
|
||||
|
||||
|
@ -82,6 +89,32 @@ function Notifications({ columnMode }) {
|
|||
});
|
||||
});
|
||||
|
||||
// TEST: Slot in a fake notification to test 'severed_relationships'
|
||||
// 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 = groupNotifications(notifications);
|
||||
|
||||
if (firstLoad) {
|
||||
|
@ -247,7 +280,6 @@ function Notifications({ columnMode }) {
|
|||
|
||||
const lastHiddenTime = useRef();
|
||||
usePageVisibility((visible) => {
|
||||
let unsub;
|
||||
if (visible) {
|
||||
const timeDiff = Date.now() - lastHiddenTime.current;
|
||||
if (!lastHiddenTime.current || timeDiff > 1000 * 3) {
|
||||
|
@ -258,20 +290,16 @@ function Notifications({ columnMode }) {
|
|||
} else {
|
||||
lastHiddenTime.current = Date.now();
|
||||
}
|
||||
unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
|
||||
if (uiState === 'loading') {
|
||||
return;
|
||||
}
|
||||
if (v) {
|
||||
loadUpdates();
|
||||
}
|
||||
setShowNew(v);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
unsub?.();
|
||||
};
|
||||
});
|
||||
useEffect(() => {
|
||||
let unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
|
||||
if (uiState === 'loading') return;
|
||||
if (v) loadUpdates();
|
||||
setShowNew(v);
|
||||
});
|
||||
return () => unsub?.();
|
||||
}, []);
|
||||
|
||||
const todayDate = new Date();
|
||||
const yesterdayDate = new Date(todayDate - 24 * 60 * 60 * 1000);
|
||||
|
@ -418,7 +446,7 @@ function Notifications({ columnMode }) {
|
|||
{supportsFilteredNotifications && (
|
||||
<button
|
||||
type="button"
|
||||
class="button plain"
|
||||
class="button plain4"
|
||||
onClick={() => {
|
||||
setShowNotificationsSettings(true);
|
||||
}}
|
||||
|
@ -613,7 +641,7 @@ function Notifications({ columnMode }) {
|
|||
</label>
|
||||
</div>
|
||||
<h2 class="timeline-header">Today</h2>
|
||||
{showTodayEmpty && !!snapStates.notifications.length && (
|
||||
{showTodayEmpty && (
|
||||
<p class="ui-state insignificant">
|
||||
{uiState === 'default' ? "You're all caught up." : <>…</>}
|
||||
</p>
|
||||
|
|
|
@ -33,6 +33,7 @@ function Public({ local, columnMode, ...props }) {
|
|||
publicIterator.current = masto.v1.timelines.public.list({
|
||||
limit: LIMIT,
|
||||
local: isLocal,
|
||||
remote: !isLocal, // Pixelfed
|
||||
});
|
||||
}
|
||||
const results = await publicIterator.current.next();
|
||||
|
@ -63,8 +64,9 @@ function Public({ local, columnMode, ...props }) {
|
|||
})
|
||||
.next();
|
||||
let { value } = results;
|
||||
value = filteredItems(value, 'public');
|
||||
if (value?.length) {
|
||||
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
|
||||
if (value?.length && !valueContainsLatestItem) {
|
||||
value = filteredItems(value, 'public');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
|
@ -177,6 +177,7 @@ function Search({ columnMode, ...props }) {
|
|||
['/', 'Slash'],
|
||||
(e) => {
|
||||
searchFormRef.current?.focus?.();
|
||||
searchFormRef.current?.select?.();
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
|
|
|
@ -28,6 +28,7 @@ const {
|
|||
PHANPY_WEBSITE: WEBSITE,
|
||||
PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL,
|
||||
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
|
||||
PHANPY_GIPHY_API_KEY: GIPHY_API_KEY,
|
||||
} = import.meta.env;
|
||||
|
||||
function Settings({ onClose }) {
|
||||
|
@ -433,6 +434,37 @@ function Settings({ onClose }) {
|
|||
</div>
|
||||
</div>
|
||||
</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 && (
|
||||
<li>
|
||||
<label>
|
||||
|
|
|
@ -12,10 +12,10 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import punycode from 'punycode';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import { matchPath, useSearchParams } from 'react-router-dom';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Avatar from '../components/avatar';
|
||||
|
@ -122,7 +122,7 @@ function StatusPage(params) {
|
|||
}, [showMedia]);
|
||||
|
||||
const mediaAttachments = mediaStatusID
|
||||
? mediaStatus?.mediaAttachments
|
||||
? snapStates.statuses[statusKey(mediaStatusID, instance)]?.mediaAttachments
|
||||
: heroStatus?.mediaAttachments;
|
||||
|
||||
const handleMediaClose = useCallback(() => {
|
||||
|
@ -1208,7 +1208,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
|||
{postInstance ? (
|
||||
<>
|
||||
{' '}
|
||||
(<b>{postInstance}</b>)
|
||||
(<b>{punycode.toUnicode(postInstance)}</b>)
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
|
|
|
@ -3,6 +3,7 @@ import '../components/links-bar.css';
|
|||
import { MenuItem } from '@szhsin/react-menu';
|
||||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||
import { useMemo, useRef, useState } from 'preact/hooks';
|
||||
import punycode from 'punycode';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
|
@ -18,6 +19,7 @@ import pmem from '../utils/pmem';
|
|||
import shortenNumber from '../utils/shorten-number';
|
||||
import states from '../utils/states';
|
||||
import { saveStatus } from '../utils/states';
|
||||
import supports from '../utils/supports';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
const LIMIT = 20;
|
||||
|
@ -32,6 +34,17 @@ const fetchLinks = pmem(
|
|||
},
|
||||
);
|
||||
|
||||
function fetchTrends(masto) {
|
||||
if (supports('@pixelfed/trending')) {
|
||||
return masto.pixelfed.v2.discover.posts.trending.list({
|
||||
range: 'daily',
|
||||
});
|
||||
}
|
||||
return masto.v1.trends.statuses.list({
|
||||
limit: LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
function Trending({ columnMode, ...props }) {
|
||||
const snapStates = useSnapshot(states);
|
||||
const params = columnMode ? {} : useParams();
|
||||
|
@ -47,36 +60,39 @@ function Trending({ columnMode, ...props }) {
|
|||
const [hashtags, setHashtags] = useState([]);
|
||||
const [links, setLinks] = useState([]);
|
||||
const trendIterator = useRef();
|
||||
|
||||
async function fetchTrend(firstLoad) {
|
||||
if (firstLoad || !trendIterator.current) {
|
||||
trendIterator.current = masto.v1.trends.statuses.list({
|
||||
limit: LIMIT,
|
||||
});
|
||||
trendIterator.current = fetchTrends(masto);
|
||||
|
||||
// Get hashtags
|
||||
try {
|
||||
const iterator = masto.v1.trends.tags.list();
|
||||
const { value: tags } = await iterator.next();
|
||||
console.log('tags', tags);
|
||||
if (tags?.length) {
|
||||
setHashtags(tags);
|
||||
if (supports('@mastodon/trending-hashtags')) {
|
||||
try {
|
||||
const iterator = masto.v1.trends.tags.list();
|
||||
const { value: tags } = await iterator.next();
|
||||
console.log('tags', tags);
|
||||
if (tags?.length) {
|
||||
setHashtags(tags);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
// Get links
|
||||
try {
|
||||
const { value } = await fetchLinks(masto, instance);
|
||||
// 4 types available: link, photo, video, rich
|
||||
// Only want links for now
|
||||
const links = value?.filter?.((link) => link.type === 'link');
|
||||
console.log('links', links);
|
||||
if (links?.length) {
|
||||
setLinks(links);
|
||||
if (supports('@mastodon/trending-links')) {
|
||||
try {
|
||||
const { value } = await fetchLinks(masto, instance);
|
||||
// 4 types available: link, photo, video, rich
|
||||
// Only want links for now
|
||||
const links = value?.filter?.((link) => link.type === 'link');
|
||||
console.log('links', links);
|
||||
if (links?.length) {
|
||||
setLinks(links);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
const results = await trendIterator.current.next();
|
||||
|
@ -161,9 +177,9 @@ function Trending({ columnMode, ...props }) {
|
|||
url,
|
||||
width,
|
||||
} = link;
|
||||
const domain = new URL(url).hostname
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/$/, '');
|
||||
const domain = punycode.toUnicode(
|
||||
new URL(url).hostname.replace(/^www\./, '').replace(/\/$/, ''),
|
||||
);
|
||||
let accentColor;
|
||||
if (blurhash) {
|
||||
const averageColor = getBlurHashAverageColor(blurhash);
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
getAccountByInstance,
|
||||
getCurrentAccount,
|
||||
saveAccount,
|
||||
setCurrentAccountID,
|
||||
} from './store-utils';
|
||||
|
||||
// Default *fallback* instance
|
||||
|
@ -118,7 +119,7 @@ export async function initAccount(client, instance, accessToken, vapidKey) {
|
|||
const mastoAccount = await masto.v1.accounts.verifyCredentials();
|
||||
|
||||
console.log('CURRENTACCOUNT SET', mastoAccount.id);
|
||||
store.session.set('currentAccount', mastoAccount.id);
|
||||
setCurrentAccountID(mastoAccount.id);
|
||||
|
||||
saveAccount({
|
||||
info: mastoAccount,
|
||||
|
|
|
@ -242,6 +242,17 @@ function _enhanceContent(content, opts = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
// ADD ASPECT RATIO TO ALL IMAGES
|
||||
if (enhancedContent.includes('<img')) {
|
||||
dom.querySelectorAll('img').forEach((img) => {
|
||||
const width = img.getAttribute('width') || img.naturalWidth;
|
||||
const height = img.getAttribute('height') || img.naturalHeight;
|
||||
if (width && height) {
|
||||
img.style.setProperty('--original-aspect-ratio', `${width}/${height}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (postEnhanceDOM) {
|
||||
queueMicrotask(() => postEnhanceDOM(dom));
|
||||
// postEnhanceDOM(dom); // mutate dom
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import mem from './mem';
|
||||
import store from './store';
|
||||
import { getCurrentAccountID } from './store-utils';
|
||||
|
||||
function _isFiltered(filtered, filterContext) {
|
||||
if (!filtered?.length) return false;
|
||||
|
@ -43,7 +43,7 @@ export function filteredItem(item, filterContext, currentAccountID) {
|
|||
export function filteredItems(items, filterContext) {
|
||||
if (!items?.length) return [];
|
||||
if (!filterContext) return items;
|
||||
const currentAccountID = store.session.get('currentAccount');
|
||||
const currentAccountID = getCurrentAccountID();
|
||||
return items.filter((item) =>
|
||||
filteredItem(item, filterContext, currentAccountID),
|
||||
);
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
export default function formatDuration(time) {
|
||||
if (!time) return;
|
||||
let hours = Math.floor(time / 3600);
|
||||
let minutes = Math.floor((time % 3600) / 60);
|
||||
let seconds = Math.round(time % 60);
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ const statusPostRegexes = [
|
|||
/\/notes\/([^\/]+)/i, // Misskey, Firefish
|
||||
/^\/(?:notice|objects)\/([a-z0-9-]+)/i, // Pleroma
|
||||
/\/@[^@\/]+@?[^\/]+?\/([^\/]+)/i, // Mastodon
|
||||
/^\/p\/[^\/]+\/([^\/]+)/i, // Pixelfed
|
||||
];
|
||||
|
||||
export function getInstanceStatusObject(url) {
|
||||
|
|
|
@ -9,7 +9,7 @@ const notificationTypeKeys = {
|
|||
poll: ['status'],
|
||||
update: ['status'],
|
||||
};
|
||||
function fixNotifications(notifications) {
|
||||
export function fixNotifications(notifications) {
|
||||
return notifications.filter((notification) => {
|
||||
const { type, id, createdAt } = notification;
|
||||
if (!type) {
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
export default function localeCode2Text(code) {
|
||||
import mem from './mem';
|
||||
|
||||
const IntlDN = new Intl.DisplayNames(navigator.languages, {
|
||||
type: 'language',
|
||||
});
|
||||
|
||||
function _localeCode2Text(code) {
|
||||
try {
|
||||
return new Intl.DisplayNames(navigator.languages, {
|
||||
type: 'language',
|
||||
}).of(code);
|
||||
return IntlDN.of(code);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default mem(_localeCode2Text);
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { api } from './api';
|
||||
import store from './store';
|
||||
import { getCurrentAccountID } from './store-utils';
|
||||
|
||||
export async function fetchRelationships(accounts, relationshipsMap = {}) {
|
||||
if (!accounts?.length) return;
|
||||
const { masto } = api();
|
||||
|
||||
const currentAccount = store.session.get('currentAccount');
|
||||
const currentAccount = getCurrentAccountID();
|
||||
const uniqueAccountIds = accounts.reduce((acc, a) => {
|
||||
// 1. Ignore duplicate accounts
|
||||
// 2. Ignore accounts that are already inside relationshipsMap
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
const { locale } = Intl.NumberFormat().resolvedOptions();
|
||||
const shortenNumber = Intl.NumberFormat(locale, {
|
||||
notation: 'compact',
|
||||
roundingMode: 'floor',
|
||||
}).format;
|
||||
export default shortenNumber;
|
||||
|
|
|
@ -67,6 +67,7 @@ const states = proxy({
|
|||
contentTranslationAutoInline: false,
|
||||
shortcutSettingsCloudImportExport: false,
|
||||
mediaAltGenerator: false,
|
||||
composerGIFPicker: false,
|
||||
cloakMode: false,
|
||||
},
|
||||
});
|
||||
|
@ -99,6 +100,8 @@ export function initStates() {
|
|||
store.account.get('settings-shortcutSettingsCloudImportExport') ?? false;
|
||||
states.settings.mediaAltGenerator =
|
||||
store.account.get('settings-mediaAltGenerator') ?? false;
|
||||
states.settings.composerGIFPicker =
|
||||
store.account.get('settings-composerGIFPicker') ?? false;
|
||||
states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false;
|
||||
}
|
||||
|
||||
|
@ -140,6 +143,9 @@ subscribe(states, (changes) => {
|
|||
if (path.join('.') === 'settings.mediaAltGenerator') {
|
||||
store.account.set('settings-mediaAltGenerator', !!value);
|
||||
}
|
||||
if (path.join('.') === 'settings.composerGIFPicker') {
|
||||
store.account.set('settings-composerGIFPicker', !!value);
|
||||
}
|
||||
if (path?.[0] === 'shortcuts') {
|
||||
store.account.set('shortcuts', states.shortcuts);
|
||||
}
|
||||
|
|
|
@ -16,13 +16,40 @@ export function getAccountByInstance(instance) {
|
|||
return accounts.find((a) => a.instanceURL === instance);
|
||||
}
|
||||
|
||||
const standaloneMQ = window.matchMedia('(display-mode: standalone)');
|
||||
|
||||
export function getCurrentAccountID() {
|
||||
try {
|
||||
const id = store.session.get('currentAccount');
|
||||
if (id) return id;
|
||||
} catch (e) {}
|
||||
if (standaloneMQ.matches) {
|
||||
try {
|
||||
const id = store.local.get('currentAccount');
|
||||
if (id) return id;
|
||||
} catch (e) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setCurrentAccountID(id) {
|
||||
try {
|
||||
store.session.set('currentAccount', id);
|
||||
} catch (e) {}
|
||||
if (standaloneMQ.matches) {
|
||||
try {
|
||||
store.local.set('currentAccount', id);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentAccount() {
|
||||
if (!window.__IGNORE_GET_ACCOUNT_ERROR__) {
|
||||
// Track down getCurrentAccount() calls before account-based states are initialized
|
||||
console.error('getCurrentAccount() called before states are initialized');
|
||||
if (import.meta.env.DEV) console.trace();
|
||||
}
|
||||
const currentAccount = store.session.get('currentAccount');
|
||||
const currentAccount = getCurrentAccountID();
|
||||
const account = getAccount(currentAccount);
|
||||
return account;
|
||||
}
|
||||
|
@ -48,7 +75,7 @@ export function saveAccount(account) {
|
|||
accounts.push(account);
|
||||
}
|
||||
store.local.setJSON('accounts', accounts);
|
||||
store.session.set('currentAccount', account.info.id);
|
||||
setCurrentAccountID(account.info.id);
|
||||
}
|
||||
|
||||
export function updateAccount(accountInfo) {
|
||||
|
@ -80,10 +107,10 @@ export function getCurrentInstance() {
|
|||
return (currentInstance = instances[instance]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(`Failed to load instance configuration. Please try again.\n\n${e}`);
|
||||
// alert(`Failed to load instance configuration. Please try again.\n\n${e}`);
|
||||
// Temporary fix for corrupted data
|
||||
store.local.del('instances');
|
||||
location.reload();
|
||||
// store.local.del('instances');
|
||||
// location.reload();
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
@ -126,3 +153,8 @@ export function getCurrentInstanceConfiguration() {
|
|||
const instance = getCurrentInstance();
|
||||
return getInstanceConfiguration(instance);
|
||||
}
|
||||
|
||||
export function isMediaFirstInstance() {
|
||||
const instance = getCurrentInstance();
|
||||
return /pixelfed/i.test(instance?.version);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,21 @@ import features from '../data/features.json';
|
|||
|
||||
import { getCurrentInstance } from './store-utils';
|
||||
|
||||
// Non-semver(?) UA string detection
|
||||
const containPixelfed = /pixelfed/i;
|
||||
const notContainPixelfed = /^(?!.*pixelfed).*$/i;
|
||||
const platformFeatures = {
|
||||
'@mastodon/lists': notContainPixelfed,
|
||||
'@mastodon/filters': notContainPixelfed,
|
||||
'@mastodon/mentions': notContainPixelfed,
|
||||
'@mastodon/trending-hashtags': notContainPixelfed,
|
||||
'@mastodon/trending-links': notContainPixelfed,
|
||||
'@mastodon/post-bookmark': notContainPixelfed,
|
||||
'@mastodon/post-edit': notContainPixelfed,
|
||||
'@mastodon/profile-edit': notContainPixelfed,
|
||||
'@mastodon/profile-private-note': notContainPixelfed,
|
||||
'@pixelfed/trending': containPixelfed,
|
||||
};
|
||||
const supportsCache = {};
|
||||
|
||||
function supports(feature) {
|
||||
|
@ -11,6 +26,11 @@ function supports(feature) {
|
|||
const { version, domain } = getCurrentInstance();
|
||||
const key = `${domain}-${feature}`;
|
||||
if (supportsCache[key]) return supportsCache[key];
|
||||
|
||||
if (platformFeatures[feature]) {
|
||||
return (supportsCache[key] = platformFeatures[feature].test(version));
|
||||
}
|
||||
|
||||
const range = features[feature];
|
||||
if (!range) return false;
|
||||
return (supportsCache[key] = satisfies(version, range, {
|
||||
|
|
|
@ -83,15 +83,23 @@ function _unfurlMastodonLink(instance, url) {
|
|||
limit: 1,
|
||||
})
|
||||
.then((results) => {
|
||||
if (results.statuses.length > 0) {
|
||||
const status = results.statuses[0];
|
||||
return {
|
||||
status,
|
||||
instance,
|
||||
};
|
||||
} else {
|
||||
throw new Error('No results');
|
||||
const { statuses } = results;
|
||||
if (statuses.length > 0) {
|
||||
// Filter out statuses that has content that contains the URL, in-case-sensitive
|
||||
const theStatuses = statuses.filter(
|
||||
(status) =>
|
||||
!status.content?.toLowerCase().includes(theURL.toLowerCase()),
|
||||
);
|
||||
|
||||
if (theStatuses.length === 1) {
|
||||
return {
|
||||
status: theStatuses[0],
|
||||
instance,
|
||||
};
|
||||
}
|
||||
// If there are multiple statuses, give up, something is wrong
|
||||
}
|
||||
throw new Error('No results');
|
||||
});
|
||||
|
||||
function handleFulfill(result) {
|
||||
|
|
Ładowanie…
Reference in New Issue