Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
2e3bac2e1e
|
@ -138,7 +138,7 @@ Download or `git clone` this repository. Use `production` branch for *stable* re
|
||||||
Customization can be done by passing environment variables to the build command. Examples:
|
Customization can be done by passing environment variables to the build command. Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PHANPY_APP_TITLE="Phanpy Dev" \
|
PHANPY_CLIENT_NAME="Phanpy Dev" \
|
||||||
PHANPY_WEBSITE="https://dev.phanpy.social" \
|
PHANPY_WEBSITE="https://dev.phanpy.social" \
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
@ -179,6 +179,10 @@ Available variables:
|
||||||
- May specify a self-hosted Lingva instance, powered by either [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api)
|
- May specify a self-hosted Lingva instance, powered by either [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api)
|
||||||
- List of fallback instances hard-coded in `/.env`
|
- List of fallback instances hard-coded in `/.env`
|
||||||
- [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances)
|
- [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances)
|
||||||
|
- `PHANPY_GIPHY_API_KEY` (optional, no defaults):
|
||||||
|
- API key for [GIPHY](https://developers.giphy.com/). See [API docs](https://developers.giphy.com/docs/api/).
|
||||||
|
- If provided, a setting will appear for users to enable the GIF picker in the composer. Disabled by default.
|
||||||
|
- This is not self-hosted.
|
||||||
|
|
||||||
### Static site hosting
|
### Static site hosting
|
||||||
|
|
||||||
|
@ -251,6 +255,8 @@ And here I am. Building a Mastodon web client.
|
||||||
- [Statuzer](https://statuzer.com/)
|
- [Statuzer](https://statuzer.com/)
|
||||||
- [Tusked](https://tusked.app/)
|
- [Tusked](https://tusked.app/)
|
||||||
- [Mastodon Glitch Edition (standalone frontend)](https://iceshrimp.dev/iceshrimp/masto-fe-standalone)
|
- [Mastodon Glitch Edition (standalone frontend)](https://iceshrimp.dev/iceshrimp/masto-fe-standalone)
|
||||||
|
- [Mangane](https://github.com/BDX-town/Mangane)
|
||||||
|
- [TheDesk](https://github.com/cutls/TheDesk)
|
||||||
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
|
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
|
||||||
|
|
||||||
## 💁♂️ Notice to all other social media client developers
|
## 💁♂️ Notice to all other social media client developers
|
||||||
|
|
723
package-lock.json
generated
723
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
@ -27,11 +27,12 @@
|
||||||
"idb-keyval": "~6.2.1",
|
"idb-keyval": "~6.2.1",
|
||||||
"just-debounce-it": "~3.2.0",
|
"just-debounce-it": "~3.2.0",
|
||||||
"lz-string": "~1.5.0",
|
"lz-string": "~1.5.0",
|
||||||
"masto": "~6.6.4",
|
"masto": "~6.7.0",
|
||||||
"moize": "~6.1.6",
|
"moize": "~6.1.6",
|
||||||
"p-retry": "~6.2.0",
|
"p-retry": "~6.2.0",
|
||||||
"p-throttle": "~6.1.0",
|
"p-throttle": "~6.1.0",
|
||||||
"preact": "~10.19.6",
|
"preact": "~10.20.1",
|
||||||
|
"punycode": "~2.3.1",
|
||||||
"react-hotkeys-hook": "~4.5.0",
|
"react-hotkeys-hook": "~4.5.0",
|
||||||
"react-intersection-observer": "~9.8.1",
|
"react-intersection-observer": "~9.8.1",
|
||||||
"react-quick-pinch-zoom": "~5.1.0",
|
"react-quick-pinch-zoom": "~5.1.0",
|
||||||
|
@ -48,14 +49,14 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@preact/preset-vite": "~2.8.2",
|
"@preact/preset-vite": "~2.8.2",
|
||||||
"@trivago/prettier-plugin-sort-imports": "~4.3.0",
|
"@trivago/prettier-plugin-sort-imports": "~4.3.0",
|
||||||
"postcss": "~8.4.35",
|
"postcss": "~8.4.38",
|
||||||
"postcss-dark-theme-class": "~1.2.1",
|
"postcss-dark-theme-class": "~1.2.1",
|
||||||
"postcss-preset-env": "~9.5.1",
|
"postcss-preset-env": "~9.5.4",
|
||||||
"twitter-text": "~3.1.0",
|
"twitter-text": "~3.1.0",
|
||||||
"vite": "~5.1.6",
|
"vite": "~5.2.8",
|
||||||
"vite-plugin-generate-file": "~0.1.1",
|
"vite-plugin-generate-file": "~0.1.1",
|
||||||
"vite-plugin-html-config": "~1.0.11",
|
"vite-plugin-html-config": "~1.0.11",
|
||||||
"vite-plugin-pwa": "~0.19.4",
|
"vite-plugin-pwa": "~0.19.7",
|
||||||
"vite-plugin-remove-console": "~2.2.0",
|
"vite-plugin-remove-console": "~2.2.0",
|
||||||
"workbox-cacheable-response": "~7.0.0",
|
"workbox-cacheable-response": "~7.0.0",
|
||||||
"workbox-expiration": "~7.0.0",
|
"workbox-expiration": "~7.0.0",
|
||||||
|
|
177
src/app.css
177
src/app.css
|
@ -295,12 +295,40 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
video,
|
video,
|
||||||
img,
|
img,
|
||||||
audio {
|
audio {
|
||||||
min-height: 88px; /* for extreme dimensions */
|
min-height: var(--min-dimension); /* for extreme dimensions */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.deck-container-media-first {
|
||||||
|
.timeline {
|
||||||
|
> li:not(.timeline-item-carousel, .timeline-item-container) {
|
||||||
|
&:has(.status-media-first) {
|
||||||
|
width: fit-content;
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
max-width: min(480px, 100%);
|
||||||
|
margin-inline: auto !important;
|
||||||
|
|
||||||
|
&:has(.skeleton) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(.media[data-orientation='landscape']) {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-link:has(.status-media-first):hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.timeline.grow {
|
.timeline.grow {
|
||||||
/* min-height: 100vh;
|
/* min-height: 100vh;
|
||||||
min-height: 100dvh; */
|
min-height: 100dvh; */
|
||||||
|
@ -1926,11 +1954,11 @@ body > .szh-menu-container {
|
||||||
.szh-menu__item:not(.szh-menu__item--disabled):not(
|
.szh-menu__item:not(.szh-menu__item--disabled):not(
|
||||||
.szh-menu__item--hover
|
.szh-menu__item--hover
|
||||||
).danger {
|
).danger {
|
||||||
color: var(--red-color);
|
color: var(--red-text-color);
|
||||||
}
|
}
|
||||||
.szh-menu
|
.szh-menu
|
||||||
.szh-menu__item.danger:not(.szh-menu__item--disabled).szh-menu__item--hover {
|
.szh-menu__item.danger:not(.szh-menu__item--disabled).szh-menu__item--hover {
|
||||||
background-color: var(--red-color);
|
background-color: var(--red-text-color);
|
||||||
}
|
}
|
||||||
.szh-menu
|
.szh-menu
|
||||||
.szh-menu__item:not(.szh-menu__item--disabled):not(
|
.szh-menu__item:not(.szh-menu__item--disabled):not(
|
||||||
|
@ -2027,71 +2055,86 @@ body > .szh-menu-container {
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* DONUT METER */
|
/* CHAR COUNTER */
|
||||||
|
|
||||||
meter.donut {
|
.char-counter {
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
meter.donut::-webkit-meter-inner-element,
|
|
||||||
meter.donut::-webkit-meter-bar,
|
|
||||||
meter.donut::-webkit-meter-optimum-value,
|
|
||||||
meter.donut::-webkit-meter-suboptimum-value,
|
|
||||||
meter.donut::-webkit-meter-even-less-good-value {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
meter.donut::-moz-meter-bar {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
meter.donut {
|
|
||||||
position: relative;
|
|
||||||
--dimension: 24px;
|
--dimension: 24px;
|
||||||
--border-width: 2px;
|
min-width: var(--dimension);
|
||||||
--middle-circle-radius: calc(var(--dimension) / 2 - var(--border-width));
|
min-height: var(--dimension);
|
||||||
width: var(--dimension);
|
position: relative;
|
||||||
height: var(--dimension);
|
|
||||||
border-radius: 50%;
|
|
||||||
--fill: calc(var(--percentage) * 1%);
|
|
||||||
--color: var(--link-color);
|
|
||||||
--middle-circle: radial-gradient(
|
|
||||||
circle at 50% 50%,
|
|
||||||
var(--bg-color) var(--middle-circle-radius),
|
|
||||||
transparent var(--middle-circle-radius)
|
|
||||||
);
|
|
||||||
background-image: var(--middle-circle),
|
|
||||||
conic-gradient(var(--color) var(--fill), var(--outline-color) 0);
|
|
||||||
transform: scale(0.7);
|
|
||||||
transition: transform 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
meter.donut.warning {
|
|
||||||
--color: var(--orange-color);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
meter.donut.danger {
|
|
||||||
--color: var(--red-color);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
meter.donut.explode {
|
|
||||||
background-image: none;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
meter.donut:is(.warning, .danger, .explode):after {
|
|
||||||
content: attr(data-left);
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-insignificant-color);
|
|
||||||
}
|
|
||||||
meter.donut:is(.danger, .explode):after {
|
|
||||||
color: var(--red-color);
|
|
||||||
}
|
|
||||||
meter.donut[hidden] {
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
visibility: hidden;
|
|
||||||
|
&[hidden] {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
meter {
|
||||||
|
appearance: none;
|
||||||
|
position: relative;
|
||||||
|
--border-width: 2px;
|
||||||
|
--middle-circle-radius: calc(var(--dimension) / 2 - var(--border-width));
|
||||||
|
width: var(--dimension);
|
||||||
|
height: var(--dimension);
|
||||||
|
border-radius: 50%;
|
||||||
|
--fill: calc(var(--percentage) * 1%);
|
||||||
|
--color: var(--link-color);
|
||||||
|
--middle-circle: radial-gradient(
|
||||||
|
circle at 50% 50%,
|
||||||
|
var(--bg-color) var(--middle-circle-radius),
|
||||||
|
transparent var(--middle-circle-radius)
|
||||||
|
);
|
||||||
|
background-image: var(--middle-circle),
|
||||||
|
conic-gradient(var(--color) var(--fill), var(--outline-color) 0);
|
||||||
|
transform: scale(0.7);
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
|
||||||
|
&::-webkit-meter-inner-element,
|
||||||
|
&::-webkit-meter-bar,
|
||||||
|
&::-webkit-meter-optimum-value,
|
||||||
|
&::-webkit-meter-suboptimum-value,
|
||||||
|
&::-webkit-meter-even-less-good-value {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-meter-bar {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
--color: var(--orange-color);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
&.danger {
|
||||||
|
--color: var(--red-color);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
&.explode {
|
||||||
|
background-image: none;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
&:is(.warning, .danger, .explode) + .counter {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
&:is(.danger, .explode) + .counter {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--red-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SHINY PILL */
|
/* SHINY PILL */
|
||||||
|
@ -2289,10 +2332,10 @@ ul.link-list li a .icon {
|
||||||
filter: none !important;
|
filter: none !important;
|
||||||
}
|
}
|
||||||
.nav-menu-button .avatar {
|
.nav-menu-button .avatar {
|
||||||
transition: box-shadow 0.3s ease-out;
|
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-light-color) !important;
|
||||||
}
|
}
|
||||||
.nav-menu-button:is(:hover, :focus, .active) .avatar {
|
.nav-menu-button:is(:hover, :focus, .active) .avatar {
|
||||||
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-light-color);
|
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-color) !important;
|
||||||
}
|
}
|
||||||
.nav-menu-button.with-avatar .icon {
|
.nav-menu-button.with-avatar .icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
25
src/app.jsx
25
src/app.jsx
|
@ -1,7 +1,6 @@
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
|
||||||
import debounce from 'just-debounce-it';
|
import debounce from 'just-debounce-it';
|
||||||
import { lazy, Suspense } from 'preact/compat';
|
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
|
@ -18,15 +17,16 @@ import ComposeButton from './components/compose-button';
|
||||||
import { ICONS } from './components/ICONS';
|
import { ICONS } from './components/ICONS';
|
||||||
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
|
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
|
||||||
import Loader from './components/loader';
|
import Loader from './components/loader';
|
||||||
// import Modals from './components/modals';
|
import Modals from './components/modals';
|
||||||
import NotificationService from './components/notification-service';
|
import NotificationService from './components/notification-service';
|
||||||
import SearchCommand from './components/search-command';
|
import SearchCommand from './components/search-command';
|
||||||
import Shortcuts from './components/shortcuts';
|
import Shortcuts from './components/shortcuts';
|
||||||
import NotFound from './pages/404';
|
import NotFound from './pages/404';
|
||||||
import AccountStatuses from './pages/account-statuses';
|
import AccountStatuses from './pages/account-statuses';
|
||||||
import Bookmarks from './pages/bookmarks';
|
import Bookmarks from './pages/bookmarks';
|
||||||
// import Catchup from './pages/catchup';
|
import Catchup from './pages/catchup';
|
||||||
import Favourites from './pages/favourites';
|
import Favourites from './pages/favourites';
|
||||||
|
import Filters from './pages/filters';
|
||||||
import FollowedHashtags from './pages/followed-hashtags';
|
import FollowedHashtags from './pages/followed-hashtags';
|
||||||
import Following from './pages/following';
|
import Following from './pages/following';
|
||||||
import Hashtag from './pages/hashtag';
|
import Hashtag from './pages/hashtag';
|
||||||
|
@ -56,9 +56,6 @@ import store from './utils/store';
|
||||||
import { getCurrentAccount } from './utils/store-utils';
|
import { getCurrentAccount } from './utils/store-utils';
|
||||||
import './utils/toast-alert';
|
import './utils/toast-alert';
|
||||||
|
|
||||||
const Catchup = lazy(() => import('./pages/catchup'));
|
|
||||||
const Modals = lazy(() => import('./components/modals'));
|
|
||||||
|
|
||||||
window.__STATES__ = states;
|
window.__STATES__ = states;
|
||||||
window.__STATES_STATS__ = () => {
|
window.__STATES_STATS__ = () => {
|
||||||
const keys = [
|
const keys = [
|
||||||
|
@ -386,9 +383,7 @@ function App() {
|
||||||
)}
|
)}
|
||||||
{isLoggedIn && <ComposeButton />}
|
{isLoggedIn && <ComposeButton />}
|
||||||
{isLoggedIn && <Shortcuts />}
|
{isLoggedIn && <Shortcuts />}
|
||||||
<Suspense>
|
<Modals />
|
||||||
<Modals />
|
|
||||||
</Suspense>
|
|
||||||
{isLoggedIn && <NotificationService />}
|
{isLoggedIn && <NotificationService />}
|
||||||
<BackgroundService isLoggedIn={isLoggedIn} />
|
<BackgroundService isLoggedIn={isLoggedIn} />
|
||||||
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
|
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
|
||||||
|
@ -463,15 +458,9 @@ function SecondaryRoutes({ isLoggedIn }) {
|
||||||
<Route index element={<Lists />} />
|
<Route index element={<Lists />} />
|
||||||
<Route path=":id" element={<List />} />
|
<Route path=":id" element={<List />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/ft" element={<FollowedHashtags />} />
|
<Route path="/fh" element={<FollowedHashtags />} />
|
||||||
<Route
|
<Route path="/ft" element={<Filters />} />
|
||||||
path="/catchup"
|
<Route path="/catchup" element={<Catchup />} />
|
||||||
element={
|
|
||||||
<Suspense>
|
|
||||||
<Catchup />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
|
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
|
||||||
|
|
3
src/assets/powered-by-giphy.svg
Normal file
3
src/assets/powered-by-giphy.svg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 641 223">
|
||||||
|
<path fill="#aaa" d="M86 214c-9-1-17-4-24-8l-6-3-5-5-5-4-4-6-4-6-3-8-2-8v-27l2-9 3-9 4-6 4-6 5-5 5-5 7-3 6-4 7-2 7-2 12-1h12l7 1 8 2 7 4 7 3 5 5 5 4-10 10-10 9-4-3-10-5-5-1H88l-5 2-6 3-3 4-4 4-2 5-2 6v6l-1 7 1 7 2 7 3 5 2 4 4 3 4 3 5 2 6 2h9l10-1 5-2 6-3v-16H91v-27h59v54l-1 3-2 3-5 4-4 4-5 3-5 2-8 2-8 2-10 1H92l-6-1zm266-62V91h34v46h44V91h34v121h-34v-46h-44v46h-34v-61zm-182-1V90h34v121h-34v-60zm59-1V90h35l36 1 5 2c3 0 8 2 10 4l5 2 4 5 5 4 3 7 3 7 1 13v13l-4 6-3 7-4 4-5 5-5 2-5 3-6 2-5 1-18 1h-18v32h-34v-61zm67-2 3-2 2-4 2-5v-5l-2-4-2-4-3-2-3-3h-30v31h30l3-2zm226 39v-24l-8-12-18-28a1751 1751 0 0 0-20-31v-2h39l7 12 12 21 6 9 13-21 13-21h38v2l-41 61-7 10v48h-34v-24zM109 66l-4-1-5-5-5-4-1-5-3-9v-5l1-5c2-7 3-10 8-15l4-4 7-2 7-2h7l6 1 5 2 5 2 3 4 4 3 2 6 2 5v13l-2 5-2 6-4 4-3 3-5 2-4 2-9 1h-9l-5-2zm22-11 4-2 3-4 2-5V34l-2-4-2-4-3-2-4-3-5-1h-6l-4 2-5 2-2 4-3 5-1 3v4l1 5 2 5 2 2 5 3 4 2h10l4-2zM37 39V11h33l3 1 3 2 4 3 3 3 1 5 1 4v5l-1 4-3 4-3 5-4 1-3 2-11 1H49v16H37V39zm31 0 3-2 1-2 1-2v-4l-1-3-3-2-2-2H49v18h15l4-1zm107 25a512 512 0 0 0-19-53h14l4 14 6 19 1 4 1-1 7-19 5-17h9l6 19 7 18v-1l2-6 5-17 4-13h14v1l-4 12-16 41v2h-5l-5-1-6-15-6-15-1 1-3 7-6 15-2 8h-11l-1-3zm74-25V11h42v11h-29v2l-1 5v4h29v11h-28v11h2l15 1h13v11h-43V39zm55 0V11h33l5 3 5 2 2 4 2 5v10l-2 3-1 4-5 3-5 3 5 5 8 10 3 4h-14l-7-9-8-10h-9v19h-12V39zm33-3 2-3v-6l-3-3-2-3h-18v16h1v1h17l2-2zm26 3V11h42v11h-29l-1 6v5h29v11h-28v5l-1 5 1 1v1h30v11h-43V39zm54 0V11h17l18 1 4 2 5 3 2 4 3 4 2 6 1 6v5c-1 6-3 12-6 15l-3 4-5 3-5 2-17 1h-16V39zm33 14 5-5 2-3v-6l-1-6-1-3-1-3-4-3-3-2h-5l-6-1-3 1h-3v34h9l8-1 3-2zm50-14V11h34l5 2 4 2 2 3 2 3v9l-2 2-3 4-1 1 3 3 3 4 1 3 1 4-1 4-1 4-3 3-3 3-5 1-5 1h-31V39zm34 15 2-1v-6l-2-2-2-2h-20v13h20l2-2zm-3-22 4-2v-6l-2-1-2-2h-19v12h16l4-1zm42 24V45l-6-9-11-17-5-8h15l4 8 7 11 2 3 7-11 7-11h14l-11 16-11 17v23h-12V56z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
|
@ -15,7 +15,8 @@ body.cloak,
|
||||||
.account-block,
|
.account-block,
|
||||||
.catchup-filters .filter-author *,
|
.catchup-filters .filter-author *,
|
||||||
.post-peek-html *,
|
.post-peek-html *,
|
||||||
.post-peek-content > * {
|
.post-peek-content > *,
|
||||||
|
.request-notifications-account * {
|
||||||
text-decoration-thickness: 1.1em;
|
text-decoration-thickness: 1.1em;
|
||||||
text-decoration-line: line-through;
|
text-decoration-line: line-through;
|
||||||
/* text-rendering: optimizeSpeed; */
|
/* text-rendering: optimizeSpeed; */
|
||||||
|
@ -51,7 +52,8 @@ body.cloak,
|
||||||
.cloak {
|
.cloak {
|
||||||
.media-container figcaption,
|
.media-container figcaption,
|
||||||
.media-container figcaption > *,
|
.media-container figcaption > *,
|
||||||
.catchup-filters .filter-author * {
|
.catchup-filters .filter-author *,
|
||||||
|
.request-notifications-account * {
|
||||||
color: var(--text-color) !important;
|
color: var(--text-color) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,6 +78,7 @@ export const ICONS = {
|
||||||
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
|
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
|
||||||
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
|
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
|
||||||
filter: () => import('@iconify-icons/mingcute/filter-2-line'),
|
filter: () => import('@iconify-icons/mingcute/filter-2-line'),
|
||||||
|
filters: () => import('@iconify-icons/mingcute/filter-line'),
|
||||||
chart: () => import('@iconify-icons/mingcute/chart-line-line'),
|
chart: () => import('@iconify-icons/mingcute/chart-line-line'),
|
||||||
react: () => import('@iconify-icons/mingcute/react-line'),
|
react: () => import('@iconify-icons/mingcute/react-line'),
|
||||||
layout4: () => import('@iconify-icons/mingcute/layout-4-line'),
|
layout4: () => import('@iconify-icons/mingcute/layout-4-line'),
|
||||||
|
@ -105,4 +106,6 @@ export const ICONS = {
|
||||||
copy: () => import('@iconify-icons/mingcute/copy-2-line'),
|
copy: () => import('@iconify-icons/mingcute/copy-2-line'),
|
||||||
quote: () => import('@iconify-icons/mingcute/quote-left-line'),
|
quote: () => import('@iconify-icons/mingcute/quote-left-line'),
|
||||||
settings: () => import('@iconify-icons/mingcute/settings-6-line'),
|
settings: () => import('@iconify-icons/mingcute/settings-6-line'),
|
||||||
|
'heart-break': () => import('@iconify-icons/mingcute/heart-crack-line'),
|
||||||
|
'user-x': () => import('@iconify-icons/mingcute/user-x-line'),
|
||||||
};
|
};
|
||||||
|
|
|
@ -781,3 +781,108 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#edit-profile-container {
|
||||||
|
p {
|
||||||
|
margin-block: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 5em;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.8em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr td:first-child {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
|
* {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-info {
|
||||||
|
.handle-handle {
|
||||||
|
display: inline-block;
|
||||||
|
margin-block: 5px;
|
||||||
|
|
||||||
|
b {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
box-shadow: 0 0 0 5px var(--bg-blur-color);
|
||||||
|
|
||||||
|
&.handle-username {
|
||||||
|
color: var(--orange-fg-color);
|
||||||
|
background-color: var(--orange-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.handle-server {
|
||||||
|
color: var(--purple-fg-color);
|
||||||
|
background-color: var(--purple-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-at {
|
||||||
|
display: inline-block;
|
||||||
|
margin-inline: -3px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-legend {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-legend-icon {
|
||||||
|
overflow: hidden;
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
|
||||||
|
&.username {
|
||||||
|
background-color: var(--orange-fg-color);
|
||||||
|
border-color: var(--orange-bg-color);
|
||||||
|
}
|
||||||
|
&.server {
|
||||||
|
background-color: var(--purple-fg-color);
|
||||||
|
border-color: var(--purple-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import './account-info.css';
|
import './account-info.css';
|
||||||
|
|
||||||
import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu';
|
import { MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
@ -9,11 +9,13 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
|
import punycode from 'punycode';
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import enhanceContent from '../utils/enhance-content';
|
import enhanceContent from '../utils/enhance-content';
|
||||||
import getHTMLText from '../utils/getHTMLText';
|
import getHTMLText from '../utils/getHTMLText';
|
||||||
import handleContentLinks from '../utils/handle-content-links';
|
import handleContentLinks from '../utils/handle-content-links';
|
||||||
|
import { getLists } from '../utils/lists';
|
||||||
import niceDateTime from '../utils/nice-date-time';
|
import niceDateTime from '../utils/nice-date-time';
|
||||||
import pmem from '../utils/pmem';
|
import pmem from '../utils/pmem';
|
||||||
import shortenNumber from '../utils/shorten-number';
|
import shortenNumber from '../utils/shorten-number';
|
||||||
|
@ -31,7 +33,9 @@ import ListAddEdit from './list-add-edit';
|
||||||
import Loader from './loader';
|
import Loader from './loader';
|
||||||
import Menu2 from './menu2';
|
import Menu2 from './menu2';
|
||||||
import MenuConfirm from './menu-confirm';
|
import MenuConfirm from './menu-confirm';
|
||||||
|
import MenuLink from './menu-link';
|
||||||
import Modal from './modal';
|
import Modal from './modal';
|
||||||
|
import SubMenu2 from './submenu2';
|
||||||
import TranslationBlock from './translation-block';
|
import TranslationBlock from './translation-block';
|
||||||
|
|
||||||
const MUTE_DURATIONS = [
|
const MUTE_DURATIONS = [
|
||||||
|
@ -227,7 +231,7 @@ function AccountInfo({
|
||||||
|
|
||||||
const accountInstance = useMemo(() => {
|
const accountInstance = useMemo(() => {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
const domain = new URL(url).hostname;
|
const domain = punycode.toUnicode(new URL(url).hostname);
|
||||||
return domain;
|
return domain;
|
||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
|
@ -250,12 +254,13 @@ function AccountInfo({
|
||||||
// On first load, fetch familiar followers, merge to top of results' `value`
|
// On first load, fetch familiar followers, merge to top of results' `value`
|
||||||
// Remove dups on every fetch
|
// Remove dups on every fetch
|
||||||
if (firstLoad) {
|
if (firstLoad) {
|
||||||
const familiarFollowers = await masto.v1.accounts.familiarFollowers.fetch(
|
let familiarFollowers = [];
|
||||||
{
|
try {
|
||||||
|
familiarFollowers = await masto.v1.accounts.familiarFollowers.fetch({
|
||||||
id: [id],
|
id: [id],
|
||||||
},
|
});
|
||||||
);
|
} catch (e) {}
|
||||||
familiarFollowersCache.current = familiarFollowers[0].accounts;
|
familiarFollowersCache.current = familiarFollowers?.[0]?.accounts || [];
|
||||||
newValue = [
|
newValue = [
|
||||||
...familiarFollowersCache.current,
|
...familiarFollowersCache.current,
|
||||||
...value.filter(
|
...value.filter(
|
||||||
|
@ -340,6 +345,17 @@ function AccountInfo({
|
||||||
[standalone, id, statusesCount],
|
[standalone, id, statusesCount],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onProfileUpdate = useCallback(
|
||||||
|
(newAccount) => {
|
||||||
|
if (newAccount.id === id) {
|
||||||
|
console.log('Updated account info', newAccount);
|
||||||
|
setInfo(newAccount);
|
||||||
|
states.accounts[`${newAccount.id}@${instance}`] = newAccount;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[id, instance],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
|
@ -529,13 +545,64 @@ function AccountInfo({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<header>
|
<header>
|
||||||
<AccountBlock
|
{standalone ? (
|
||||||
account={info}
|
<Menu2
|
||||||
instance={instance}
|
shift={
|
||||||
avatarSize="xxxl"
|
window.matchMedia('(min-width: calc(40em))').matches
|
||||||
external={standalone}
|
? 114
|
||||||
internal={!standalone}
|
: 64
|
||||||
/>
|
}
|
||||||
|
menuButton={
|
||||||
|
<div>
|
||||||
|
<AccountBlock
|
||||||
|
account={info}
|
||||||
|
instance={instance}
|
||||||
|
avatarSize="xxxl"
|
||||||
|
onClick={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="szh-menu__header">
|
||||||
|
<AccountHandleInfo acct={acct} instance={instance} />
|
||||||
|
</div>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const handle = `@${acct}`;
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(handle);
|
||||||
|
showToast('Handle copied');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast('Unable to copy handle');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="link" />
|
||||||
|
<span>Copy handle</span>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem href={url} target="_blank">
|
||||||
|
<Icon icon="external" />
|
||||||
|
<span>Go to original profile page</span>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuLink href={info.avatar} target="_blank">
|
||||||
|
<Icon icon="user" />
|
||||||
|
<span>View profile image</span>
|
||||||
|
</MenuLink>
|
||||||
|
<MenuLink href={info.header} target="_blank">
|
||||||
|
<Icon icon="media" />
|
||||||
|
<span>View profile header</span>
|
||||||
|
</MenuLink>
|
||||||
|
</Menu2>
|
||||||
|
) : (
|
||||||
|
<AccountBlock
|
||||||
|
account={info}
|
||||||
|
instance={instance}
|
||||||
|
avatarSize="xxxl"
|
||||||
|
internal
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<div class="faux-header-bg" aria-hidden="true" />
|
<div class="faux-header-bg" aria-hidden="true" />
|
||||||
<main>
|
<main>
|
||||||
|
@ -605,6 +672,7 @@ function AccountInfo({
|
||||||
// states.showAccount = false;
|
// states.showAccount = false;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
states.showGenericAccounts = {
|
states.showGenericAccounts = {
|
||||||
|
id: 'followers',
|
||||||
heading: 'Followers',
|
heading: 'Followers',
|
||||||
fetchAccounts: fetchFollowers,
|
fetchAccounts: fetchFollowers,
|
||||||
instance,
|
instance,
|
||||||
|
@ -755,45 +823,49 @@ function AccountInfo({
|
||||||
</div>
|
</div>
|
||||||
</LinkOrDiv>
|
</LinkOrDiv>
|
||||||
)}
|
)}
|
||||||
<div class="account-metadata-box">
|
{!moved && (
|
||||||
<div
|
<div class="account-metadata-box">
|
||||||
class="shazam-container no-animation"
|
<div
|
||||||
hidden={!!postingStats}
|
class="shazam-container no-animation"
|
||||||
>
|
hidden={!!postingStats}
|
||||||
<div class="shazam-container-inner">
|
>
|
||||||
<button
|
<div class="shazam-container-inner">
|
||||||
type="button"
|
<button
|
||||||
class="posting-stats-button"
|
type="button"
|
||||||
disabled={postingStatsUIState === 'loading'}
|
class="posting-stats-button"
|
||||||
onClick={() => {
|
disabled={postingStatsUIState === 'loading'}
|
||||||
renderPostingStats();
|
onClick={() => {
|
||||||
}}
|
renderPostingStats();
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={`posting-stats-bar posting-stats-icon ${
|
|
||||||
postingStatsUIState === 'loading' ? 'loading' : ''
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
'--originals-percentage': '33%',
|
|
||||||
'--replies-percentage': '66%',
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
View post stats{' '}
|
<div
|
||||||
{/* <Loader
|
class={`posting-stats-bar posting-stats-icon ${
|
||||||
|
postingStatsUIState === 'loading' ? 'loading' : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
'--originals-percentage': '33%',
|
||||||
|
'--replies-percentage': '66%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
View post stats{' '}
|
||||||
|
{/* <Loader
|
||||||
abrupt
|
abrupt
|
||||||
hidden={postingStatsUIState !== 'loading'}
|
hidden={postingStatsUIState !== 'loading'}
|
||||||
/> */}
|
/> */}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
<RelatedActions
|
<RelatedActions
|
||||||
info={info}
|
info={info}
|
||||||
instance={instance}
|
instance={instance}
|
||||||
|
standalone={standalone}
|
||||||
authenticated={authenticated}
|
authenticated={authenticated}
|
||||||
onRelationshipChange={onRelationshipChange}
|
onRelationshipChange={onRelationshipChange}
|
||||||
|
onProfileUpdate={onProfileUpdate}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
|
@ -808,8 +880,10 @@ const FAMILIAR_FOLLOWERS_LIMIT = 3;
|
||||||
function RelatedActions({
|
function RelatedActions({
|
||||||
info,
|
info,
|
||||||
instance,
|
instance,
|
||||||
|
standalone,
|
||||||
authenticated,
|
authenticated,
|
||||||
onRelationshipChange = () => {},
|
onRelationshipChange = () => {},
|
||||||
|
onProfileUpdate = () => {},
|
||||||
}) {
|
}) {
|
||||||
if (!info) return null;
|
if (!info) return null;
|
||||||
const {
|
const {
|
||||||
|
@ -881,7 +955,7 @@ function RelatedActions({
|
||||||
|
|
||||||
accountID.current = currentID;
|
accountID.current = currentID;
|
||||||
|
|
||||||
if (moved) return;
|
// if (moved) return;
|
||||||
|
|
||||||
setRelationshipUIState('loading');
|
setRelationshipUIState('loading');
|
||||||
|
|
||||||
|
@ -920,6 +994,7 @@ function RelatedActions({
|
||||||
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
|
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
|
||||||
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
|
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
|
||||||
const [showPrivateNoteModal, setShowPrivateNoteModal] = useState(false);
|
const [showPrivateNoteModal, setShowPrivateNoteModal] = useState(false);
|
||||||
|
const [showEditProfile, setShowEditProfile] = useState(false);
|
||||||
const [lists, setLists] = useState([]);
|
const [lists, setLists] = useState([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1029,6 +1104,70 @@ function RelatedActions({
|
||||||
{privateNote ? 'Edit private note' : 'Add private note'}
|
{privateNote ? 'Edit private note' : 'Add private note'}
|
||||||
</span>
|
</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{following && !!relationship && (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setRelationshipUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const rel = await currentMasto.v1.accounts
|
||||||
|
.$select(accountID.current)
|
||||||
|
.follow({
|
||||||
|
notify: !notifying,
|
||||||
|
});
|
||||||
|
if (rel) setRelationship(rel);
|
||||||
|
setRelationshipUIState('default');
|
||||||
|
showToast(
|
||||||
|
rel.notifying
|
||||||
|
? `Notifications enabled for @${username}'s posts.`
|
||||||
|
: ` Notifications disabled for @${username}'s posts.`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e);
|
||||||
|
setRelationshipUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="notification" />
|
||||||
|
<span>
|
||||||
|
{notifying
|
||||||
|
? 'Disable notifications'
|
||||||
|
: 'Enable notifications'}
|
||||||
|
</span>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setRelationshipUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const rel = await currentMasto.v1.accounts
|
||||||
|
.$select(accountID.current)
|
||||||
|
.follow({
|
||||||
|
reblogs: !showingReblogs,
|
||||||
|
});
|
||||||
|
if (rel) setRelationship(rel);
|
||||||
|
setRelationshipUIState('default');
|
||||||
|
showToast(
|
||||||
|
rel.showingReblogs
|
||||||
|
? `Boosts from @${username} disabled.`
|
||||||
|
: `Boosts from @${username} enabled.`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e);
|
||||||
|
setRelationshipUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="rocket" />
|
||||||
|
<span>
|
||||||
|
{showingReblogs ? 'Disable boosts' : 'Enable boosts'}
|
||||||
|
</span>
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{/* Add/remove from lists is only possible if following the account */}
|
{/* Add/remove from lists is only possible if following the account */}
|
||||||
{following && (
|
{following && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
@ -1147,7 +1286,7 @@ function RelatedActions({
|
||||||
<span>Unmute @{username}</span>
|
<span>Unmute @{username}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : (
|
) : (
|
||||||
<SubMenu
|
<SubMenu2
|
||||||
menuClassName="menu-blur"
|
menuClassName="menu-blur"
|
||||||
openTrigger="clickOnly"
|
openTrigger="clickOnly"
|
||||||
direction="bottom"
|
direction="bottom"
|
||||||
|
@ -1201,7 +1340,44 @@ function RelatedActions({
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SubMenu>
|
</SubMenu2>
|
||||||
|
)}
|
||||||
|
{followedBy && (
|
||||||
|
<MenuConfirm
|
||||||
|
subMenu
|
||||||
|
menuItemClassName="danger"
|
||||||
|
confirmLabel={
|
||||||
|
<>
|
||||||
|
<Icon icon="user-x" />
|
||||||
|
<span>Remove @{username} from followers?</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setRelationshipUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const newRelationship = await currentMasto.v1.accounts
|
||||||
|
.$select(currentInfo?.id || id)
|
||||||
|
.removeFromFollowers();
|
||||||
|
console.log(
|
||||||
|
'removing from followers',
|
||||||
|
newRelationship,
|
||||||
|
);
|
||||||
|
setRelationship(newRelationship);
|
||||||
|
setRelationshipUIState('default');
|
||||||
|
showToast(`@${username} removed from followers`);
|
||||||
|
states.reloadGenericAccounts.id = 'followers';
|
||||||
|
states.reloadGenericAccounts.counter++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setRelationshipUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="user-x" />
|
||||||
|
<span>Remove follower…</span>
|
||||||
|
</MenuConfirm>
|
||||||
)}
|
)}
|
||||||
<MenuConfirm
|
<MenuConfirm
|
||||||
subMenu
|
subMenu
|
||||||
|
@ -1276,6 +1452,19 @@ function RelatedActions({
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{currentAuthenticated && isSelf && standalone && (
|
||||||
|
<>
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setShowEditProfile(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="pencil" />
|
||||||
|
<span>Edit profile</span>
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{import.meta.env.DEV && currentAuthenticated && isSelf && (
|
{import.meta.env.DEV && currentAuthenticated && isSelf && (
|
||||||
<>
|
<>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
|
@ -1301,7 +1490,7 @@ function RelatedActions({
|
||||||
{!relationship && relationshipUIState === 'loading' && (
|
{!relationship && relationshipUIState === 'loading' && (
|
||||||
<Loader abrupt />
|
<Loader abrupt />
|
||||||
)}
|
)}
|
||||||
{!!relationship && (
|
{!!relationship && !moved && (
|
||||||
<MenuConfirm
|
<MenuConfirm
|
||||||
confirm={following || requested}
|
confirm={following || requested}
|
||||||
confirmLabel={
|
confirmLabel={
|
||||||
|
@ -1417,6 +1606,22 @@ function RelatedActions({
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
{!!showEditProfile && (
|
||||||
|
<Modal
|
||||||
|
onClose={() => {
|
||||||
|
setShowEditProfile(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditProfileSheet
|
||||||
|
onClose={({ state, account } = {}) => {
|
||||||
|
setShowEditProfile(false);
|
||||||
|
if (state === 'success' && account) {
|
||||||
|
onProfileUpdate(account);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1444,7 +1649,7 @@ function niceAccountURL(url) {
|
||||||
const path = pathname.replace(/\/$/, '').replace(/^\//, '');
|
const path = pathname.replace(/\/$/, '').replace(/^\//, '');
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span class="more-insignificant">{host}/</span>
|
<span class="more-insignificant">{punycode.toUnicode(host)}/</span>
|
||||||
<wbr />
|
<wbr />
|
||||||
<span>{path}</span>
|
<span>{path}</span>
|
||||||
</>
|
</>
|
||||||
|
@ -1494,13 +1699,12 @@ function AddRemoveListsSheet({ accountID, onClose }) {
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const lists = await masto.v1.lists.list();
|
const lists = await getLists();
|
||||||
lists.sort((a, b) => a.title.localeCompare(b.title));
|
setLists(lists);
|
||||||
const listsContainingAccount = await masto.v1.accounts
|
const listsContainingAccount = await masto.v1.accounts
|
||||||
.$select(accountID)
|
.$select(accountID)
|
||||||
.lists.list();
|
.lists.list();
|
||||||
console.log({ lists, listsContainingAccount });
|
console.log({ lists, listsContainingAccount });
|
||||||
setLists(lists);
|
|
||||||
setListsContainingAccount(listsContainingAccount);
|
setListsContainingAccount(listsContainingAccount);
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -1705,4 +1909,213 @@ function PrivateNoteSheet({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EditProfileSheet({ onClose = () => {} }) {
|
||||||
|
const { masto } = api();
|
||||||
|
const [uiState, setUIState] = useState('loading');
|
||||||
|
const [account, setAccount] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const acc = await masto.v1.accounts.verifyCredentials();
|
||||||
|
setAccount(acc);
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
console.log('EditProfileSheet', account);
|
||||||
|
const { displayName, source } = account || {};
|
||||||
|
const { note, fields } = source || {};
|
||||||
|
const fieldsAttributesRef = useRef(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="sheet" id="edit-profile-container">
|
||||||
|
{!!onClose && (
|
||||||
|
<button type="button" class="sheet-close" onClick={onClose}>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<header>
|
||||||
|
<b>Edit profile</b>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{uiState === 'loading' ? (
|
||||||
|
<p class="ui-state">
|
||||||
|
<Loader abrupt />
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const displayName = formData.get('display_name');
|
||||||
|
const note = formData.get('note');
|
||||||
|
const fieldsAttributesFields =
|
||||||
|
fieldsAttributesRef.current.querySelectorAll(
|
||||||
|
'input[name^="fields_attributes"]',
|
||||||
|
);
|
||||||
|
const fieldsAttributes = [];
|
||||||
|
fieldsAttributesFields.forEach((field) => {
|
||||||
|
const name = field.name;
|
||||||
|
const [_, index, key] =
|
||||||
|
name.match(/fields_attributes\[(\d+)\]\[(.+)\]/) || [];
|
||||||
|
const value = field.value ? field.value.trim() : '';
|
||||||
|
if (index && key && value) {
|
||||||
|
if (!fieldsAttributes[index]) fieldsAttributes[index] = {};
|
||||||
|
fieldsAttributes[index][key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Fill in the blanks
|
||||||
|
fieldsAttributes.forEach((field) => {
|
||||||
|
if (field.name && !field.value) {
|
||||||
|
field.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const newAccount = await masto.v1.accounts.updateCredentials({
|
||||||
|
displayName,
|
||||||
|
note,
|
||||||
|
fieldsAttributes,
|
||||||
|
});
|
||||||
|
console.log('updated account', newAccount);
|
||||||
|
onClose?.({
|
||||||
|
state: 'success',
|
||||||
|
account: newAccount,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(e?.message || 'Unable to update profile.');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
Name{' '}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="display_name"
|
||||||
|
defaultValue={displayName}
|
||||||
|
maxLength={30}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
Bio
|
||||||
|
<textarea
|
||||||
|
defaultValue={note}
|
||||||
|
name="note"
|
||||||
|
maxLength={500}
|
||||||
|
rows="5"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
{/* Table for fields; name and values are in fields, min 4 rows */}
|
||||||
|
<p>Extra fields</p>
|
||||||
|
<table ref={fieldsAttributesRef}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Content</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: Math.max(4, fields.length) }).map(
|
||||||
|
(_, i) => {
|
||||||
|
const { name = '', value = '' } = fields[i] || {};
|
||||||
|
return (
|
||||||
|
<FieldsAttributesRow
|
||||||
|
key={i}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
index={i}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<footer>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
onClose?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={uiState === 'loading'}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldsAttributesRow({ name, value, disabled, index: i }) {
|
||||||
|
const [hasValue, setHasValue] = useState(!!value);
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={`fields_attributes[${i}][name]`}
|
||||||
|
defaultValue={name}
|
||||||
|
disabled={disabled}
|
||||||
|
maxLength={255}
|
||||||
|
required={hasValue}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={`fields_attributes[${i}][value]`}
|
||||||
|
defaultValue={value}
|
||||||
|
disabled={disabled}
|
||||||
|
maxLength={255}
|
||||||
|
onChange={(e) => setHasValue(!!e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountHandleInfo({ acct, instance }) {
|
||||||
|
// acct = username or username@server
|
||||||
|
let [username, server] = acct.split('@');
|
||||||
|
if (!server) server = instance;
|
||||||
|
return (
|
||||||
|
<div class="handle-info">
|
||||||
|
<span class="handle-handle">
|
||||||
|
<b class="handle-username">{username}</b>
|
||||||
|
<span class="handle-at">@</span>
|
||||||
|
<b class="handle-server">{server}</b>
|
||||||
|
</span>
|
||||||
|
<div class="handle-legend">
|
||||||
|
<span class="ib">
|
||||||
|
<span class="handle-legend-icon username" /> username
|
||||||
|
</span>{' '}
|
||||||
|
<span class="ib">
|
||||||
|
<span class="handle-legend-icon server" /> server domain name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default AccountInfo;
|
export default AccountInfo;
|
||||||
|
|
|
@ -39,6 +39,8 @@ function Columns() {
|
||||||
if (!Component) return null;
|
if (!Component) return null;
|
||||||
// Don't show Search column with no query, for now
|
// Don't show Search column with no query, for now
|
||||||
if (type === 'search' && !params.query) return null;
|
if (type === 'search' && !params.query) return null;
|
||||||
|
// Don't show List column with no list, for now
|
||||||
|
if (type === 'list' && !params.id) return null;
|
||||||
return (
|
return (
|
||||||
<Component key={type + JSON.stringify(params)} {...params} columnMode />
|
<Component key={type + JSON.stringify(params)} {...params} columnMode />
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
import openCompose from '../utils/open-compose';
|
import openCompose from '../utils/open-compose';
|
||||||
|
import openOSK from '../utils/open-osk';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
@ -14,6 +15,7 @@ export default function ComposeButton() {
|
||||||
states.showCompose = true;
|
states.showCompose = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
openOSK();
|
||||||
states.showCompose = true;
|
states.showCompose = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -727,3 +727,165 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes gif-shake {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: rotate(5deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(-5deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gif-picker-button {
|
||||||
|
span {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 11.5px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:is(:hover, :focus) {
|
||||||
|
span {
|
||||||
|
animation: gif-shake 0.3s 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#gif-picker-sheet {
|
||||||
|
height: 50vh;
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
input[type='search'] {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent 2px,
|
||||||
|
black 16px,
|
||||||
|
black calc(100% - 16px),
|
||||||
|
transparent calc(100% - 2px)
|
||||||
|
);
|
||||||
|
|
||||||
|
@media (min-height: 480px) {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-state {
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
min-height: 100px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
list-style: none;
|
||||||
|
padding: 8px 2px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
@media (min-height: 480px) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
grid-auto-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 4px;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
|
||||||
|
@media (min-height: 480px) {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:is(:hover, :focus) {
|
||||||
|
background-color: var(--link-bg-color);
|
||||||
|
box-shadow: 0 0 0 2px var(--link-light-color);
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: var(--figure-width);
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
@media (min-height: 480px) {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption {
|
||||||
|
font-size: 0.8em;
|
||||||
|
padding: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
background-color: var(--img-bg-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
vertical-align: top;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
@media (min-height: 480px) {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -11,6 +11,8 @@ import { uid } from 'uid/single';
|
||||||
import { useDebouncedCallback, useThrottledCallback } from 'use-debounce';
|
import { useDebouncedCallback, useThrottledCallback } from 'use-debounce';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
import poweredByGiphyURL from '../assets/powered-by-giphy.svg';
|
||||||
|
|
||||||
import Menu2 from '../components/menu2';
|
import Menu2 from '../components/menu2';
|
||||||
import supportedLanguages from '../data/status-supported-languages';
|
import supportedLanguages from '../data/status-supported-languages';
|
||||||
import urlRegex from '../data/url-regex';
|
import urlRegex from '../data/url-regex';
|
||||||
|
@ -41,7 +43,10 @@ import Loader from './loader';
|
||||||
import Modal from './modal';
|
import Modal from './modal';
|
||||||
import Status from './status';
|
import Status from './status';
|
||||||
|
|
||||||
const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env;
|
const {
|
||||||
|
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
|
||||||
|
PHANPY_GIPHY_API_KEY: GIPHY_API_KEY,
|
||||||
|
} = import.meta.env;
|
||||||
|
|
||||||
const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => {
|
const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => {
|
||||||
const [code, common, native] = l;
|
const [code, common, native] = l;
|
||||||
|
@ -299,7 +304,7 @@ function Compose({
|
||||||
setVisibility(visibility);
|
setVisibility(visibility);
|
||||||
setLanguage(language || presf.postingDefaultLanguage || DEFAULT_LANG);
|
setLanguage(language || presf.postingDefaultLanguage || DEFAULT_LANG);
|
||||||
setSensitive(sensitive);
|
setSensitive(sensitive);
|
||||||
setPoll(composablePoll);
|
if (composablePoll) setPoll(composablePoll);
|
||||||
setMediaAttachments(mediaAttachments);
|
setMediaAttachments(mediaAttachments);
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -610,6 +615,7 @@ function Compose({
|
||||||
}, [mediaAttachments]);
|
}, [mediaAttachments]);
|
||||||
|
|
||||||
const [showEmoji2Picker, setShowEmoji2Picker] = useState(false);
|
const [showEmoji2Picker, setShowEmoji2Picker] = useState(false);
|
||||||
|
const [showGIFPicker, setShowGIFPicker] = useState(false);
|
||||||
|
|
||||||
const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => {
|
const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => {
|
||||||
const topLanguages = [];
|
const topLanguages = [];
|
||||||
|
@ -1235,6 +1241,18 @@ function Compose({
|
||||||
>
|
>
|
||||||
<Icon icon="emoji2" />
|
<Icon icon="emoji2" />
|
||||||
</button>
|
</button>
|
||||||
|
{!!states.settings.composerGIFPicker && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toolbar-button gif-picker-button"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
setShowGIFPicker(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>GIF</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
{uiState === 'loading' ? (
|
{uiState === 'loading' ? (
|
||||||
|
@ -1319,6 +1337,64 @@ function Compose({
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
{showGIFPicker && (
|
||||||
|
<Modal
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
setShowGIFPicker(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GIFPickerModal
|
||||||
|
onClose={() => setShowGIFPicker(false)}
|
||||||
|
onSelect={({ url, type, alt_text }) => {
|
||||||
|
console.log('GIF URL', url);
|
||||||
|
if (mediaAttachments.length >= maxMediaAttachments) {
|
||||||
|
alert(
|
||||||
|
`You can only attach up to ${maxMediaAttachments} files.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Download the GIF and insert it as media attachment
|
||||||
|
(async () => {
|
||||||
|
let theToast;
|
||||||
|
try {
|
||||||
|
theToast = showToast({
|
||||||
|
text: 'Downloading GIF…',
|
||||||
|
duration: -1,
|
||||||
|
});
|
||||||
|
const blob = await fetch(url, {
|
||||||
|
referrerPolicy: 'no-referrer',
|
||||||
|
}).then((res) => res.blob());
|
||||||
|
const file = new File(
|
||||||
|
[blob],
|
||||||
|
type === 'video/mp4' ? 'video.mp4' : 'image.gif',
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const newMediaAttachments = [
|
||||||
|
...mediaAttachments,
|
||||||
|
{
|
||||||
|
file,
|
||||||
|
type,
|
||||||
|
size: file.size,
|
||||||
|
id: null,
|
||||||
|
description: alt_text || '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMediaAttachments(newMediaAttachments);
|
||||||
|
theToast?.hideToast?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
theToast?.hideToast?.();
|
||||||
|
showToast('Failed to download GIF');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1662,27 +1738,31 @@ function CharCountMeter({ maxCharacters = 500, hidden }) {
|
||||||
const charCount = snapStates.composerCharacterCount;
|
const charCount = snapStates.composerCharacterCount;
|
||||||
const leftChars = maxCharacters - charCount;
|
const leftChars = maxCharacters - charCount;
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
return <meter class="donut" hidden />;
|
return <span class="char-counter" hidden />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<meter
|
<span
|
||||||
class={`donut ${
|
class="char-counter"
|
||||||
leftChars <= -10
|
|
||||||
? 'explode'
|
|
||||||
: leftChars <= 0
|
|
||||||
? 'danger'
|
|
||||||
: leftChars <= 20
|
|
||||||
? 'warning'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
value={charCount}
|
|
||||||
max={maxCharacters}
|
|
||||||
data-left={leftChars}
|
|
||||||
title={`${leftChars}/${maxCharacters}`}
|
title={`${leftChars}/${maxCharacters}`}
|
||||||
style={{
|
style={{
|
||||||
'--percentage': (charCount / maxCharacters) * 100,
|
'--percentage': (charCount / maxCharacters) * 100,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<meter
|
||||||
|
class={`${
|
||||||
|
leftChars <= -10
|
||||||
|
? 'explode'
|
||||||
|
: leftChars <= 0
|
||||||
|
? 'danger'
|
||||||
|
: leftChars <= 20
|
||||||
|
? 'warning'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
value={charCount}
|
||||||
|
max={maxCharacters}
|
||||||
|
/>
|
||||||
|
<span class="counter">{leftChars}</span>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1707,6 +1787,9 @@ function MediaAttachment({
|
||||||
onDescriptionChange,
|
onDescriptionChange,
|
||||||
250,
|
250,
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
debouncedOnDescriptionChange(description);
|
||||||
|
}, [description, debouncedOnDescriptionChange]);
|
||||||
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
|
@ -1755,7 +1838,7 @@ function MediaAttachment({
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const { value } = e.target;
|
const { value } = e.target;
|
||||||
setDescription(value);
|
setDescription(value);
|
||||||
debouncedOnDescriptionChange(value);
|
// debouncedOnDescriptionChange(value);
|
||||||
}}
|
}}
|
||||||
></textarea>
|
></textarea>
|
||||||
)}
|
)}
|
||||||
|
@ -2239,4 +2322,225 @@ function CustomEmojisModal({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GIFS_PER_PAGE = 20;
|
||||||
|
function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
const formRef = useRef(null);
|
||||||
|
const qRef = useRef(null);
|
||||||
|
const currentOffset = useRef(0);
|
||||||
|
const scrollableRef = useRef(null);
|
||||||
|
|
||||||
|
function fetchGIFs({ offset }) {
|
||||||
|
console.log('fetchGIFs', { offset });
|
||||||
|
if (!qRef.current?.value) return;
|
||||||
|
setUIState('loading');
|
||||||
|
scrollableRef.current?.scrollTo?.({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const query = {
|
||||||
|
api_key: GIPHY_API_KEY,
|
||||||
|
q: qRef.current.value,
|
||||||
|
rating: 'g',
|
||||||
|
limit: GIFS_PER_PAGE,
|
||||||
|
bundle: 'messaging_non_clips',
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
const response = await fetch(
|
||||||
|
'https://api.giphy.com/v1/gifs/search?' + new URLSearchParams(query),
|
||||||
|
{
|
||||||
|
referrerPolicy: 'no-referrer',
|
||||||
|
},
|
||||||
|
).then((r) => r.json());
|
||||||
|
currentOffset.current = response.pagination?.offset || 0;
|
||||||
|
setResults(response);
|
||||||
|
setUIState('results');
|
||||||
|
} catch (e) {
|
||||||
|
setUIState('error');
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
qRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="gif-picker-sheet" class="sheet">
|
||||||
|
{!!onClose && (
|
||||||
|
<button type="button" class="sheet-close" onClick={onClose}>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<header>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchGIFs({ offset: 0 });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={qRef}
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
placeholder="Search GIFs"
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellCheck="false"
|
||||||
|
dir="auto"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="image"
|
||||||
|
class="powered-button"
|
||||||
|
src={poweredByGiphyURL}
|
||||||
|
width="86"
|
||||||
|
height="30"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
<main ref={scrollableRef} class={uiState === 'loading' ? 'loading' : ''}>
|
||||||
|
{uiState === 'default' && (
|
||||||
|
<div class="ui-state">
|
||||||
|
<p class="insignificant">Type to search GIFs</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uiState === 'loading' && !results?.data?.length && (
|
||||||
|
<div class="ui-state">
|
||||||
|
<Loader abrupt />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{results?.data?.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ul>
|
||||||
|
{results.data.map((gif) => {
|
||||||
|
const { id, images, title, alt_text } = gif;
|
||||||
|
const {
|
||||||
|
fixed_height_small,
|
||||||
|
fixed_height_downsampled,
|
||||||
|
fixed_height,
|
||||||
|
original,
|
||||||
|
} = images;
|
||||||
|
const theImage = fixed_height_small?.url
|
||||||
|
? fixed_height_small
|
||||||
|
: fixed_height_downsampled?.url
|
||||||
|
? fixed_height_downsampled
|
||||||
|
: fixed_height;
|
||||||
|
let { url, webp, width, height } = theImage;
|
||||||
|
if (+height > 100) {
|
||||||
|
width = (width / height) * 100;
|
||||||
|
height = 100;
|
||||||
|
}
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const strippedURL = urlObj.origin + urlObj.pathname;
|
||||||
|
let strippedWebP;
|
||||||
|
if (webp) {
|
||||||
|
const webpObj = new URL(webp);
|
||||||
|
strippedWebP = webpObj.origin + webpObj.pathname;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li key={id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const { mp4, url } = original;
|
||||||
|
const theURL = mp4 || url;
|
||||||
|
const urlObj = new URL(theURL);
|
||||||
|
const strippedURL = urlObj.origin + urlObj.pathname;
|
||||||
|
onClose();
|
||||||
|
onSelect({
|
||||||
|
url: strippedURL,
|
||||||
|
type: mp4 ? 'video/mp4' : 'image/gif',
|
||||||
|
alt_text: alt_text || title,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<figure
|
||||||
|
style={{
|
||||||
|
'--figure-width': width + 'px',
|
||||||
|
// width: width + 'px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<picture>
|
||||||
|
{strippedWebP && (
|
||||||
|
<source srcset={strippedWebP} type="image/webp" />
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={strippedURL}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
alt={alt_text}
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
onLoad={(e) => {
|
||||||
|
e.target.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
<figcaption>{alt_text || title}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<p class="pagination">
|
||||||
|
{results.pagination?.offset > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light small"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
fetchGIFs({
|
||||||
|
offset: results.pagination?.offset - GIFS_PER_PAGE,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="chevron-left" />
|
||||||
|
<span>Previous</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span />
|
||||||
|
{results.pagination?.offset + results.pagination?.count <
|
||||||
|
results.pagination?.total_count && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light small"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
fetchGIFs({
|
||||||
|
offset: results.pagination?.offset + GIFS_PER_PAGE,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Next</span> <Icon icon="chevron-right" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
uiState === 'results' && (
|
||||||
|
<div class="ui-state">
|
||||||
|
<p>No results</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{uiState === 'error' && (
|
||||||
|
<div class="ui-state">
|
||||||
|
<p>Error loading GIFs</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default Compose;
|
export default Compose;
|
||||||
|
|
19
src/components/custom-emoji.jsx
Normal file
19
src/components/custom-emoji.jsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
export default function CustomEmoji({ staticUrl, alt, url }) {
|
||||||
|
return (
|
||||||
|
<picture>
|
||||||
|
{staticUrl && (
|
||||||
|
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
key={alt || url}
|
||||||
|
src={url}
|
||||||
|
alt={alt}
|
||||||
|
class="shortcode-emoji emoji"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
import CustomEmoji from './custom-emoji';
|
||||||
|
|
||||||
function EmojiText({ text, emojis }) {
|
function EmojiText({ text, emojis }) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
if (!emojis?.length) return text;
|
if (!emojis?.length) return text;
|
||||||
|
@ -12,21 +14,7 @@ function EmojiText({ text, emojis }) {
|
||||||
const emoji = emojis.find((e) => e.shortcode === word);
|
const emoji = emojis.find((e) => e.shortcode === word);
|
||||||
if (emoji) {
|
if (emoji) {
|
||||||
const { url, staticUrl } = emoji;
|
const { url, staticUrl } = emoji;
|
||||||
return (
|
return <CustomEmoji staticUrl={staticUrl} alt={word} url={url} />;
|
||||||
<picture>
|
|
||||||
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
|
|
||||||
<img
|
|
||||||
key={word}
|
|
||||||
src={url}
|
|
||||||
alt={word}
|
|
||||||
class="shortcode-emoji emoji"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
</picture>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return word;
|
return word;
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,24 @@
|
||||||
#generic-accounts-container {
|
#generic-accounts-container {
|
||||||
|
.post-preview {
|
||||||
|
--max-height: 120px;
|
||||||
|
max-height: var(--max-height);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-block: 8px;
|
||||||
|
border: 1px solid var(--outline-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: calc(var(--text-size) * 0.9);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
black calc(var(--max-height) / 2),
|
||||||
|
transparent calc(var(--max-height) - 8px)
|
||||||
|
);
|
||||||
|
filter: saturate(0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.accounts-list {
|
.accounts-list {
|
||||||
--list-gap: 16px;
|
--list-gap: 16px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
|
@ -12,10 +12,12 @@ import useLocationChange from '../utils/useLocationChange';
|
||||||
import AccountBlock from './account-block';
|
import AccountBlock from './account-block';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import Loader from './loader';
|
import Loader from './loader';
|
||||||
|
import Status from './status';
|
||||||
|
|
||||||
export default function GenericAccounts({
|
export default function GenericAccounts({
|
||||||
instance,
|
instance,
|
||||||
excludeRelationshipAttrs = [],
|
excludeRelationshipAttrs = [],
|
||||||
|
postID,
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}) {
|
}) {
|
||||||
const { masto, instance: currentInstance } = api();
|
const { masto, instance: currentInstance } = api();
|
||||||
|
@ -129,6 +131,8 @@ export default function GenericAccounts({
|
||||||
}
|
}
|
||||||
}, [snapStates.reloadGenericAccounts.counter]);
|
}, [snapStates.reloadGenericAccounts.counter]);
|
||||||
|
|
||||||
|
const post = states.statuses[postID];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="generic-accounts-container" class="sheet" tabindex="-1">
|
<div id="generic-accounts-container" class="sheet" tabindex="-1">
|
||||||
<button type="button" class="sheet-close" onClick={onClose}>
|
<button type="button" class="sheet-close" onClick={onClose}>
|
||||||
|
@ -138,6 +142,11 @@ export default function GenericAccounts({
|
||||||
<h2>{heading || 'Accounts'}</h2>
|
<h2>{heading || 'Accounts'}</h2>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
{post && (
|
||||||
|
<div class="post-preview">
|
||||||
|
<Status status={post} size="s" readOnly />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{accounts.length > 0 ? (
|
{accounts.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<ul class="accounts-list">
|
<ul class="accounts-list">
|
||||||
|
|
36
src/components/intl-segmenter-suspense.jsx
Normal file
36
src/components/intl-segmenter-suspense.jsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { shouldPolyfill } from '@formatjs/intl-segmenter/should-polyfill';
|
||||||
|
import { Suspense } from 'preact/compat';
|
||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import Loader from './loader';
|
||||||
|
|
||||||
|
const supportsIntlSegmenter = !shouldPolyfill();
|
||||||
|
|
||||||
|
// Preload IntlSegmenter
|
||||||
|
setTimeout(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!supportsIntlSegmenter) {
|
||||||
|
import('@formatjs/intl-segmenter/polyfill-force').catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
export default function IntlSegmenterSuspense({ children }) {
|
||||||
|
if (supportsIntlSegmenter) {
|
||||||
|
return <Suspense fallback={<Loader />}>{children}</Suspense>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [polyfillLoaded, setPolyfillLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await import('@formatjs/intl-segmenter/polyfill-force');
|
||||||
|
setPolyfillLoaded(true);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return polyfillLoaded ? (
|
||||||
|
<Suspense fallback={<Loader />}>{children}</Suspense>
|
||||||
|
) : (
|
||||||
|
<Loader />
|
||||||
|
);
|
||||||
|
}
|
54
src/components/lazy-shazam.jsx
Normal file
54
src/components/lazy-shazam.jsx
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
Rendered but hidden. Only show when visible
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
|
||||||
|
// The sticky header, usually at the top
|
||||||
|
const TOP = 48;
|
||||||
|
|
||||||
|
export default function LazyShazam({ children }) {
|
||||||
|
const containerRef = useRef();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [visibleStart, setVisibleStart] = useState(false);
|
||||||
|
|
||||||
|
const { ref } = useInView({
|
||||||
|
root: null,
|
||||||
|
rootMargin: `-${TOP}px 0px 0px 0px`,
|
||||||
|
trackVisibility: true,
|
||||||
|
delay: 1000,
|
||||||
|
onChange: (inView) => {
|
||||||
|
if (inView) {
|
||||||
|
setVisible(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
triggerOnce: true,
|
||||||
|
skip: visibleStart || visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
if (rect.bottom > TOP) {
|
||||||
|
if (rect.top < window.innerHeight) {
|
||||||
|
setVisible(true);
|
||||||
|
} else {
|
||||||
|
setVisibleStart(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (visibleStart) return children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class="shazam-container no-animation"
|
||||||
|
hidden={!visible}
|
||||||
|
>
|
||||||
|
<div ref={ref} class="shazam-container-inner">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
import { addListStore, deleteListStore, updateListStore } from '../utils/lists';
|
||||||
import supports from '../utils/supports';
|
import supports from '../utils/supports';
|
||||||
|
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
@ -75,6 +76,14 @@ function ListAddEdit({ list, onClose }) {
|
||||||
state: 'success',
|
state: 'success',
|
||||||
list: listResult,
|
list: listResult,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (editMode) {
|
||||||
|
updateListStore(listResult);
|
||||||
|
} else {
|
||||||
|
addListStore(listResult);
|
||||||
|
}
|
||||||
|
}, 1);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setUIState('error');
|
setUIState('error');
|
||||||
|
@ -146,6 +155,9 @@ function ListAddEdit({ list, onClose }) {
|
||||||
onClose?.({
|
onClose?.({
|
||||||
state: 'deleted',
|
state: 'deleted',
|
||||||
});
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
deleteListStore(list.id);
|
||||||
|
}, 1);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setUIState('error');
|
setUIState('error');
|
||||||
|
|
|
@ -9,12 +9,12 @@ import {
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
|
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
|
||||||
|
|
||||||
|
import formatDuration from '../utils/format-duration';
|
||||||
import mem from '../utils/mem';
|
import mem from '../utils/mem';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import Link from './link';
|
import Link from './link';
|
||||||
import { formatDuration } from './status';
|
|
||||||
|
|
||||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
|
||||||
|
|
||||||
|
@ -74,7 +74,7 @@ function Media({
|
||||||
altIndex,
|
altIndex,
|
||||||
onClick = () => {},
|
onClick = () => {},
|
||||||
}) {
|
}) {
|
||||||
const {
|
let {
|
||||||
blurhash,
|
blurhash,
|
||||||
description,
|
description,
|
||||||
meta,
|
meta,
|
||||||
|
@ -84,15 +84,27 @@ function Media({
|
||||||
url,
|
url,
|
||||||
type,
|
type,
|
||||||
} = media;
|
} = media;
|
||||||
|
if (/no\-preview\./i.test(previewUrl)) {
|
||||||
|
previewUrl = null;
|
||||||
|
}
|
||||||
const { original = {}, small, focus } = meta || {};
|
const { original = {}, small, focus } = meta || {};
|
||||||
|
|
||||||
const width = showOriginal ? original?.width : small?.width;
|
const width = showOriginal
|
||||||
const height = showOriginal ? original?.height : small?.height;
|
? original?.width
|
||||||
|
: small?.width || original?.width;
|
||||||
|
const height = showOriginal
|
||||||
|
? original?.height
|
||||||
|
: small?.height || original?.height;
|
||||||
const mediaURL = showOriginal ? url : previewUrl || url;
|
const mediaURL = showOriginal ? url : previewUrl || url;
|
||||||
const remoteMediaURL = showOriginal
|
const remoteMediaURL = showOriginal
|
||||||
? remoteUrl
|
? remoteUrl
|
||||||
: previewRemoteUrl || remoteUrl;
|
: previewRemoteUrl || remoteUrl;
|
||||||
const orientation = width >= height ? 'landscape' : 'portrait';
|
const hasDimensions = width && height;
|
||||||
|
const orientation = hasDimensions
|
||||||
|
? width > height
|
||||||
|
? 'landscape'
|
||||||
|
: 'portrait'
|
||||||
|
: null;
|
||||||
|
|
||||||
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
|
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
|
||||||
|
|
||||||
|
@ -133,7 +145,8 @@ function Media({
|
||||||
enabled: pinchZoomEnabled,
|
enabled: pinchZoomEnabled,
|
||||||
draggableUnZoomed: false,
|
draggableUnZoomed: false,
|
||||||
inertiaFriction: 0.9,
|
inertiaFriction: 0.9,
|
||||||
doubleTapZoomOutOnMaxScale: true,
|
tapZoomFactor: 2,
|
||||||
|
doubleTapToggleZoom: true,
|
||||||
containerProps: {
|
containerProps: {
|
||||||
className: 'media-zoom',
|
className: 'media-zoom',
|
||||||
style: {
|
style: {
|
||||||
|
@ -290,7 +303,11 @@ function Media({
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const { src } = e.target;
|
const { src } = e.target;
|
||||||
if (src === mediaURL && mediaURL !== remoteMediaURL) {
|
if (
|
||||||
|
src === mediaURL &&
|
||||||
|
remoteMediaURL &&
|
||||||
|
mediaURL !== remoteMediaURL
|
||||||
|
) {
|
||||||
e.target.src = remoteMediaURL;
|
e.target.src = remoteMediaURL;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -321,6 +338,18 @@ function Media({
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
// e.target.closest('.media-image').style.backgroundImage = '';
|
// e.target.closest('.media-image').style.backgroundImage = '';
|
||||||
e.target.dataset.loaded = true;
|
e.target.dataset.loaded = true;
|
||||||
|
if (!hasDimensions) {
|
||||||
|
const $media = e.target.closest('.media');
|
||||||
|
if ($media) {
|
||||||
|
$media.dataset.orientation =
|
||||||
|
e.target.naturalWidth > e.target.naturalHeight
|
||||||
|
? 'landscape'
|
||||||
|
: 'portrait';
|
||||||
|
$media.style['--width'] = `${e.target.naturalWidth}px`;
|
||||||
|
$media.style['--height'] = `${e.target.naturalHeight}px`;
|
||||||
|
$media.style.aspectRatio = `${e.target.naturalWidth}/${e.target.naturalHeight}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const { src } = e.target;
|
const { src } = e.target;
|
||||||
|
@ -338,6 +367,7 @@ function Media({
|
||||||
</Figure>
|
</Figure>
|
||||||
);
|
);
|
||||||
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
|
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
|
||||||
|
const hasDuration = original.duration > 0;
|
||||||
const shortDuration = original.duration < 31;
|
const shortDuration = original.duration < 31;
|
||||||
const isGIF = type === 'gifv' && shortDuration;
|
const isGIF = type === 'gifv' && shortDuration;
|
||||||
// If GIF is too long, treat it as a video
|
// If GIF is too long, treat it as a video
|
||||||
|
@ -473,14 +503,55 @@ function Media({
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<img
|
{previewUrl ? (
|
||||||
src={previewUrl}
|
<img
|
||||||
alt={showInlineDesc ? '' : description}
|
src={previewUrl}
|
||||||
width={width}
|
alt={showInlineDesc ? '' : description}
|
||||||
height={height}
|
width={width}
|
||||||
data-orientation={orientation}
|
height={height}
|
||||||
loading="lazy"
|
data-orientation={orientation}
|
||||||
/>
|
loading="lazy"
|
||||||
|
onLoad={(e) => {
|
||||||
|
if (!hasDimensions) {
|
||||||
|
const $media = e.target.closest('.media');
|
||||||
|
if ($media) {
|
||||||
|
$media.dataset.orientation =
|
||||||
|
e.target.naturalWidth > e.target.naturalHeight
|
||||||
|
? 'landscape'
|
||||||
|
: 'portrait';
|
||||||
|
$media.style['--width'] = `${e.target.naturalWidth}px`;
|
||||||
|
$media.style[
|
||||||
|
'--height'
|
||||||
|
] = `${e.target.naturalHeight}px`;
|
||||||
|
$media.style.aspectRatio = `${e.target.naturalWidth}/${e.target.naturalHeight}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
src={url + '#t=0.1'} // Make Safari show 1st-frame preview
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
data-orientation={orientation}
|
||||||
|
preload="metadata"
|
||||||
|
muted
|
||||||
|
disablePictureInPicture
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
if (!hasDuration) {
|
||||||
|
const { duration } = e.target;
|
||||||
|
if (duration) {
|
||||||
|
const formattedDuration = formatDuration(duration);
|
||||||
|
const container = e.target.closest('.media-video');
|
||||||
|
if (container) {
|
||||||
|
container.dataset.formattedDuration =
|
||||||
|
formattedDuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div class="media-play">
|
<div class="media-play">
|
||||||
<Icon icon="play" size="xl" />
|
<Icon icon="play" size="xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { MenuItem, SubMenu } from '@szhsin/react-menu';
|
import { MenuItem } from '@szhsin/react-menu';
|
||||||
import { cloneElement } from 'preact';
|
import { cloneElement } from 'preact';
|
||||||
import { useRef } from 'preact/hooks';
|
|
||||||
|
|
||||||
import Menu2 from './menu2';
|
import Menu2 from './menu2';
|
||||||
|
import SubMenu2 from './submenu2';
|
||||||
|
|
||||||
function MenuConfirm({
|
function MenuConfirm({
|
||||||
subMenu = false,
|
subMenu = false,
|
||||||
|
@ -23,11 +23,9 @@ function MenuConfirm({
|
||||||
}
|
}
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
const Parent = subMenu ? SubMenu : Menu2;
|
const Parent = subMenu ? SubMenu2 : Menu2;
|
||||||
const menuRef = useRef();
|
|
||||||
return (
|
return (
|
||||||
<Parent
|
<Parent
|
||||||
instanceRef={menuRef}
|
|
||||||
openTrigger="clickOnly"
|
openTrigger="clickOnly"
|
||||||
direction="bottom"
|
direction="bottom"
|
||||||
overflow="auto"
|
overflow="auto"
|
||||||
|
@ -37,19 +35,6 @@ function MenuConfirm({
|
||||||
{...restProps}
|
{...restProps}
|
||||||
menuButton={subMenu ? undefined : children}
|
menuButton={subMenu ? undefined : children}
|
||||||
label={subMenu ? children : undefined}
|
label={subMenu ? children : undefined}
|
||||||
// Test fix for bug; submenus not opening on Android
|
|
||||||
itemProps={{
|
|
||||||
onPointerMove: (e) => {
|
|
||||||
if (e.pointerType === 'touch') {
|
|
||||||
menuRef.current?.openMenu?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPointerLeave: (e) => {
|
|
||||||
if (e.pointerType === 'touch') {
|
|
||||||
menuRef.current?.openMenu?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MenuItem className={menuItemClassName} onClick={onClick}>
|
<MenuItem className={menuItemClassName} onClick={onClick}>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { lazy } from 'preact/compat';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { subscribe, useSnapshot } from 'valtio';
|
import { subscribe, useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
@ -8,16 +9,19 @@ import showToast from '../utils/show-toast';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
|
||||||
import AccountSheet from './account-sheet';
|
import AccountSheet from './account-sheet';
|
||||||
import Compose from './compose';
|
// import Compose from './compose';
|
||||||
import Drafts from './drafts';
|
import Drafts from './drafts';
|
||||||
import EmbedModal from './embed-modal';
|
import EmbedModal from './embed-modal';
|
||||||
import GenericAccounts from './generic-accounts';
|
import GenericAccounts from './generic-accounts';
|
||||||
|
import IntlSegmenterSuspense from './intl-segmenter-suspense';
|
||||||
import MediaAltModal from './media-alt-modal';
|
import MediaAltModal from './media-alt-modal';
|
||||||
import MediaModal from './media-modal';
|
import MediaModal from './media-modal';
|
||||||
import Modal from './modal';
|
import Modal from './modal';
|
||||||
import ReportModal from './report-modal';
|
import ReportModal from './report-modal';
|
||||||
import ShortcutsSettings from './shortcuts-settings';
|
import ShortcutsSettings from './shortcuts-settings';
|
||||||
|
|
||||||
|
const Compose = lazy(() => import('./compose'));
|
||||||
|
|
||||||
subscribe(states, (changes) => {
|
subscribe(states, (changes) => {
|
||||||
for (const [action, path, value, prevValue] of changes) {
|
for (const [action, path, value, prevValue] of changes) {
|
||||||
// When closing modal, focus on deck
|
// When closing modal, focus on deck
|
||||||
|
@ -36,49 +40,51 @@ export default function Modals() {
|
||||||
<>
|
<>
|
||||||
{!!snapStates.showCompose && (
|
{!!snapStates.showCompose && (
|
||||||
<Modal class="solid">
|
<Modal class="solid">
|
||||||
<Compose
|
<IntlSegmenterSuspense>
|
||||||
replyToStatus={
|
<Compose
|
||||||
typeof snapStates.showCompose !== 'boolean'
|
replyToStatus={
|
||||||
? snapStates.showCompose.replyToStatus
|
typeof snapStates.showCompose !== 'boolean'
|
||||||
: window.__COMPOSE__?.replyToStatus || null
|
? snapStates.showCompose.replyToStatus
|
||||||
}
|
: window.__COMPOSE__?.replyToStatus || null
|
||||||
editStatus={
|
|
||||||
states.showCompose?.editStatus ||
|
|
||||||
window.__COMPOSE__?.editStatus ||
|
|
||||||
null
|
|
||||||
}
|
|
||||||
draftStatus={
|
|
||||||
states.showCompose?.draftStatus ||
|
|
||||||
window.__COMPOSE__?.draftStatus ||
|
|
||||||
null
|
|
||||||
}
|
|
||||||
onClose={(results) => {
|
|
||||||
const { newStatus, instance, type } = results || {};
|
|
||||||
states.showCompose = false;
|
|
||||||
window.__COMPOSE__ = null;
|
|
||||||
if (newStatus) {
|
|
||||||
states.reloadStatusPage++;
|
|
||||||
showToast({
|
|
||||||
text: {
|
|
||||||
post: 'Post published. Check it out.',
|
|
||||||
reply: 'Reply posted. Check it out.',
|
|
||||||
edit: 'Post updated. Check it out.',
|
|
||||||
}[type || 'post'],
|
|
||||||
delay: 1000,
|
|
||||||
duration: 10_000, // 10 seconds
|
|
||||||
onClick: (toast) => {
|
|
||||||
toast.hideToast();
|
|
||||||
states.prevLocation = location;
|
|
||||||
navigate(
|
|
||||||
instance
|
|
||||||
? `/${instance}/s/${newStatus.id}`
|
|
||||||
: `/s/${newStatus.id}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}}
|
editStatus={
|
||||||
/>
|
states.showCompose?.editStatus ||
|
||||||
|
window.__COMPOSE__?.editStatus ||
|
||||||
|
null
|
||||||
|
}
|
||||||
|
draftStatus={
|
||||||
|
states.showCompose?.draftStatus ||
|
||||||
|
window.__COMPOSE__?.draftStatus ||
|
||||||
|
null
|
||||||
|
}
|
||||||
|
onClose={(results) => {
|
||||||
|
const { newStatus, instance, type } = results || {};
|
||||||
|
states.showCompose = false;
|
||||||
|
window.__COMPOSE__ = null;
|
||||||
|
if (newStatus) {
|
||||||
|
states.reloadStatusPage++;
|
||||||
|
showToast({
|
||||||
|
text: {
|
||||||
|
post: 'Post published. Check it out.',
|
||||||
|
reply: 'Reply posted. Check it out.',
|
||||||
|
edit: 'Post updated. Check it out.',
|
||||||
|
}[type || 'post'],
|
||||||
|
delay: 1000,
|
||||||
|
duration: 10_000, // 10 seconds
|
||||||
|
onClick: (toast) => {
|
||||||
|
toast.hideToast();
|
||||||
|
states.prevLocation = location;
|
||||||
|
navigate(
|
||||||
|
instance
|
||||||
|
? `/${instance}/s/${newStatus.id}`
|
||||||
|
: `/s/${newStatus.id}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</IntlSegmenterSuspense>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
{!!snapStates.showSettings && (
|
{!!snapStates.showSettings && (
|
||||||
|
@ -179,6 +185,7 @@ export default function Modals() {
|
||||||
excludeRelationshipAttrs={
|
excludeRelationshipAttrs={
|
||||||
snapStates.showGenericAccounts.excludeRelationshipAttrs
|
snapStates.showGenericAccounts.excludeRelationshipAttrs
|
||||||
}
|
}
|
||||||
|
postID={snapStates.showGenericAccounts.postID}
|
||||||
onClose={() => (states.showGenericAccounts = false)}
|
onClose={() => (states.showGenericAccounts = false)}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
@ -88,3 +88,7 @@
|
||||||
.sparkle-icon {
|
.sparkle-icon {
|
||||||
animation: sparkle-icon 0.3s ease-in-out infinite alternate;
|
animation: sparkle-icon 0.3s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-submenu {
|
||||||
|
max-width: 14em;
|
||||||
|
}
|
||||||
|
|
|
@ -1,17 +1,13 @@
|
||||||
import './nav-menu.css';
|
import './nav-menu.css';
|
||||||
|
|
||||||
import {
|
import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||||
ControlledMenu,
|
|
||||||
MenuDivider,
|
|
||||||
MenuItem,
|
|
||||||
SubMenu,
|
|
||||||
} from '@szhsin/react-menu';
|
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { useLongPress } from 'use-long-press';
|
import { useLongPress } from 'use-long-press';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
import { getLists } from '../utils/lists';
|
||||||
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
|
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
|
@ -19,21 +15,19 @@ import store from '../utils/store';
|
||||||
import Avatar from './avatar';
|
import Avatar from './avatar';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import MenuLink from './menu-link';
|
import MenuLink from './menu-link';
|
||||||
|
import SubMenu2 from './submenu2';
|
||||||
|
|
||||||
function NavMenu(props) {
|
function NavMenu(props) {
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const { masto, instance, authenticated } = api();
|
const { masto, instance, authenticated } = api();
|
||||||
|
|
||||||
const [currentAccount, setCurrentAccount] = useState();
|
const [currentAccount, moreThanOneAccount] = useMemo(() => {
|
||||||
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const accounts = store.local.getJSON('accounts') || [];
|
const accounts = store.local.getJSON('accounts') || [];
|
||||||
const acc = accounts.find(
|
const acc =
|
||||||
(account) => account.info.id === store.session.get('currentAccount'),
|
accounts.find(
|
||||||
);
|
(account) => account.info.id === store.session.get('currentAccount'),
|
||||||
if (acc) setCurrentAccount(acc);
|
) || accounts[0];
|
||||||
setMoreThanOneAccount(accounts.length > 1);
|
return [acc, accounts.length > 1];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Home = Following
|
// Home = Following
|
||||||
|
@ -89,6 +83,13 @@ function NavMenu(props) {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [lists, setLists] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (menuState === 'open') {
|
||||||
|
getLists().then(setLists);
|
||||||
|
}
|
||||||
|
}, [menuState === 'open']);
|
||||||
|
|
||||||
const buttonClickTS = useRef();
|
const buttonClickTS = useRef();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -97,7 +98,7 @@ function NavMenu(props) {
|
||||||
type="button"
|
type="button"
|
||||||
class={`button plain nav-menu-button ${
|
class={`button plain nav-menu-button ${
|
||||||
moreThanOneAccount ? 'with-avatar' : ''
|
moreThanOneAccount ? 'with-avatar' : ''
|
||||||
} ${open ? 'active' : ''}`}
|
} ${menuState === 'open' ? 'active' : ''}`}
|
||||||
style={{ position: 'relative' }}
|
style={{ position: 'relative' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
buttonClickTS.current = Date.now();
|
buttonClickTS.current = Date.now();
|
||||||
|
@ -203,13 +204,44 @@ function NavMenu(props) {
|
||||||
<Icon icon="user" size="l" /> <span>Profile</span>
|
<Icon icon="user" size="l" /> <span>Profile</span>
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
)}
|
)}
|
||||||
<MenuLink to="/l">
|
{lists?.length > 0 ? (
|
||||||
<Icon icon="list" size="l" /> <span>Lists</span>
|
<SubMenu2
|
||||||
</MenuLink>
|
menuClassName="nav-submenu"
|
||||||
|
overflow="auto"
|
||||||
|
gap={-8}
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<Icon icon="list" size="l" />
|
||||||
|
<span class="menu-grow">Lists</span>
|
||||||
|
<Icon icon="chevron-right" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuLink to="/l">
|
||||||
|
<span>All Lists</span>
|
||||||
|
</MenuLink>
|
||||||
|
{lists?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<MenuDivider />
|
||||||
|
{lists.map((list) => (
|
||||||
|
<MenuLink key={list.id} to={`/l/${list.id}`}>
|
||||||
|
<span>{list.title}</span>
|
||||||
|
</MenuLink>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SubMenu2>
|
||||||
|
) : (
|
||||||
|
<MenuLink to="/l">
|
||||||
|
<Icon icon="list" size="l" />
|
||||||
|
<span>Lists</span>
|
||||||
|
</MenuLink>
|
||||||
|
)}
|
||||||
<MenuLink to="/b">
|
<MenuLink to="/b">
|
||||||
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
|
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
<SubMenu
|
<SubMenu2
|
||||||
|
menuClassName="nav-submenu"
|
||||||
overflow="auto"
|
overflow="auto"
|
||||||
gap={-8}
|
gap={-8}
|
||||||
label={
|
label={
|
||||||
|
@ -223,11 +255,15 @@ function NavMenu(props) {
|
||||||
<MenuLink to="/f">
|
<MenuLink to="/f">
|
||||||
<Icon icon="heart" size="l" /> <span>Likes</span>
|
<Icon icon="heart" size="l" /> <span>Likes</span>
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
<MenuLink to="/ft">
|
<MenuLink to="/fh">
|
||||||
<Icon icon="hashtag" size="l" />{' '}
|
<Icon icon="hashtag" size="l" />{' '}
|
||||||
<span>Followed Hashtags</span>
|
<span>Followed Hashtags</span>
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
|
<MenuLink to="/ft">
|
||||||
|
<Icon icon="filters" size="l" />
|
||||||
|
Filters
|
||||||
|
</MenuLink>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
states.showGenericAccounts = {
|
states.showGenericAccounts = {
|
||||||
|
@ -253,7 +289,7 @@ function NavMenu(props) {
|
||||||
<Icon icon="block" size="l" />
|
<Icon icon="block" size="l" />
|
||||||
Blocked users…
|
Blocked users…
|
||||||
</MenuItem>{' '}
|
</MenuItem>{' '}
|
||||||
</SubMenu>
|
</SubMenu2>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
@ -2,11 +2,12 @@ import { Fragment } from 'preact';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
import shortenNumber from '../utils/shorten-number';
|
import shortenNumber from '../utils/shorten-number';
|
||||||
import states from '../utils/states';
|
import states, { statusKey } from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
import useTruncated from '../utils/useTruncated';
|
import useTruncated from '../utils/useTruncated';
|
||||||
|
|
||||||
import Avatar from './avatar';
|
import Avatar from './avatar';
|
||||||
|
import CustomEmoji from './custom-emoji';
|
||||||
import FollowRequestButtons from './follow-request-buttons';
|
import FollowRequestButtons from './follow-request-buttons';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import Link from './link';
|
import Link from './link';
|
||||||
|
@ -25,6 +26,9 @@ const NOTIFICATION_ICONS = {
|
||||||
update: 'pencil',
|
update: 'pencil',
|
||||||
'admin.signup': 'account-edit',
|
'admin.signup': 'account-edit',
|
||||||
'admin.report': 'account-warning',
|
'admin.report': 'account-warning',
|
||||||
|
severed_relationships: 'heart-break',
|
||||||
|
emoji_reaction: 'emoji2',
|
||||||
|
'pleroma:emoji_reaction': 'emoji2',
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -42,6 +46,24 @@ admin.sign_up = Someone signed up (optionally sent to admins)
|
||||||
admin.report = A new report has been filed
|
admin.report = A new report has been filed
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
function emojiText(emoji, emoji_url) {
|
||||||
|
let url;
|
||||||
|
let staticUrl;
|
||||||
|
if (typeof emoji_url === 'string') {
|
||||||
|
url = emoji_url;
|
||||||
|
} else {
|
||||||
|
url = emoji_url?.url;
|
||||||
|
staticUrl = emoji_url?.staticUrl;
|
||||||
|
}
|
||||||
|
return url ? (
|
||||||
|
<>
|
||||||
|
reacted to your post with{' '}
|
||||||
|
<CustomEmoji url={url} staticUrl={staticUrl} alt={emoji} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`reacted to your post with ${emoji}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
const contentText = {
|
const contentText = {
|
||||||
mention: 'mentioned you in their post.',
|
mention: 'mentioned you in their post.',
|
||||||
status: 'published a post.',
|
status: 'published a post.',
|
||||||
|
@ -63,6 +85,35 @@ const contentText = {
|
||||||
'favourite+reblog_reply': 'boosted & liked your reply.',
|
'favourite+reblog_reply': 'boosted & liked your reply.',
|
||||||
'admin.sign_up': 'signed up.',
|
'admin.sign_up': 'signed up.',
|
||||||
'admin.report': (targetAccount) => <>reported {targetAccount}</>,
|
'admin.report': (targetAccount) => <>reported {targetAccount}</>,
|
||||||
|
severed_relationships: (name) => (
|
||||||
|
<>
|
||||||
|
Lost connections with <i>{name}</i>.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
emoji_reaction: emojiText,
|
||||||
|
'pleroma:emoji_reaction': emojiText,
|
||||||
|
};
|
||||||
|
|
||||||
|
// account_suspension, domain_block, user_domain_block
|
||||||
|
const SEVERED_RELATIONSHIPS_TEXT = {
|
||||||
|
account_suspension: ({ from, targetName }) => (
|
||||||
|
<>
|
||||||
|
An admin from <i>{from}</i> has suspended <i>{targetName}</i>, which means
|
||||||
|
you can no longer receive updates from them or interact with them.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
domain_block: ({ from, targetName, followersCount, followingCount }) => (
|
||||||
|
<>
|
||||||
|
An admin from <i>{from}</i> has blocked <i>{targetName}</i>. Affected
|
||||||
|
followers: {followersCount}, followings: {followingCount}.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
user_domain_block: ({ targetName, followersCount, followingCount }) => (
|
||||||
|
<>
|
||||||
|
You have blocked <i>{targetName}</i>. Removed followers: {followersCount},
|
||||||
|
followings: {followingCount}.
|
||||||
|
</>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const AVATARS_LIMIT = 50;
|
const AVATARS_LIMIT = 50;
|
||||||
|
@ -73,7 +124,8 @@ function Notification({
|
||||||
isStatic,
|
isStatic,
|
||||||
disableContextMenu,
|
disableContextMenu,
|
||||||
}) {
|
}) {
|
||||||
const { id, status, account, report, _accounts, _statuses } = notification;
|
const { id, status, account, report, event, _accounts, _statuses } =
|
||||||
|
notification;
|
||||||
let { type } = notification;
|
let { type } = notification;
|
||||||
|
|
||||||
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
|
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
|
||||||
|
@ -128,13 +180,30 @@ function Notification({
|
||||||
|
|
||||||
if (typeof text === 'function') {
|
if (typeof text === 'function') {
|
||||||
const count = _statuses?.length || _accounts?.length;
|
const count = _statuses?.length || _accounts?.length;
|
||||||
if (count) {
|
if (type === 'admin.report') {
|
||||||
text = text(count);
|
|
||||||
} else if (type === 'admin.report') {
|
|
||||||
const targetAccount = report?.targetAccount;
|
const targetAccount = report?.targetAccount;
|
||||||
if (targetAccount) {
|
if (targetAccount) {
|
||||||
text = text(<NameText account={targetAccount} showAvatar />);
|
text = text(<NameText account={targetAccount} showAvatar />);
|
||||||
}
|
}
|
||||||
|
} else if (type === 'severed_relationships') {
|
||||||
|
const targetName = event?.targetName;
|
||||||
|
if (targetName) {
|
||||||
|
text = text(targetName);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
(type === 'emoji_reaction' || type === 'pleroma:emoji_reaction') &&
|
||||||
|
notification.emoji
|
||||||
|
) {
|
||||||
|
const emojiURL =
|
||||||
|
notification.emoji_url || // This is string
|
||||||
|
status?.emojis?.find?.(
|
||||||
|
(emoji) =>
|
||||||
|
emoji?.shortcode ===
|
||||||
|
notification.emoji.replace(/^:/, '').replace(/:$/, ''),
|
||||||
|
); // Emoji object instead of string
|
||||||
|
text = text(notification.emoji, emojiURL);
|
||||||
|
} else if (count) {
|
||||||
|
text = text(count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,6 +228,7 @@ function Notification({
|
||||||
accounts: _accounts,
|
accounts: _accounts,
|
||||||
showReactions: type === 'favourite+reblog',
|
showReactions: type === 'favourite+reblog',
|
||||||
excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [],
|
excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [],
|
||||||
|
postID: statusKey(actualStatusID, instance),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -203,9 +273,11 @@ function Notification({
|
||||||
</b>{' '}
|
</b>{' '}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
account && (
|
||||||
<NameText account={account} showAvatar />{' '}
|
<>
|
||||||
</>
|
<NameText account={account} showAvatar />{' '}
|
||||||
|
</>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -224,6 +296,23 @@ function Notification({
|
||||||
{type === 'follow_request' && (
|
{type === 'follow_request' && (
|
||||||
<FollowRequestButtons accountID={account.id} />
|
<FollowRequestButtons accountID={account.id} />
|
||||||
)}
|
)}
|
||||||
|
{type === 'severed_relationships' && (
|
||||||
|
<div>
|
||||||
|
{SEVERED_RELATIONSHIPS_TEXT[event.type]({
|
||||||
|
from: instance,
|
||||||
|
...event,
|
||||||
|
})}
|
||||||
|
<br />
|
||||||
|
<a
|
||||||
|
href={`https://${instance}/severed_relationships`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Learn more <Icon icon="external" size="s" />
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{_accounts?.length > 1 && (
|
{_accounts?.length > 1 && (
|
||||||
|
|
|
@ -8,7 +8,7 @@ import dayjs from 'dayjs';
|
||||||
import dayjsTwitter from 'dayjs-twitter';
|
import dayjsTwitter from 'dayjs-twitter';
|
||||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import { useMemo } from 'preact/hooks';
|
import { useEffect, useMemo, useReducer } from 'preact/hooks';
|
||||||
|
|
||||||
dayjs.extend(dayjsTwitter);
|
dayjs.extend(dayjsTwitter);
|
||||||
dayjs.extend(localizedFormat);
|
dayjs.extend(localizedFormat);
|
||||||
|
@ -18,22 +18,49 @@ const dtf = new Intl.DateTimeFormat();
|
||||||
|
|
||||||
export default function RelativeTime({ datetime, format }) {
|
export default function RelativeTime({ datetime, format }) {
|
||||||
if (!datetime) return null;
|
if (!datetime) return null;
|
||||||
|
const [renderCount, rerender] = useReducer((x) => x + 1, 0);
|
||||||
const date = useMemo(() => dayjs(datetime), [datetime]);
|
const date = useMemo(() => dayjs(datetime), [datetime]);
|
||||||
const dateStr = useMemo(() => {
|
const [dateStr, dt, title] = useMemo(() => {
|
||||||
|
let str;
|
||||||
if (format === 'micro') {
|
if (format === 'micro') {
|
||||||
// If date <= 1 day ago or day is within this year
|
// If date <= 1 day ago or day is within this year
|
||||||
const now = dayjs();
|
const now = dayjs();
|
||||||
const dayDiff = now.diff(date, 'day');
|
const dayDiff = now.diff(date, 'day');
|
||||||
if (dayDiff <= 1 || now.year() === date.year()) {
|
if (dayDiff <= 1 || now.year() === date.year()) {
|
||||||
return date.twitter();
|
str = date.twitter();
|
||||||
} else {
|
} else {
|
||||||
return dtf.format(date.toDate());
|
str = dtf.format(date.toDate());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return date.fromNow();
|
if (!str) str = date.fromNow();
|
||||||
}, [date, format]);
|
return [str, date.toISOString(), date.format('LLLL')];
|
||||||
const dt = useMemo(() => date.toISOString(), [date]);
|
}, [date, format, renderCount]);
|
||||||
const title = useMemo(() => date.format('LLLL'), [date]);
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeout;
|
||||||
|
let raf;
|
||||||
|
function rafRerender() {
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
rerender();
|
||||||
|
scheduleRerender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function scheduleRerender() {
|
||||||
|
// If less than 1 minute, rerender every 10s
|
||||||
|
// If less than 1 hour rerender every 1m
|
||||||
|
// Else, don't need to rerender
|
||||||
|
if (date.diff(dayjs(), 'minute', true) < 1) {
|
||||||
|
timeout = setTimeout(rafRerender, 10_000);
|
||||||
|
} else if (date.diff(dayjs(), 'hour', true) < 1) {
|
||||||
|
timeout = setTimeout(rafRerender, 60_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleRerender();
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<time datetime={dt} title={title}>
|
<time datetime={dt} title={title}>
|
||||||
|
|
|
@ -14,6 +14,7 @@ import tabMenuBarUrl from '../assets/tab-menu-bar.svg';
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import { fetchFollowedTags } from '../utils/followed-tags';
|
import { fetchFollowedTags } from '../utils/followed-tags';
|
||||||
|
import { getLists, getListTitle } from '../utils/lists';
|
||||||
import pmem from '../utils/pmem';
|
import pmem from '../utils/pmem';
|
||||||
import showToast from '../utils/show-toast';
|
import showToast from '../utils/show-toast';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
@ -43,7 +44,7 @@ const TYPES = [
|
||||||
const TYPE_TEXT = {
|
const TYPE_TEXT = {
|
||||||
following: 'Home / Following',
|
following: 'Home / Following',
|
||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
list: 'List',
|
list: 'Lists',
|
||||||
public: 'Public (Local / Federated)',
|
public: 'Public (Local / Federated)',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
'account-statuses': 'Account',
|
'account-statuses': 'Account',
|
||||||
|
@ -58,6 +59,7 @@ const TYPE_PARAMS = {
|
||||||
{
|
{
|
||||||
text: 'List ID',
|
text: 'List ID',
|
||||||
name: 'id',
|
name: 'id',
|
||||||
|
notRequired: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
public: [
|
public: [
|
||||||
|
@ -122,10 +124,6 @@ const TYPE_PARAMS = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const fetchListTitle = pmem(async ({ id }) => {
|
|
||||||
const list = await api().masto.v1.lists.$select(id).fetch();
|
|
||||||
return list.title;
|
|
||||||
});
|
|
||||||
const fetchAccountTitle = pmem(async ({ id }) => {
|
const fetchAccountTitle = pmem(async ({ id }) => {
|
||||||
const account = await api().masto.v1.accounts.$select(id).fetch();
|
const account = await api().masto.v1.accounts.$select(id).fetch();
|
||||||
return account.username || account.acct || account.displayName;
|
return account.username || account.acct || account.displayName;
|
||||||
|
@ -150,10 +148,11 @@ export const SHORTCUTS_META = {
|
||||||
icon: 'notification',
|
icon: 'notification',
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
id: 'list',
|
id: ({ id }) => (id ? 'list' : 'lists'),
|
||||||
title: fetchListTitle,
|
title: ({ id }) => (id ? getListTitle(id) : 'Lists'),
|
||||||
path: ({ id }) => `/l/${id}`,
|
path: ({ id }) => (id ? `/l/${id}` : '/l'),
|
||||||
icon: 'list',
|
icon: 'list',
|
||||||
|
excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []),
|
||||||
},
|
},
|
||||||
public: {
|
public: {
|
||||||
id: 'public',
|
id: 'public',
|
||||||
|
@ -496,18 +495,8 @@ function ShortcutsSettings({ onClose }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FETCH_MAX_AGE = 1000 * 60; // 1 minute
|
|
||||||
const fetchLists = pmem(
|
|
||||||
() => {
|
|
||||||
const { masto } = api();
|
|
||||||
return masto.v1.lists.list();
|
|
||||||
},
|
|
||||||
{
|
|
||||||
maxAge: FETCH_MAX_AGE,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const FORM_NOTES = {
|
const FORM_NOTES = {
|
||||||
|
list: `Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`,
|
||||||
search: `For multi-column mode, search term is required, else the column will not be shown.`,
|
search: `For multi-column mode, search term is required, else the column will not be shown.`,
|
||||||
hashtag: 'Multiple hashtags are supported. Space-separated.',
|
hashtag: 'Multiple hashtags are supported. Space-separated.',
|
||||||
};
|
};
|
||||||
|
@ -532,8 +521,7 @@ function ShortcutForm({
|
||||||
if (currentType !== 'list') return;
|
if (currentType !== 'list') return;
|
||||||
try {
|
try {
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
const lists = await fetchLists();
|
const lists = await getLists();
|
||||||
lists.sort((a, b) => a.title.localeCompare(b.title));
|
|
||||||
setLists(lists);
|
setLists(lists);
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -644,6 +632,7 @@ function ShortcutForm({
|
||||||
disabled={disabled || uiState === 'loading'}
|
disabled={disabled || uiState === 'loading'}
|
||||||
defaultValue={editMode ? shortcut.id : undefined}
|
defaultValue={editMode ? shortcut.id : undefined}
|
||||||
>
|
>
|
||||||
|
<option value=""></option>
|
||||||
{lists.map((list) => (
|
{lists.map((list) => (
|
||||||
<option value={list.id}>{list.title}</option>
|
<option value={list.id}>{list.title}</option>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
import './shortcuts.css';
|
import './shortcuts.css';
|
||||||
|
|
||||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
import { MenuDivider } from '@szhsin/react-menu';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { useMemo, useRef } from 'preact/hooks';
|
import { useRef, useState } from 'preact/hooks';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import { SHORTCUTS_META } from '../components/shortcuts-settings';
|
import { SHORTCUTS_META } from '../components/shortcuts-settings';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
import { getLists } from '../utils/lists';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
|
||||||
import AsyncText from './AsyncText';
|
import AsyncText from './AsyncText';
|
||||||
|
@ -16,6 +17,7 @@ import Icon from './icon';
|
||||||
import Link from './link';
|
import Link from './link';
|
||||||
import Menu2 from './menu2';
|
import Menu2 from './menu2';
|
||||||
import MenuLink from './menu-link';
|
import MenuLink from './menu-link';
|
||||||
|
import SubMenu2 from './submenu2';
|
||||||
|
|
||||||
function Shortcuts() {
|
function Shortcuts() {
|
||||||
const { instance } = api();
|
const { instance } = api();
|
||||||
|
@ -34,47 +36,48 @@ function Shortcuts() {
|
||||||
|
|
||||||
const menuRef = useRef();
|
const menuRef = useRef();
|
||||||
|
|
||||||
const formattedShortcuts = useMemo(
|
const hasLists = useRef(false);
|
||||||
() =>
|
const formattedShortcuts = shortcuts
|
||||||
shortcuts
|
.map((pin, i) => {
|
||||||
.map((pin, i) => {
|
const { type, ...data } = pin;
|
||||||
const { type, ...data } = pin;
|
if (!SHORTCUTS_META[type]) return null;
|
||||||
if (!SHORTCUTS_META[type]) return null;
|
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
|
||||||
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
|
|
||||||
|
|
||||||
if (typeof id === 'function') {
|
if (typeof id === 'function') {
|
||||||
id = id(data, i);
|
id = id(data, i);
|
||||||
}
|
}
|
||||||
if (typeof path === 'function') {
|
if (typeof path === 'function') {
|
||||||
path = path(
|
path = path(
|
||||||
{
|
{
|
||||||
...data,
|
...data,
|
||||||
instance: data.instance || instance,
|
instance: data.instance || instance,
|
||||||
},
|
},
|
||||||
i,
|
i,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (typeof title === 'function') {
|
if (typeof title === 'function') {
|
||||||
title = title(data, i);
|
title = title(data, i);
|
||||||
}
|
}
|
||||||
if (typeof subtitle === 'function') {
|
if (typeof subtitle === 'function') {
|
||||||
subtitle = subtitle(data, i);
|
subtitle = subtitle(data, i);
|
||||||
}
|
}
|
||||||
if (typeof icon === 'function') {
|
if (typeof icon === 'function') {
|
||||||
icon = icon(data, i);
|
icon = icon(data, i);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (id === 'lists') {
|
||||||
id,
|
hasLists.current = true;
|
||||||
path,
|
}
|
||||||
title,
|
|
||||||
subtitle,
|
return {
|
||||||
icon,
|
id,
|
||||||
};
|
path,
|
||||||
})
|
title,
|
||||||
.filter(Boolean),
|
subtitle,
|
||||||
[shortcuts],
|
icon,
|
||||||
);
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
|
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
|
||||||
|
@ -88,6 +91,8 @@ function Shortcuts() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [lists, setLists] = useState([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="shortcuts">
|
<div id="shortcuts">
|
||||||
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
|
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
|
||||||
|
@ -147,6 +152,11 @@ function Shortcuts() {
|
||||||
menuClassName="glass-menu shortcuts-menu"
|
menuClassName="glass-menu shortcuts-menu"
|
||||||
gap={8}
|
gap={8}
|
||||||
position="anchor"
|
position="anchor"
|
||||||
|
onMenuChange={(e) => {
|
||||||
|
if (e.open && hasLists.current) {
|
||||||
|
getLists().then(setLists);
|
||||||
|
}
|
||||||
|
}}
|
||||||
menuButton={
|
menuButton={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -171,6 +181,35 @@ function Shortcuts() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{formattedShortcuts.map(({ id, path, title, subtitle, icon }, i) => {
|
{formattedShortcuts.map(({ id, path, title, subtitle, icon }, i) => {
|
||||||
|
if (id === 'lists') {
|
||||||
|
return (
|
||||||
|
<SubMenu2
|
||||||
|
menuClassName="glass-menu"
|
||||||
|
overflow="auto"
|
||||||
|
gap={-8}
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<Icon icon={icon} size="l" />
|
||||||
|
<span class="menu-grow">
|
||||||
|
<AsyncText>{title}</AsyncText>
|
||||||
|
</span>
|
||||||
|
<Icon icon="chevron-right" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuLink to="/l">
|
||||||
|
<span>All Lists</span>
|
||||||
|
</MenuLink>
|
||||||
|
<MenuDivider />
|
||||||
|
{lists?.map((list) => (
|
||||||
|
<MenuLink key={list.id} to={`/l/${list.id}`}>
|
||||||
|
<span>{list.title}</span>
|
||||||
|
</MenuLink>
|
||||||
|
))}
|
||||||
|
</SubMenu2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuLink
|
<MenuLink
|
||||||
to={path}
|
to={path}
|
||||||
|
|
|
@ -105,7 +105,7 @@
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-shadow: 0 1px var(--bg-color);
|
/* text-shadow: 0 1px var(--bg-color); */
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
@ -160,7 +160,7 @@
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:after {
|
&[data-read-more]:after {
|
||||||
content: attr(data-read-more);
|
content: attr(data-read-more);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
@ -618,31 +618,33 @@
|
||||||
~ *:not(
|
~ *:not(
|
||||||
.content.truncated,
|
.content.truncated,
|
||||||
.media-container,
|
.media-container,
|
||||||
|
.media-first-container,
|
||||||
.card,
|
.card,
|
||||||
.media-figure-multiple,
|
.media-figure-multiple,
|
||||||
.spoiler-media-button
|
.spoiler-media-button
|
||||||
),
|
),
|
||||||
~ .card .meta-container {
|
~ .card .meta-container {
|
||||||
/* filter: blur(5px) invert(0.5);
|
|
||||||
image-rendering: crisp-edges;
|
|
||||||
image-rendering: pixelated; */
|
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
text-decoration-thickness: 1.5em;
|
text-decoration-thickness: 1.5em;
|
||||||
text-decoration-line: line-through;
|
text-decoration-line: line-through;
|
||||||
/* text-rendering: optimizeSpeed; */
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
/* contain: layout; */
|
|
||||||
/* transform: scale(0.97);
|
|
||||||
transition: transform 0.1s ease-in-out; */
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
text-decoration-color: inherit;
|
text-decoration-color: inherit;
|
||||||
text-decoration-thickness: 1.5em;
|
text-decoration-thickness: 1.5em;
|
||||||
text-decoration-line: line-through;
|
text-decoration-line: line-through;
|
||||||
/* text-rendering: optimizeSpeed; */
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
~ *:not(
|
||||||
|
.media-container,
|
||||||
|
.media-first-container,
|
||||||
|
.card,
|
||||||
|
.media-figure-multiple,
|
||||||
|
.spoiler-media-button
|
||||||
|
),
|
||||||
|
~ .card .meta-container {
|
||||||
img {
|
img {
|
||||||
filter: invert(0.5);
|
filter: invert(0.5);
|
||||||
background-color: black;
|
background-color: black;
|
||||||
|
@ -708,11 +710,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
~ :is(.media-container, .media-figure-multiple) .media {
|
~ :is(.media-container, .media-first-container, .media-figure-multiple)
|
||||||
|
.media {
|
||||||
background-image: radial-gradient(
|
background-image: radial-gradient(
|
||||||
circle at 50% 50%,
|
circle at 50% 50%,
|
||||||
var(--average-color, var(--bg-faded-color)),
|
var(--average-color, var(--bg-faded-color)),
|
||||||
var(--bg-color) 20em
|
var(--bg-color) 25em
|
||||||
);
|
);
|
||||||
|
|
||||||
> *:not(.media-play, .alt-badge) {
|
> *:not(.media-play, .alt-badge) {
|
||||||
|
@ -790,7 +793,9 @@
|
||||||
black 1.5em
|
black 1.5em
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
.timeline-deck .status:not(.truncated .status) .content.truncated:after {
|
.timeline-deck
|
||||||
|
.status:not(.truncated .status)
|
||||||
|
.content.truncated[data-read-more]:after {
|
||||||
content: attr(data-read-more);
|
content: attr(data-read-more);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
@ -908,7 +913,7 @@
|
||||||
grid-auto-rows: 1fr;
|
grid-auto-rows: 1fr;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
/* height: 160px; */
|
/* height: 160px; */
|
||||||
min-height: 88px;
|
min-height: var(--min-dimension);
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: max(160px, 33vh);
|
max-height: max(160px, 33vh);
|
||||||
}
|
}
|
||||||
|
@ -1037,9 +1042,9 @@
|
||||||
.status .media-container.media-eq1 .media {
|
.status .media-container.media-eq1 .media {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
min-width: 88px;
|
min-width: var(--min-dimension);
|
||||||
/* width: auto; */
|
/* width: auto; */
|
||||||
min-height: 88px;
|
min-height: var(--min-dimension);
|
||||||
/* --maxAspectHeight: max(160px, 33vh);
|
/* --maxAspectHeight: max(160px, 33vh);
|
||||||
--aspectWidth: calc(--width / --height * var(--maxAspectHeight)); */
|
--aspectWidth: calc(--width / --height * var(--maxAspectHeight)); */
|
||||||
width: min(var(--aspectWidth), var(--width), 100%);
|
width: min(var(--aspectWidth), var(--width), 100%);
|
||||||
|
@ -1300,7 +1305,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
||||||
:is(.status, .media-post) .media-audio {
|
:is(.status, .media-post) .media-audio {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 88px;
|
min-height: var(--min-dimension);
|
||||||
background-image: radial-gradient(
|
background-image: radial-gradient(
|
||||||
circle at center center,
|
circle at center center,
|
||||||
transparent,
|
transparent,
|
||||||
|
@ -1314,6 +1319,227 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
||||||
background-blend-mode: multiply;
|
background-blend-mode: multiply;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status.skeleton .media-first-container {
|
||||||
|
min-height: 3em;
|
||||||
|
background-color: var(--outline-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-media-first {
|
||||||
|
.meta-name {
|
||||||
|
opacity: 0.65;
|
||||||
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
|
||||||
|
b + i {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:is(:hover, :focus) > & .meta-name {
|
||||||
|
opacity: 1;
|
||||||
|
b + i {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-first-spoiler-content {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
&:hover .media-first-spoiler-content {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-first-spoiler-button {
|
||||||
|
display: inline-flex !important;
|
||||||
|
}
|
||||||
|
.media-first-container {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
user-select: none;
|
||||||
|
margin-inline: -16px;
|
||||||
|
position: relative;
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* border: var(--hairline-width) solid var(--outline-color);
|
||||||
|
border-inline-width: 0;
|
||||||
|
background-color: var(--bg-faded-color); */
|
||||||
|
|
||||||
|
@media (min-width: 40em) {
|
||||||
|
margin-inline: 0;
|
||||||
|
/* border-radius: 4px; */
|
||||||
|
border-inline-width: var(--hairline-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .media-first-item {
|
||||||
|
scroll-snap-align: center;
|
||||||
|
scroll-snap-stop: always;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:not(:only-child) {
|
||||||
|
background-color: var(--bg-blur-color);
|
||||||
|
box-shadow: inset 0 0 0 var(--hairline-width) var(--outline-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media {
|
||||||
|
/* background-color: var(--average-color, var(--bg-faded-color)); */
|
||||||
|
width: var(--width);
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
min-height: var(--min-dimension);
|
||||||
|
/* max-height: min(var(--height), 80vh); */
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
object-fit: scale-down;
|
||||||
|
animation: none;
|
||||||
|
|
||||||
|
&:not([data-loaded='true']) {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-carousel-controls {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-indexer {
|
||||||
|
z-index: 1;
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
color: var(--media-fg-color);
|
||||||
|
background-color: var(--media-bg-color);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 1.5s ease-in-out;
|
||||||
|
border: var(--hairline-width) solid var(--media-outline-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-carousel-button {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-inline: 8px;
|
||||||
|
margin-block: 3em;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.carousel-button {
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ .carousel-button {
|
||||||
|
left: auto;
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.carousel-button {
|
||||||
|
filter: opacity(0);
|
||||||
|
}
|
||||||
|
&:hover .carousel-button {
|
||||||
|
filter: opacity(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:is(:hover, :focus) > & .carousel-indexer {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-carousel-dots {
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
.carousel-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--text-color);
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
opacity: 0.3;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--text-color);
|
||||||
|
transform: scale(1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-first-content {
|
||||||
|
margin-top: 8px;
|
||||||
|
height: 1.75em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.9em;
|
||||||
|
mask-image: linear-gradient(to bottom, black 1.5em, transparent 1.75em);
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
|
||||||
|
@media (min-width: 40em) {
|
||||||
|
margin-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
text-align: center;
|
||||||
|
/* Brute force ellipsis */
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
filter: grayscale(0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(:hover, :focus) > & .media-first-content {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.status:not(.large) .hashtag-stuffing {
|
.status:not(.large) .hashtag-stuffing {
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
transition: opacity 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
@ -1585,16 +1811,16 @@ a:focus-visible .card img {
|
||||||
}
|
}
|
||||||
.card .meta.domain {
|
.card .meta.domain {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: var(--link-color);
|
color: var(--text-insignificant-color);
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
.domain {
|
||||||
overflow: hidden;
|
color: var(--link-color);
|
||||||
display: block;
|
}
|
||||||
}
|
}
|
||||||
.card:visited .meta.domain {
|
.card:visited .meta .domain {
|
||||||
color: var(--link-visited-color);
|
color: var(--link-visited-color);
|
||||||
}
|
}
|
||||||
.card .meta.domain * {
|
.card .meta .domain * {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
a.card {
|
a.card {
|
||||||
|
@ -1695,12 +1921,14 @@ a.card:is(:hover, :focus):visited {
|
||||||
}
|
}
|
||||||
.poll-label input:is([type='radio'], [type='checkbox']) {
|
.poll-label input:is([type='radio'], [type='checkbox']) {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: 3px;
|
margin: 0 3px;
|
||||||
|
min-height: 0.9em;
|
||||||
}
|
}
|
||||||
.poll-option-votes {
|
.poll-option-votes {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.poll-option-leading .poll-option-votes {
|
.poll-option-leading .poll-option-votes {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
@ -1719,6 +1947,7 @@ a.card:is(:hover, :focus):visited {
|
||||||
}
|
}
|
||||||
.poll-option-title {
|
.poll-option-title {
|
||||||
text-shadow: 0 1px var(--bg-color);
|
text-shadow: 0 1px var(--bg-color);
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.poll-option-title .icon {
|
.poll-option-title .icon {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
@ -1753,6 +1982,13 @@ a.card:is(:hover, :focus):visited {
|
||||||
margin-left: calc(-50px - 16px);
|
margin-left: calc(-50px - 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* EMOJI REACTIONS */
|
||||||
|
|
||||||
|
.status.large .emoji-reactions {
|
||||||
|
cursor: default;
|
||||||
|
margin-left: calc(-50px - 16px);
|
||||||
|
}
|
||||||
|
|
||||||
/* ACTIONS */
|
/* ACTIONS */
|
||||||
|
|
||||||
.status .actions {
|
.status .actions {
|
||||||
|
@ -2279,7 +2515,7 @@ a.card:is(:hover, :focus):visited {
|
||||||
mask-image: linear-gradient(to bottom, #000 80px, transparent);
|
mask-image: linear-gradient(to bottom, #000 80px, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:after {
|
&[data-read-more]:after {
|
||||||
content: attr(data-read-more);
|
content: attr(data-read-more);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
@ -20,12 +20,14 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
|
import punycode from 'punycode';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { useLongPress } from 'use-long-press';
|
import { useLongPress } from 'use-long-press';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import AccountBlock from '../components/account-block';
|
import CustomEmoji from '../components/custom-emoji';
|
||||||
import EmojiText from '../components/emoji-text';
|
import EmojiText from '../components/emoji-text';
|
||||||
|
import LazyShazam from '../components/lazy-shazam';
|
||||||
import Loader from '../components/loader';
|
import Loader from '../components/loader';
|
||||||
import Menu2 from '../components/menu2';
|
import Menu2 from '../components/menu2';
|
||||||
import MenuConfirm from '../components/menu-confirm';
|
import MenuConfirm from '../components/menu-confirm';
|
||||||
|
@ -167,15 +169,19 @@ function Status({
|
||||||
allowContextMenu,
|
allowContextMenu,
|
||||||
showActionsBar,
|
showActionsBar,
|
||||||
showReplyParent,
|
showReplyParent,
|
||||||
|
mediaFirst,
|
||||||
}) {
|
}) {
|
||||||
if (skeleton) {
|
if (skeleton) {
|
||||||
return (
|
return (
|
||||||
<div class="status skeleton">
|
<div class={`status skeleton ${mediaFirst ? 'status-media-first' : ''}`}>
|
||||||
<Avatar size="xxl" />
|
{!mediaFirst && <Avatar size="xxl" />}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="meta">███ ████████</div>
|
<div class="meta">
|
||||||
|
{(size === 's' || mediaFirst) && <Avatar size="m" />} ███ ████████
|
||||||
|
</div>
|
||||||
<div class="content-container">
|
<div class="content-container">
|
||||||
<div class="content">
|
{mediaFirst && <div class="media-first-container" />}
|
||||||
|
<div class={`content ${mediaFirst ? 'media-first-content' : ''}`}>
|
||||||
<p>████ ████████</p>
|
<p>████ ████████</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -241,8 +247,14 @@ function Status({
|
||||||
_deleted,
|
_deleted,
|
||||||
_pinned,
|
_pinned,
|
||||||
// _filtered,
|
// _filtered,
|
||||||
|
// Non-Mastodon
|
||||||
|
emojiReactions,
|
||||||
} = status;
|
} = status;
|
||||||
|
|
||||||
|
// if (!mediaAttachments?.length) mediaFirst = false;
|
||||||
|
const hasMediaAttachments = !!mediaAttachments?.length;
|
||||||
|
if (mediaFirst && hasMediaAttachments) size = 's';
|
||||||
|
|
||||||
const currentAccount = useMemo(() => {
|
const currentAccount = useMemo(() => {
|
||||||
return store.session.get('currentAccount');
|
return store.session.get('currentAccount');
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -350,6 +362,7 @@ function Status({
|
||||||
size={size}
|
size={size}
|
||||||
contentTextWeight={contentTextWeight}
|
contentTextWeight={contentTextWeight}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
mediaFirst={mediaFirst}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -374,6 +387,7 @@ function Status({
|
||||||
contentTextWeight={contentTextWeight}
|
contentTextWeight={contentTextWeight}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
enableCommentHint
|
enableCommentHint
|
||||||
|
mediaFirst={mediaFirst}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -407,6 +421,7 @@ function Status({
|
||||||
contentTextWeight={contentTextWeight}
|
contentTextWeight={contentTextWeight}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
enableCommentHint
|
enableCommentHint
|
||||||
|
mediaFirst={mediaFirst}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -724,25 +739,6 @@ function Status({
|
||||||
const isPinnable = ['public', 'unlisted', 'private'].includes(visibility);
|
const isPinnable = ['public', 'unlisted', 'private'].includes(visibility);
|
||||||
const StatusMenuItems = (
|
const StatusMenuItems = (
|
||||||
<>
|
<>
|
||||||
{isSizeLarge && (
|
|
||||||
<>
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
states.showGenericAccounts = {
|
|
||||||
heading: 'Boosted/Liked by…',
|
|
||||||
fetchAccounts: fetchBoostedLikedByAccounts,
|
|
||||||
instance,
|
|
||||||
showReactions: true,
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="react" />
|
|
||||||
<span>
|
|
||||||
Boosted/Liked by<span class="more-insignificant">…</span>
|
|
||||||
</span>
|
|
||||||
</MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!isSizeLarge && sameInstance && (
|
{!isSizeLarge && sameInstance && (
|
||||||
<>
|
<>
|
||||||
<div class="menu-control-group-horizontal status-menu">
|
<div class="menu-control-group-horizontal status-menu">
|
||||||
|
@ -840,56 +836,85 @@ function Status({
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(enableTranslate || !language || differentLanguage) && <MenuDivider />}
|
{!isSizeLarge && sameInstance && (isSizeLarge || showActionsBar) && (
|
||||||
{enableTranslate ? (
|
<MenuDivider />
|
||||||
<div class={supportsTTS ? 'menu-horizontal' : ''}>
|
)}
|
||||||
|
{(isSizeLarge || showActionsBar) && (
|
||||||
|
<>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
disabled={forceTranslate}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setForceTranslate(true);
|
states.showGenericAccounts = {
|
||||||
|
heading: 'Boosted/Liked by…',
|
||||||
|
fetchAccounts: fetchBoostedLikedByAccounts,
|
||||||
|
instance,
|
||||||
|
showReactions: true,
|
||||||
|
postID: sKey,
|
||||||
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="translate" />
|
<Icon icon="react" />
|
||||||
<span>Translate</span>
|
<span>
|
||||||
|
Boosted/Liked by<span class="more-insignificant">…</span>
|
||||||
|
</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{supportsTTS && (
|
</>
|
||||||
<MenuItem
|
)}
|
||||||
onClick={() => {
|
{!mediaFirst && (
|
||||||
const postText = getPostText(status);
|
<>
|
||||||
if (postText) {
|
{(enableTranslate || !language || differentLanguage) && (
|
||||||
speak(postText, language);
|
<MenuDivider />
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="speak" />
|
|
||||||
<span>Speak</span>
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
{enableTranslate ? (
|
||||||
) : (
|
<div class={supportsTTS ? 'menu-horizontal' : ''}>
|
||||||
(!language || differentLanguage) && (
|
|
||||||
<div class={supportsTTS ? 'menu-horizontal' : ''}>
|
|
||||||
<MenuLink
|
|
||||||
to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`}
|
|
||||||
>
|
|
||||||
<Icon icon="translate" />
|
|
||||||
<span>Translate</span>
|
|
||||||
</MenuLink>
|
|
||||||
{supportsTTS && (
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
disabled={forceTranslate}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const postText = getPostText(status);
|
setForceTranslate(true);
|
||||||
if (postText) {
|
|
||||||
speak(postText, language);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="speak" />
|
<Icon icon="translate" />
|
||||||
<span>Speak</span>
|
<span>Translate</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
{supportsTTS && (
|
||||||
</div>
|
<MenuItem
|
||||||
)
|
onClick={() => {
|
||||||
|
const postText = getPostText(status);
|
||||||
|
if (postText) {
|
||||||
|
speak(postText, language);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="speak" />
|
||||||
|
<span>Speak</span>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(!language || differentLanguage) && (
|
||||||
|
<div class={supportsTTS ? 'menu-horizontal' : ''}>
|
||||||
|
<MenuLink
|
||||||
|
to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`}
|
||||||
|
>
|
||||||
|
<Icon icon="translate" />
|
||||||
|
<span>Translate</span>
|
||||||
|
</MenuLink>
|
||||||
|
{supportsTTS && (
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const postText = getPostText(status);
|
||||||
|
if (postText) {
|
||||||
|
speak(postText, language);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="speak" />
|
||||||
|
<span>Speak</span>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{((!isSizeLarge && sameInstance) ||
|
{((!isSizeLarge && sameInstance) ||
|
||||||
enableTranslate ||
|
enableTranslate ||
|
||||||
|
@ -1376,7 +1401,7 @@ function Status({
|
||||||
}[size]
|
}[size]
|
||||||
} ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''} ${
|
} ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''} ${
|
||||||
isContextMenuOpen ? 'status-menu-open' : ''
|
isContextMenuOpen ? 'status-menu-open' : ''
|
||||||
}`}
|
} ${mediaFirst && hasMediaAttachments ? 'status-media-first' : ''}`}
|
||||||
onMouseEnter={debugHover}
|
onMouseEnter={debugHover}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
if (!showContextMenu) return;
|
if (!showContextMenu) return;
|
||||||
|
@ -1586,11 +1611,14 @@ function Status({
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Icon
|
visibility !== 'public' &&
|
||||||
icon={visibilityIconsMap[visibility]}
|
visibility !== 'direct' && (
|
||||||
alt={visibilityText[visibility]}
|
<Icon
|
||||||
size="s"
|
icon={visibilityIconsMap[visibility]}
|
||||||
/>
|
alt={visibilityText[visibility]}
|
||||||
|
size="s"
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}{' '}
|
)}{' '}
|
||||||
<RelativeTime datetime={createdAtDate} format="micro" />
|
<RelativeTime datetime={createdAtDate} format="micro" />
|
||||||
{!previewMode && !readOnly && (
|
{!previewMode && !readOnly && (
|
||||||
|
@ -1641,11 +1669,15 @@ function Status({
|
||||||
// {StatusMenuItems}
|
// {StatusMenuItems}
|
||||||
// </Menu>
|
// </Menu>
|
||||||
<span class="time">
|
<span class="time">
|
||||||
<Icon
|
{visibility !== 'public' && visibility !== 'direct' && (
|
||||||
icon={visibilityIconsMap[visibility]}
|
<>
|
||||||
alt={visibilityText[visibility]}
|
<Icon
|
||||||
size="s"
|
icon={visibilityIconsMap[visibility]}
|
||||||
/>{' '}
|
alt={visibilityText[visibility]}
|
||||||
|
size="s"
|
||||||
|
/>{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<RelativeTime datetime={createdAtDate} format="micro" />
|
<RelativeTime datetime={createdAtDate} format="micro" />
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
@ -1697,188 +1729,253 @@ function Status({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!!spoilerText && (
|
{mediaFirst && hasMediaAttachments ? (
|
||||||
<>
|
<>
|
||||||
<div
|
{(!!spoilerText || !!sensitive) && !readingExpandSpoilers && (
|
||||||
class="content spoiler-content"
|
<>
|
||||||
lang={language}
|
{!!spoilerText && (
|
||||||
dir="auto"
|
<span
|
||||||
ref={spoilerContentRef}
|
class="spoiler-content media-first-spoiler-content"
|
||||||
data-read-more={readMoreText}
|
lang={language}
|
||||||
>
|
dir="auto"
|
||||||
<p>
|
ref={spoilerContentRef}
|
||||||
<EmojiText text={spoilerText} emojis={emojis} />
|
data-read-more={readMoreText}
|
||||||
</p>
|
>
|
||||||
</div>
|
<EmojiText text={spoilerText} emojis={emojis} />{' '}
|
||||||
{readingExpandSpoilers || previewMode ? (
|
</span>
|
||||||
<div class="spoiler-divider">
|
)}
|
||||||
<Icon icon="eye-open" /> Content warning
|
<button
|
||||||
|
class={`light spoiler-button media-first-spoiler-button ${
|
||||||
|
showSpoiler ? 'spoiling' : ''
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (showSpoiler) {
|
||||||
|
delete states.spoilers[id];
|
||||||
|
if (!readingExpandSpoilers) {
|
||||||
|
delete states.spoilersMedia[id];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
states.spoilers[id] = true;
|
||||||
|
if (!readingExpandSpoilers) {
|
||||||
|
states.spoilersMedia[id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
|
||||||
|
{showSpoiler ? 'Show less' : 'Show content'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<MediaFirstContainer
|
||||||
|
mediaAttachments={mediaAttachments}
|
||||||
|
language={language}
|
||||||
|
postID={id}
|
||||||
|
instance={instance}
|
||||||
|
/>
|
||||||
|
{!!content && (
|
||||||
|
<div class="media-first-content content" ref={contentRef}>
|
||||||
|
<PostContent
|
||||||
|
post={status}
|
||||||
|
instance={instance}
|
||||||
|
previewMode={previewMode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
class={`light spoiler-button ${
|
|
||||||
showSpoiler ? 'spoiling' : ''
|
|
||||||
}`}
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (showSpoiler) {
|
|
||||||
delete states.spoilers[id];
|
|
||||||
if (!readingExpandSpoilers) {
|
|
||||||
delete states.spoilersMedia[id];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
states.spoilers[id] = true;
|
|
||||||
if (!readingExpandSpoilers) {
|
|
||||||
states.spoilersMedia[id] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
|
|
||||||
{showSpoiler ? 'Show less' : 'Show content'}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
) : (
|
||||||
{!!content && (
|
<>
|
||||||
<div
|
{!!spoilerText && (
|
||||||
class="content"
|
<>
|
||||||
ref={contentRef}
|
<div
|
||||||
data-read-more={readMoreText}
|
class="content spoiler-content"
|
||||||
>
|
|
||||||
<PostContent
|
|
||||||
post={status}
|
|
||||||
instance={instance}
|
|
||||||
previewMode={previewMode}
|
|
||||||
/>
|
|
||||||
<QuoteStatuses id={id} instance={instance} level={quoted} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!!poll && (
|
|
||||||
<Poll
|
|
||||||
lang={language}
|
|
||||||
poll={poll}
|
|
||||||
readOnly={readOnly || !sameInstance || !authenticated}
|
|
||||||
onUpdate={(newPoll) => {
|
|
||||||
states.statuses[sKey].poll = newPoll;
|
|
||||||
}}
|
|
||||||
refresh={() => {
|
|
||||||
return masto.v1.polls
|
|
||||||
.$select(poll.id)
|
|
||||||
.fetch()
|
|
||||||
.then((pollResponse) => {
|
|
||||||
states.statuses[sKey].poll = pollResponse;
|
|
||||||
})
|
|
||||||
.catch((e) => {}); // Silently fail
|
|
||||||
}}
|
|
||||||
votePoll={(choices) => {
|
|
||||||
return masto.v1.polls
|
|
||||||
.$select(poll.id)
|
|
||||||
.votes.create({
|
|
||||||
choices,
|
|
||||||
})
|
|
||||||
.then((pollResponse) => {
|
|
||||||
states.statuses[sKey].poll = pollResponse;
|
|
||||||
})
|
|
||||||
.catch((e) => {}); // Silently fail
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(((enableTranslate || inlineTranslate) &&
|
|
||||||
!!content.trim() &&
|
|
||||||
!!getHTMLText(emojifyText(content, emojis)) &&
|
|
||||||
differentLanguage) ||
|
|
||||||
forceTranslate) && (
|
|
||||||
<TranslationBlock
|
|
||||||
forceTranslate={forceTranslate || inlineTranslate}
|
|
||||||
mini={!isSizeLarge && !withinContext}
|
|
||||||
sourceLanguage={language}
|
|
||||||
text={getPostText(status)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!previewMode &&
|
|
||||||
sensitive &&
|
|
||||||
!!mediaAttachments.length &&
|
|
||||||
readingExpandMedia !== 'show_all' && (
|
|
||||||
<button
|
|
||||||
class={`plain spoiler-media-button ${
|
|
||||||
showSpoilerMedia ? 'spoiling' : ''
|
|
||||||
}`}
|
|
||||||
type="button"
|
|
||||||
hidden={!readingExpandSpoilers && !!spoilerText}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (showSpoilerMedia) {
|
|
||||||
delete states.spoilersMedia[id];
|
|
||||||
} else {
|
|
||||||
states.spoilersMedia[id] = true;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon={showSpoilerMedia ? 'eye-open' : 'eye-close'} />{' '}
|
|
||||||
{showSpoilerMedia ? 'Show less' : 'Show media'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!!mediaAttachments.length && (
|
|
||||||
<MultipleMediaFigure
|
|
||||||
lang={language}
|
|
||||||
enabled={showMultipleMediaCaptions}
|
|
||||||
captionChildren={captionChildren}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={mediaContainerRef}
|
|
||||||
class={`media-container media-eq${mediaAttachments.length} ${
|
|
||||||
mediaAttachments.length > 2 ? 'media-gt2' : ''
|
|
||||||
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
|
|
||||||
>
|
|
||||||
{displayedMediaAttachments.map((media, i) => (
|
|
||||||
<Media
|
|
||||||
key={media.id}
|
|
||||||
media={media}
|
|
||||||
autoAnimate={isSizeLarge}
|
|
||||||
showCaption={mediaAttachments.length === 1}
|
|
||||||
allowLongerCaption={
|
|
||||||
!content && mediaAttachments.length === 1
|
|
||||||
}
|
|
||||||
lang={language}
|
lang={language}
|
||||||
altIndex={
|
dir="auto"
|
||||||
showMultipleMediaCaptions &&
|
ref={spoilerContentRef}
|
||||||
!!media.description &&
|
data-read-more={readMoreText}
|
||||||
i + 1
|
>
|
||||||
}
|
<p>
|
||||||
to={`/${instance}/s/${id}?${
|
<EmojiText text={spoilerText} emojis={emojis} />
|
||||||
withinContext ? 'media' : 'media-only'
|
</p>
|
||||||
}=${i + 1}`}
|
</div>
|
||||||
onClick={
|
{readingExpandSpoilers || previewMode ? (
|
||||||
onMediaClick
|
<div class="spoiler-divider">
|
||||||
? (e) => {
|
<Icon icon="eye-open" /> Content warning
|
||||||
onMediaClick(e, i, media, status);
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
class={`light spoiler-button ${
|
||||||
|
showSpoiler ? 'spoiling' : ''
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (showSpoiler) {
|
||||||
|
delete states.spoilers[id];
|
||||||
|
if (!readingExpandSpoilers) {
|
||||||
|
delete states.spoilersMedia[id];
|
||||||
}
|
}
|
||||||
: undefined
|
} else {
|
||||||
}
|
states.spoilers[id] = true;
|
||||||
|
if (!readingExpandSpoilers) {
|
||||||
|
states.spoilersMedia[id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
|
||||||
|
{showSpoiler ? 'Show less' : 'Show content'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!!content && (
|
||||||
|
<div
|
||||||
|
class="content"
|
||||||
|
ref={contentRef}
|
||||||
|
data-read-more={readMoreText}
|
||||||
|
>
|
||||||
|
<PostContent
|
||||||
|
post={status}
|
||||||
|
instance={instance}
|
||||||
|
previewMode={previewMode}
|
||||||
/>
|
/>
|
||||||
))}
|
<QuoteStatuses id={id} instance={instance} level={quoted} />
|
||||||
</div>
|
</div>
|
||||||
</MultipleMediaFigure>
|
)}
|
||||||
|
{!!poll && (
|
||||||
|
<Poll
|
||||||
|
lang={language}
|
||||||
|
poll={poll}
|
||||||
|
readOnly={readOnly || !sameInstance || !authenticated}
|
||||||
|
onUpdate={(newPoll) => {
|
||||||
|
states.statuses[sKey].poll = newPoll;
|
||||||
|
}}
|
||||||
|
refresh={() => {
|
||||||
|
return masto.v1.polls
|
||||||
|
.$select(poll.id)
|
||||||
|
.fetch()
|
||||||
|
.then((pollResponse) => {
|
||||||
|
states.statuses[sKey].poll = pollResponse;
|
||||||
|
})
|
||||||
|
.catch((e) => {}); // Silently fail
|
||||||
|
}}
|
||||||
|
votePoll={(choices) => {
|
||||||
|
return masto.v1.polls
|
||||||
|
.$select(poll.id)
|
||||||
|
.votes.create({
|
||||||
|
choices,
|
||||||
|
})
|
||||||
|
.then((pollResponse) => {
|
||||||
|
states.statuses[sKey].poll = pollResponse;
|
||||||
|
})
|
||||||
|
.catch((e) => {}); // Silently fail
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(((enableTranslate || inlineTranslate) &&
|
||||||
|
!!content.trim() &&
|
||||||
|
!!getHTMLText(emojifyText(content, emojis)) &&
|
||||||
|
differentLanguage) ||
|
||||||
|
forceTranslate) && (
|
||||||
|
<TranslationBlock
|
||||||
|
forceTranslate={forceTranslate || inlineTranslate}
|
||||||
|
mini={!isSizeLarge && !withinContext}
|
||||||
|
sourceLanguage={language}
|
||||||
|
text={getPostText(status)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!previewMode &&
|
||||||
|
sensitive &&
|
||||||
|
!!mediaAttachments.length &&
|
||||||
|
readingExpandMedia !== 'show_all' && (
|
||||||
|
<button
|
||||||
|
class={`plain spoiler-media-button ${
|
||||||
|
showSpoilerMedia ? 'spoiling' : ''
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
hidden={!readingExpandSpoilers && !!spoilerText}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (showSpoilerMedia) {
|
||||||
|
delete states.spoilersMedia[id];
|
||||||
|
} else {
|
||||||
|
states.spoilersMedia[id] = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={showSpoilerMedia ? 'eye-open' : 'eye-close'}
|
||||||
|
/>{' '}
|
||||||
|
{showSpoilerMedia ? 'Show less' : 'Show media'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!!mediaAttachments.length && (
|
||||||
|
<MultipleMediaFigure
|
||||||
|
lang={language}
|
||||||
|
enabled={showMultipleMediaCaptions}
|
||||||
|
captionChildren={captionChildren}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={mediaContainerRef}
|
||||||
|
class={`media-container media-eq${
|
||||||
|
mediaAttachments.length
|
||||||
|
} ${mediaAttachments.length > 2 ? 'media-gt2' : ''} ${
|
||||||
|
mediaAttachments.length > 4 ? 'media-gt4' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{displayedMediaAttachments.map((media, i) => (
|
||||||
|
<Media
|
||||||
|
key={media.id}
|
||||||
|
media={media}
|
||||||
|
autoAnimate={isSizeLarge}
|
||||||
|
showCaption={mediaAttachments.length === 1}
|
||||||
|
allowLongerCaption={
|
||||||
|
!content && mediaAttachments.length === 1
|
||||||
|
}
|
||||||
|
lang={language}
|
||||||
|
altIndex={
|
||||||
|
showMultipleMediaCaptions &&
|
||||||
|
!!media.description &&
|
||||||
|
i + 1
|
||||||
|
}
|
||||||
|
to={`/${instance}/s/${id}?${
|
||||||
|
withinContext ? 'media' : 'media-only'
|
||||||
|
}=${i + 1}`}
|
||||||
|
onClick={
|
||||||
|
onMediaClick
|
||||||
|
? (e) => {
|
||||||
|
onMediaClick(e, i, media, status);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</MultipleMediaFigure>
|
||||||
|
)}
|
||||||
|
{!!card &&
|
||||||
|
/^https/i.test(card?.url) &&
|
||||||
|
!sensitive &&
|
||||||
|
!spoilerText &&
|
||||||
|
!poll &&
|
||||||
|
!mediaAttachments.length &&
|
||||||
|
!snapStates.statusQuotes[sKey] && (
|
||||||
|
<Card
|
||||||
|
card={card}
|
||||||
|
selfReferential={
|
||||||
|
card?.url === status.url || card?.url === status.uri
|
||||||
|
}
|
||||||
|
instance={currentInstance}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{!!card &&
|
|
||||||
/^https/i.test(card?.url) &&
|
|
||||||
!sensitive &&
|
|
||||||
!spoilerText &&
|
|
||||||
!poll &&
|
|
||||||
!mediaAttachments.length &&
|
|
||||||
!snapStates.statusQuotes[sKey] && (
|
|
||||||
<Card
|
|
||||||
card={card}
|
|
||||||
selfReferential={
|
|
||||||
card?.url === status.url || card?.url === status.uri
|
|
||||||
}
|
|
||||||
instance={currentInstance}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{!isSizeLarge && showCommentCount && (
|
{!isSizeLarge && showCommentCount && (
|
||||||
<div class="content-comment-hint insignificant">
|
<div class="content-comment-hint insignificant">
|
||||||
|
@ -1925,6 +2022,63 @@ function Status({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!!emojiReactions?.length && (
|
||||||
|
<div class="emoji-reactions">
|
||||||
|
{emojiReactions.map((emojiReaction) => {
|
||||||
|
const { name, count, me, url, staticUrl } = emojiReaction;
|
||||||
|
if (url) {
|
||||||
|
// Some servers return url and staticUrl
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
class={`emoji-reaction tag ${
|
||||||
|
me ? '' : 'insignificant'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CustomEmoji
|
||||||
|
alt={name}
|
||||||
|
url={url}
|
||||||
|
staticUrl={staticUrl}
|
||||||
|
/>{' '}
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isShortCode = /^:.+?:$/.test(name);
|
||||||
|
if (isShortCode) {
|
||||||
|
const emoji = emojis.find(
|
||||||
|
(e) =>
|
||||||
|
e.shortcode ===
|
||||||
|
name.replace(/^:/, '').replace(/:$/, ''),
|
||||||
|
);
|
||||||
|
if (emoji) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
class={`emoji-reaction tag ${
|
||||||
|
me ? '' : 'insignificant'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CustomEmoji
|
||||||
|
alt={name}
|
||||||
|
url={emoji.url}
|
||||||
|
staticUrl={emoji.staticUrl}
|
||||||
|
/>{' '}
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
class={`emoji-reaction tag ${
|
||||||
|
me ? '' : 'insignificant'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{name} {count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div class={`actions ${_deleted ? 'disabled' : ''}`}>
|
<div class={`actions ${_deleted ? 'disabled' : ''}`}>
|
||||||
<div class="action has-count">
|
<div class="action has-count">
|
||||||
<StatusButton
|
<StatusButton
|
||||||
|
@ -2099,6 +2253,101 @@ function MultipleMediaFigure(props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MediaFirstContainer(props) {
|
||||||
|
const { mediaAttachments, language, postID, instance } = props;
|
||||||
|
const moreThanOne = mediaAttachments.length > 1;
|
||||||
|
|
||||||
|
const carouselRef = useRef();
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let handleScroll = () => {
|
||||||
|
const { clientWidth, scrollLeft } = carouselRef.current;
|
||||||
|
const index = Math.round(scrollLeft / clientWidth);
|
||||||
|
setCurrentIndex(index);
|
||||||
|
};
|
||||||
|
if (carouselRef.current) {
|
||||||
|
carouselRef.current.addEventListener('scroll', handleScroll, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (carouselRef.current) {
|
||||||
|
carouselRef.current.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="media-first-container" ref={carouselRef}>
|
||||||
|
{mediaAttachments.map((media, i) => (
|
||||||
|
<div class="media-first-item" key={media.id}>
|
||||||
|
<Media
|
||||||
|
media={media}
|
||||||
|
lang={language}
|
||||||
|
to={`/${instance}/s/${postID}?media-only=${i + 1}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{moreThanOne && (
|
||||||
|
<div class="media-carousel-controls">
|
||||||
|
<div class="carousel-indexer">
|
||||||
|
{currentIndex + 1}/{mediaAttachments.length}
|
||||||
|
</div>
|
||||||
|
<label class="media-carousel-button">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carousel-button"
|
||||||
|
hidden={currentIndex === 0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
carouselRef.current.focus();
|
||||||
|
carouselRef.current.scrollTo({
|
||||||
|
left: carouselRef.current.clientWidth * (currentIndex - 1),
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-left" />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
<label class="media-carousel-button">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carousel-button"
|
||||||
|
hidden={currentIndex === mediaAttachments.length - 1}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
carouselRef.current.focus();
|
||||||
|
carouselRef.current.scrollTo({
|
||||||
|
left: carouselRef.current.clientWidth * (currentIndex + 1),
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-right" />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{moreThanOne && (
|
||||||
|
<div class="media-carousel-dots">
|
||||||
|
{mediaAttachments.map((media, i) => (
|
||||||
|
<span
|
||||||
|
key={media.id}
|
||||||
|
class={`carousel-dot ${i === currentIndex ? 'active' : ''}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Card({ card, selfReferential, instance }) {
|
function Card({ card, selfReferential, instance }) {
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const {
|
const {
|
||||||
|
@ -2177,9 +2426,9 @@ function Card({ card, selfReferential, instance }) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasText && (image || (type === 'photo' && blurhash))) {
|
if (hasText && (image || (type === 'photo' && blurhash))) {
|
||||||
const domain = new URL(url).hostname
|
const domain = punycode.toUnicode(
|
||||||
.replace(/^www\./, '')
|
new URL(url).hostname.replace(/^www\./, '').replace(/\/$/, ''),
|
||||||
.replace(/\/$/, '');
|
);
|
||||||
let blurhashImage;
|
let blurhashImage;
|
||||||
const rgbAverageColor =
|
const rgbAverageColor =
|
||||||
image && blurhash ? getBlurHashAverageColor(blurhash) : null;
|
image && blurhash ? getBlurHashAverageColor(blurhash) : null;
|
||||||
|
@ -2228,8 +2477,14 @@ function Card({ card, selfReferential, instance }) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-container">
|
<div class="meta-container">
|
||||||
<p class="meta domain" dir="auto">
|
<p class="meta domain">
|
||||||
{domain}
|
<span class="domain">{domain}</span>{' '}
|
||||||
|
{!!publishedAt && <>· </>}
|
||||||
|
{!!publishedAt && (
|
||||||
|
<>
|
||||||
|
<RelativeTime datetime={publishedAt} format="micro" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p class="title" dir="auto" title={title}>
|
<p class="title" dir="auto" title={title}>
|
||||||
{title}
|
{title}
|
||||||
|
@ -2289,7 +2544,9 @@ function Card({ card, selfReferential, instance }) {
|
||||||
// );
|
// );
|
||||||
}
|
}
|
||||||
if (hasText && !image) {
|
if (hasText && !image) {
|
||||||
const domain = new URL(url).hostname.replace(/^www\./, '');
|
const domain = punycode.toUnicode(
|
||||||
|
new URL(url).hostname.replace(/^www\./, ''),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={cardStatusURL || url}
|
href={cardStatusURL || url}
|
||||||
|
@ -2301,7 +2558,15 @@ function Card({ card, selfReferential, instance }) {
|
||||||
>
|
>
|
||||||
<div class="meta-container">
|
<div class="meta-container">
|
||||||
<p class="meta domain">
|
<p class="meta domain">
|
||||||
<Icon icon="link" size="s" /> <span>{domain}</span>
|
<span class="domain">
|
||||||
|
<Icon icon="link" size="s" /> <span>{domain}</span>
|
||||||
|
</span>{' '}
|
||||||
|
{!!publishedAt && <>· </>}
|
||||||
|
{!!publishedAt && (
|
||||||
|
<>
|
||||||
|
<RelativeTime datetime={publishedAt} format="micro" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p class="title" title={title}>
|
<p class="title" title={title}>
|
||||||
{title}
|
{title}
|
||||||
|
@ -2804,21 +3069,6 @@ function StatusButton({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDuration(time) {
|
|
||||||
if (!time) return;
|
|
||||||
let hours = Math.floor(time / 3600);
|
|
||||||
let minutes = Math.floor((time % 3600) / 60);
|
|
||||||
let seconds = Math.round(time % 60);
|
|
||||||
|
|
||||||
if (hours === 0) {
|
|
||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
||||||
} else {
|
|
||||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
|
||||||
.toString()
|
|
||||||
.padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nicePostURL(url) {
|
function nicePostURL(url) {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
|
@ -2828,7 +3078,7 @@ function nicePostURL(url) {
|
||||||
const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || [];
|
const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || [];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{host}
|
{punycode.toUnicode(host)}
|
||||||
{username ? (
|
{username ? (
|
||||||
<>
|
<>
|
||||||
/{username}
|
/{username}
|
||||||
|
@ -3068,20 +3318,22 @@ const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
|
||||||
|
|
||||||
return uniqueQuotes.map((q) => {
|
return uniqueQuotes.map((q) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<LazyShazam>
|
||||||
key={q.instance + q.id}
|
<Link
|
||||||
to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
|
key={q.instance + q.id}
|
||||||
class="status-card-link"
|
to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
|
||||||
data-read-more="Read more →"
|
class="status-card-link"
|
||||||
>
|
data-read-more="Read more →"
|
||||||
<Status
|
>
|
||||||
statusID={q.id}
|
<Status
|
||||||
instance={q.instance}
|
statusID={q.id}
|
||||||
size="s"
|
instance={q.instance}
|
||||||
quoted={level + 1}
|
size="s"
|
||||||
enableCommentHint
|
quoted={level + 1}
|
||||||
/>
|
enableCommentHint
|
||||||
</Link>
|
/>
|
||||||
|
</Link>
|
||||||
|
</LazyShazam>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
25
src/components/submenu2.jsx
Normal file
25
src/components/submenu2.jsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { SubMenu } from '@szhsin/react-menu';
|
||||||
|
import { useRef } from 'preact/hooks';
|
||||||
|
|
||||||
|
export default function SubMenu2(props) {
|
||||||
|
const menuRef = useRef();
|
||||||
|
return (
|
||||||
|
<SubMenu
|
||||||
|
{...props}
|
||||||
|
instanceRef={menuRef}
|
||||||
|
// Test fix for bug; submenus not opening on Android
|
||||||
|
itemProps={{
|
||||||
|
onPointerMove: (e) => {
|
||||||
|
if (e.pointerType === 'touch') {
|
||||||
|
menuRef.current?.openMenu?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPointerLeave: (e) => {
|
||||||
|
if (e.pointerType === 'touch') {
|
||||||
|
menuRef.current?.openMenu?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,5 +1,11 @@
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'preact/hooks';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { InView } from 'react-intersection-observer';
|
import { InView } from 'react-intersection-observer';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
@ -9,6 +15,7 @@ import FilterContext from '../utils/filter-context';
|
||||||
import { filteredItems, isFiltered } from '../utils/filters';
|
import { filteredItems, isFiltered } from '../utils/filters';
|
||||||
import states, { statusKey } from '../utils/states';
|
import states, { statusKey } from '../utils/states';
|
||||||
import statusPeek from '../utils/status-peek';
|
import statusPeek from '../utils/status-peek';
|
||||||
|
import { isMediaFirstInstance } from '../utils/store-utils';
|
||||||
import { groupBoosts, groupContext } from '../utils/timeline-utils';
|
import { groupBoosts, groupContext } from '../utils/timeline-utils';
|
||||||
import useInterval from '../utils/useInterval';
|
import useInterval from '../utils/useInterval';
|
||||||
import usePageVisibility from '../utils/usePageVisibility';
|
import usePageVisibility from '../utils/usePageVisibility';
|
||||||
|
@ -51,7 +58,7 @@ function Timeline({
|
||||||
}) {
|
}) {
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('start');
|
||||||
const [showMore, setShowMore] = useState(false);
|
const [showMore, setShowMore] = useState(false);
|
||||||
const [showNew, setShowNew] = useState(false);
|
const [showNew, setShowNew] = useState(false);
|
||||||
const [visible, setVisible] = useState(true);
|
const [visible, setVisible] = useState(true);
|
||||||
|
@ -59,6 +66,8 @@ function Timeline({
|
||||||
|
|
||||||
console.debug('RENDER Timeline', id, refresh);
|
console.debug('RENDER Timeline', id, refresh);
|
||||||
|
|
||||||
|
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
|
||||||
|
|
||||||
const allowGrouping = view !== 'media';
|
const allowGrouping = view !== 'media';
|
||||||
const loadItems = useDebouncedCallback(
|
const loadItems = useDebouncedCallback(
|
||||||
(firstLoad) => {
|
(firstLoad) => {
|
||||||
|
@ -209,17 +218,13 @@ function Timeline({
|
||||||
const showNewPostsIndicator =
|
const showNewPostsIndicator =
|
||||||
items.length > 0 && uiState !== 'loading' && showNew;
|
items.length > 0 && uiState !== 'loading' && showNew;
|
||||||
const handleLoadNewPosts = useCallback(() => {
|
const handleLoadNewPosts = useCallback(() => {
|
||||||
loadItems(true);
|
if (showNewPostsIndicator) loadItems(true);
|
||||||
scrollableRef.current?.scrollTo({
|
scrollableRef.current?.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
}, [loadItems]);
|
}, [loadItems, showNewPostsIndicator]);
|
||||||
const dotRef = useHotkeys('.', () => {
|
const dotRef = useHotkeys('.', handleLoadNewPosts);
|
||||||
if (showNewPostsIndicator) {
|
|
||||||
handleLoadNewPosts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// const {
|
// const {
|
||||||
// scrollDirection,
|
// scrollDirection,
|
||||||
|
@ -359,12 +364,15 @@ function Timeline({
|
||||||
<FilterContext.Provider value={filterContext}>
|
<FilterContext.Provider value={filterContext}>
|
||||||
<div
|
<div
|
||||||
id={`${id}-page`}
|
id={`${id}-page`}
|
||||||
class="deck-container"
|
class={`deck-container ${
|
||||||
|
mediaFirst ? 'deck-container-media-first' : ''
|
||||||
|
}`}
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
scrollableRef.current = node;
|
scrollableRef.current = node;
|
||||||
jRef.current = node;
|
jRef.current = node;
|
||||||
kRef.current = node;
|
kRef.current = node;
|
||||||
oRef.current = node;
|
oRef.current = node;
|
||||||
|
dotRef.current = node;
|
||||||
}}
|
}}
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
>
|
>
|
||||||
|
@ -435,6 +443,7 @@ function Timeline({
|
||||||
view={view}
|
view={view}
|
||||||
showFollowedTags={showFollowedTags}
|
showFollowedTags={showFollowedTags}
|
||||||
showReplyParent={showReplyParent}
|
showReplyParent={showReplyParent}
|
||||||
|
mediaFirst={mediaFirst}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{showMore &&
|
{showMore &&
|
||||||
|
@ -446,14 +455,14 @@ function Timeline({
|
||||||
height: '20vh',
|
height: '20vh',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Status skeleton />
|
<Status skeleton mediaFirst={mediaFirst} />
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
style={{
|
style={{
|
||||||
height: '25vh',
|
height: '25vh',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Status skeleton />
|
<Status skeleton mediaFirst={mediaFirst} />
|
||||||
</li>
|
</li>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
|
@ -493,13 +502,14 @@ function Timeline({
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<li key={i}>
|
<li key={i}>
|
||||||
<Status skeleton />
|
<Status skeleton mediaFirst={mediaFirst} />
|
||||||
</li>
|
</li>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
uiState !== 'error' && <p class="ui-state">{emptyText}</p>
|
uiState !== 'error' &&
|
||||||
|
uiState !== 'start' && <p class="ui-state">{emptyText}</p>
|
||||||
)}
|
)}
|
||||||
{uiState === 'error' && (
|
{uiState === 'error' && (
|
||||||
<p class="ui-state">
|
<p class="ui-state">
|
||||||
|
@ -527,6 +537,7 @@ const TimelineItem = memo(
|
||||||
view,
|
view,
|
||||||
showFollowedTags,
|
showFollowedTags,
|
||||||
showReplyParent,
|
showReplyParent,
|
||||||
|
mediaFirst,
|
||||||
}) => {
|
}) => {
|
||||||
console.debug('RENDER TimelineItem', status.id);
|
console.debug('RENDER TimelineItem', status.id);
|
||||||
const { id: statusID, reblog, items, type, _pinned } = status;
|
const { id: statusID, reblog, items, type, _pinned } = status;
|
||||||
|
@ -535,6 +546,7 @@ const TimelineItem = memo(
|
||||||
const url = instance
|
const url = instance
|
||||||
? `/${instance}/s/${actualStatusID}`
|
? `/${instance}/s/${actualStatusID}`
|
||||||
: `/s/${actualStatusID}`;
|
: `/s/${actualStatusID}`;
|
||||||
|
|
||||||
if (items) {
|
if (items) {
|
||||||
const fItems = filteredItems(items, filterContext);
|
const fItems = filteredItems(items, filterContext);
|
||||||
let title = '';
|
let title = '';
|
||||||
|
@ -587,6 +599,7 @@ const TimelineItem = memo(
|
||||||
contentTextWeight
|
contentTextWeight
|
||||||
enableCommentHint
|
enableCommentHint
|
||||||
// allowFilters={allowFilters}
|
// allowFilters={allowFilters}
|
||||||
|
mediaFirst={mediaFirst}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Status
|
<Status
|
||||||
|
@ -596,6 +609,7 @@ const TimelineItem = memo(
|
||||||
contentTextWeight
|
contentTextWeight
|
||||||
enableCommentHint
|
enableCommentHint
|
||||||
// allowFilters={allowFilters}
|
// allowFilters={allowFilters}
|
||||||
|
mediaFirst={mediaFirst}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -691,6 +705,7 @@ const TimelineItem = memo(
|
||||||
showFollowedTags={showFollowedTags}
|
showFollowedTags={showFollowedTags}
|
||||||
showReplyParent={showReplyParent}
|
showReplyParent={showReplyParent}
|
||||||
// allowFilters={allowFilters}
|
// allowFilters={allowFilters}
|
||||||
|
mediaFirst={mediaFirst}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Status
|
<Status
|
||||||
|
@ -700,6 +715,7 @@ const TimelineItem = memo(
|
||||||
showFollowedTags={showFollowedTags}
|
showFollowedTags={showFollowedTags}
|
||||||
showReplyParent={showReplyParent}
|
showReplyParent={showReplyParent}
|
||||||
// allowFilters={allowFilters}
|
// allowFilters={allowFilters}
|
||||||
|
mediaFirst={mediaFirst}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -10,6 +10,7 @@ import localeCode2Text from '../utils/localeCode2Text';
|
||||||
import pmem from '../utils/pmem';
|
import pmem from '../utils/pmem';
|
||||||
|
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
import LazyShazam from './lazy-shazam';
|
||||||
import Loader from './loader';
|
import Loader from './loader';
|
||||||
|
|
||||||
const { PHANPY_LINGVA_INSTANCES } = import.meta.env;
|
const { PHANPY_LINGVA_INSTANCES } = import.meta.env;
|
||||||
|
@ -142,23 +143,21 @@ function TranslationBlock({
|
||||||
detectedLang !== targetLangText
|
detectedLang !== targetLangText
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<div class="shazam-container">
|
<LazyShazam>
|
||||||
<div class="shazam-container-inner">
|
<div class="status-translation-block-mini">
|
||||||
<div class="status-translation-block-mini">
|
<Icon
|
||||||
<Icon
|
icon="translate"
|
||||||
icon="translate"
|
alt={`Auto-translated from ${sourceLangText}`}
|
||||||
alt={`Auto-translated from ${sourceLangText}`}
|
/>
|
||||||
/>
|
<output
|
||||||
<output
|
lang={targetLang}
|
||||||
lang={targetLang}
|
dir="auto"
|
||||||
dir="auto"
|
title={pronunciationContent || ''}
|
||||||
title={pronunciationContent || ''}
|
>
|
||||||
>
|
{translatedContent}
|
||||||
{translatedContent}
|
</output>
|
||||||
</output>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</LazyShazam>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -3,11 +3,15 @@ import './index.css';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
|
||||||
import { render } from 'preact';
|
import { render } from 'preact';
|
||||||
|
import { lazy } from 'preact/compat';
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
import Compose from './components/compose';
|
import IntlSegmenterSuspense from './components/intl-segmenter-suspense';
|
||||||
|
// import Compose from './components/compose';
|
||||||
import useTitle from './utils/useTitle';
|
import useTitle from './utils/useTitle';
|
||||||
|
|
||||||
|
const Compose = lazy(() => import('./components/compose'));
|
||||||
|
|
||||||
if (window.opener) {
|
if (window.opener) {
|
||||||
console = window.opener.console;
|
console = window.opener.console;
|
||||||
}
|
}
|
||||||
|
@ -57,23 +61,25 @@ function App() {
|
||||||
console.debug('OPEN COMPOSE');
|
console.debug('OPEN COMPOSE');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Compose
|
<IntlSegmenterSuspense>
|
||||||
editStatus={editStatus}
|
<Compose
|
||||||
replyToStatus={replyToStatus}
|
editStatus={editStatus}
|
||||||
draftStatus={draftStatus}
|
replyToStatus={replyToStatus}
|
||||||
standalone
|
draftStatus={draftStatus}
|
||||||
hasOpener={window.opener}
|
standalone
|
||||||
onClose={(results) => {
|
hasOpener={window.opener}
|
||||||
const { newStatus, fn = () => {} } = results || {};
|
onClose={(results) => {
|
||||||
try {
|
const { newStatus, fn = () => {} } = results || {};
|
||||||
if (newStatus) {
|
try {
|
||||||
window.opener.__STATES__.reloadStatusPage++;
|
if (newStatus) {
|
||||||
}
|
window.opener.__STATES__.reloadStatusPage++;
|
||||||
fn();
|
}
|
||||||
setUIState('closed');
|
fn();
|
||||||
} catch (e) {}
|
setUIState('closed');
|
||||||
}}
|
} catch (e) {}
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</IntlSegmenterSuspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,12 @@
|
||||||
|
|
||||||
--blue-color: royalblue;
|
--blue-color: royalblue;
|
||||||
--purple-color: blueviolet;
|
--purple-color: blueviolet;
|
||||||
|
--purple-fg-color: color-mix(
|
||||||
|
in srgb-linear,
|
||||||
|
var(--purple-color) 60%,
|
||||||
|
var(--text-color) 40%
|
||||||
|
);
|
||||||
|
--purple-bg-color: color-mix(in srgb, var(--purple-color) 10%, transparent);
|
||||||
--green-color: darkgreen;
|
--green-color: darkgreen;
|
||||||
--orange-color: darkorange;
|
--orange-color: darkorange;
|
||||||
--orange-light-bg-color: color-mix(
|
--orange-light-bg-color: color-mix(
|
||||||
|
@ -23,7 +29,18 @@
|
||||||
var(--orange-color) 20%,
|
var(--orange-color) 20%,
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
|
--orange-fg-color: color-mix(
|
||||||
|
in srgb-linear,
|
||||||
|
var(--orange-color) 60%,
|
||||||
|
var(--text-color) 40%
|
||||||
|
);
|
||||||
|
--orange-bg-color: color-mix(in srgb, var(--orange-color) 10%, transparent);
|
||||||
--red-color: orangered;
|
--red-color: orangered;
|
||||||
|
--red-text-color: color-mix(
|
||||||
|
in srgb-linear,
|
||||||
|
var(--red-color) 60%,
|
||||||
|
var(--text-color) 40%
|
||||||
|
);
|
||||||
--red-bg-color: color-mix(in lch, var(--red-color) 40%, transparent);
|
--red-bg-color: color-mix(in lch, var(--red-color) 40%, transparent);
|
||||||
--bg-color: #fff;
|
--bg-color: #fff;
|
||||||
--bg-faded-color: #f0f2f5;
|
--bg-faded-color: #f0f2f5;
|
||||||
|
@ -91,6 +108,8 @@
|
||||||
|
|
||||||
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
|
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
|
||||||
--spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
--spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
|
||||||
|
--min-dimension: 88px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-resolution: 2dppx) {
|
@media (min-resolution: 2dppx) {
|
||||||
|
@ -328,6 +347,7 @@ button[hidden] {
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='text'],
|
input[type='text'],
|
||||||
|
input[type='search'],
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
@ -337,6 +357,7 @@ select {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
input[type='text']:focus,
|
input[type='text']:focus,
|
||||||
|
input[type='search']:focus,
|
||||||
textarea:focus,
|
textarea:focus,
|
||||||
select:focus {
|
select:focus {
|
||||||
border-color: var(--outline-color);
|
border-color: var(--outline-color);
|
||||||
|
@ -352,7 +373,7 @@ textarea:disabled {
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-faded-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
:is(input[type='text'], textarea, select).block {
|
:is(input[type='text'], input[type='search'], textarea, select).block {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import './cloak-mode.css';
|
||||||
|
|
||||||
// Polyfill needed for Firefox < 122
|
// Polyfill needed for Firefox < 122
|
||||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
|
||||||
import '@formatjs/intl-segmenter/polyfill';
|
// import '@formatjs/intl-segmenter/polyfill';
|
||||||
import { render } from 'preact';
|
import { render } from 'preact';
|
||||||
import { HashRouter } from 'react-router-dom';
|
import { HashRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
|
import punycode from 'punycode';
|
||||||
import { useParams, useSearchParams } from 'react-router-dom';
|
import { useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
@ -150,7 +151,7 @@ function AccountStatuses() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
let results = [];
|
||||||
if (firstLoad) {
|
if (firstLoad) {
|
||||||
const { value } = await masto.v1.accounts
|
const { value } = await masto.v1.accounts
|
||||||
.$select(id)
|
.$select(id)
|
||||||
|
@ -191,6 +192,26 @@ function AccountStatuses() {
|
||||||
}
|
}
|
||||||
const { value, done } = await accountStatusesIterator.current.next();
|
const { value, done } = await accountStatusesIterator.current.next();
|
||||||
if (value?.length) {
|
if (value?.length) {
|
||||||
|
// Check if value is same as pinned post (results)
|
||||||
|
// If the index for every post is the same, means API might not support pinned posts
|
||||||
|
if (results.length) {
|
||||||
|
let pinnedStatusesIds = [];
|
||||||
|
if (results[0]?.type === 'pinned') {
|
||||||
|
pinnedStatusesIds = results[0].id;
|
||||||
|
} else {
|
||||||
|
pinnedStatusesIds = results
|
||||||
|
.filter((status) => status._pinned)
|
||||||
|
.map((status) => status.id);
|
||||||
|
}
|
||||||
|
const containsAllPinned = pinnedStatusesIds.every((postId) =>
|
||||||
|
value.some((status) => status.id === postId),
|
||||||
|
);
|
||||||
|
if (containsAllPinned) {
|
||||||
|
// Remove pinned posts
|
||||||
|
results = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
results.push(...value);
|
results.push(...value);
|
||||||
|
|
||||||
value.forEach((item) => {
|
value.forEach((item) => {
|
||||||
|
@ -516,7 +537,13 @@ function AccountStatuses() {
|
||||||
>
|
>
|
||||||
<Icon icon="transfer" />{' '}
|
<Icon icon="transfer" />{' '}
|
||||||
<small class="menu-double-lines">
|
<small class="menu-double-lines">
|
||||||
Switch to account's instance (<b>{accountInstance}</b>)
|
Switch to account's instance{' '}
|
||||||
|
{accountInstance ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
(<b>{punycode.toUnicode(accountInstance)}</b>)
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</small>
|
</small>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{!sameCurrentInstance && (
|
{!sameCurrentInstance && (
|
||||||
|
|
|
@ -813,6 +813,10 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
text-decoration-color: transparent;
|
text-decoration-color: transparent;
|
||||||
color: var(--link-text-color);
|
color: var(--link-text-color);
|
||||||
|
|
||||||
|
span {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
|
import punycode from 'punycode';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { uid } from 'uid/single';
|
import { uid } from 'uid/single';
|
||||||
|
@ -191,6 +192,7 @@ function Catchup() {
|
||||||
|
|
||||||
const [posts, setPosts] = useState([]);
|
const [posts, setPosts] = useState([]);
|
||||||
const catchupRangeRef = useRef();
|
const catchupRangeRef = useRef();
|
||||||
|
const catchupLastRef = useRef();
|
||||||
const NS = useMemo(() => getCurrentAccountNS(), []);
|
const NS = useMemo(() => getCurrentAccountNS(), []);
|
||||||
const handleCatchupClick = useCallback(async ({ duration } = {}) => {
|
const handleCatchupClick = useCallback(async ({ duration } = {}) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
@ -925,7 +927,15 @@ function Catchup() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (range < RANGES[RANGES.length - 1].value) {
|
if (range < RANGES[RANGES.length - 1].value) {
|
||||||
const duration = range * 60 * 60 * 1000;
|
let duration;
|
||||||
|
if (
|
||||||
|
range === RANGES[RANGES.length - 1].value &&
|
||||||
|
catchupLastRef.current?.checked
|
||||||
|
) {
|
||||||
|
duration = Date.now() - lastCatchupEndAt;
|
||||||
|
} else {
|
||||||
|
duration = range * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
handleCatchupClick({ duration });
|
handleCatchupClick({ duration });
|
||||||
} else {
|
} else {
|
||||||
handleCatchupClick();
|
handleCatchupClick();
|
||||||
|
@ -935,11 +945,25 @@ function Catchup() {
|
||||||
Catch up
|
Catch up
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{lastCatchupRange && range > lastCatchupRange && (
|
{lastCatchupRange && range > lastCatchupRange ? (
|
||||||
<p class="catchup-info">
|
<p class="catchup-info">
|
||||||
<Icon icon="info" /> Overlaps with your last catch-up
|
<Icon icon="info" /> Overlaps with your last catch-up
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : range === RANGES[RANGES.length - 1].value &&
|
||||||
|
lastCatchupEndAt ? (
|
||||||
|
<p class="catchup-info">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
switch
|
||||||
|
checked
|
||||||
|
ref={catchupLastRef}
|
||||||
|
/>{' '}
|
||||||
|
Until the last catch-up (
|
||||||
|
{dtf.format(new Date(lastCatchupEndAt))})
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<p class="insignificant">
|
<p class="insignificant">
|
||||||
<small>
|
<small>
|
||||||
Note: your instance might only show a maximum of 800 posts in
|
Note: your instance might only show a maximum of 800 posts in
|
||||||
|
@ -956,10 +980,12 @@ function Catchup() {
|
||||||
<Link to={`/catchup?id=${pc.id}`}>
|
<Link to={`/catchup?id=${pc.id}`}>
|
||||||
<Icon icon="history2" />{' '}
|
<Icon icon="history2" />{' '}
|
||||||
<span>
|
<span>
|
||||||
{formatRange(
|
{pc.startAt
|
||||||
new Date(pc.startAt),
|
? dtf.formatRange(
|
||||||
new Date(pc.endAt),
|
new Date(pc.startAt),
|
||||||
)}
|
new Date(pc.endAt),
|
||||||
|
)
|
||||||
|
: `… – ${dtf.format(new Date(pc.endAt))}`}
|
||||||
</span>
|
</span>
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
<span>
|
<span>
|
||||||
|
@ -1011,7 +1037,7 @@ function Catchup() {
|
||||||
{posts.length > 0 && (
|
{posts.length > 0 && (
|
||||||
<p>
|
<p>
|
||||||
<b class="ib">
|
<b class="ib">
|
||||||
{formatRange(
|
{dtf.formatRange(
|
||||||
new Date(posts[0].createdAt),
|
new Date(posts[0].createdAt),
|
||||||
new Date(posts[posts.length - 1].createdAt),
|
new Date(posts[posts.length - 1].createdAt),
|
||||||
)}
|
)}
|
||||||
|
@ -1074,9 +1100,11 @@ function Catchup() {
|
||||||
height,
|
height,
|
||||||
publishedAt,
|
publishedAt,
|
||||||
} = card;
|
} = card;
|
||||||
const domain = new URL(url).hostname
|
const domain = punycode.toUnicode(
|
||||||
.replace(/^www\./, '')
|
new URL(url).hostname
|
||||||
.replace(/\/$/, '');
|
.replace(/^www\./, '')
|
||||||
|
.replace(/\/$/, ''),
|
||||||
|
);
|
||||||
let accentColor;
|
let accentColor;
|
||||||
if (blurhash) {
|
if (blurhash) {
|
||||||
const averageColor = getBlurHashAverageColor(blurhash);
|
const averageColor = getBlurHashAverageColor(blurhash);
|
||||||
|
@ -1132,7 +1160,12 @@ function Catchup() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!!title && (
|
{!!title && (
|
||||||
<h1 class="title" lang={language} dir="auto">
|
<h1
|
||||||
|
class="title"
|
||||||
|
lang={language}
|
||||||
|
dir="auto"
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
)}
|
)}
|
||||||
|
@ -1142,6 +1175,7 @@ function Catchup() {
|
||||||
class="description"
|
class="description"
|
||||||
lang={language}
|
lang={language}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
|
title={description}
|
||||||
>
|
>
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
|
@ -1255,7 +1289,7 @@ function Catchup() {
|
||||||
authors[author].avatarStatic || authors[author].avatar
|
authors[author].avatarStatic || authors[author].avatar
|
||||||
}
|
}
|
||||||
size="xxl"
|
size="xxl"
|
||||||
alt={`${authors[author].displayName} (@${authors[author].username})`}
|
alt={`${authors[author].displayName} (@${authors[author].acct})`}
|
||||||
/>{' '}
|
/>{' '}
|
||||||
<span class="count">{authorCounts[author]}</span>
|
<span class="count">{authorCounts[author]}</span>
|
||||||
<span class="username">{authors[author].username}</span>
|
<span class="username">{authors[author].username}</span>
|
||||||
|
@ -1836,9 +1870,6 @@ const dtf = new Intl.DateTimeFormat(locale, {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: 'numeric',
|
minute: 'numeric',
|
||||||
});
|
});
|
||||||
function formatRange(startDate, endDate) {
|
|
||||||
return dtf.formatRange(startDate, endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function binByTime(data, key, numBins) {
|
function binByTime(data, key, numBins) {
|
||||||
// Extract dates from data objects
|
// Extract dates from data objects
|
||||||
|
|
149
src/pages/filters.css
Normal file
149
src/pages/filters.css
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
#filters-page {
|
||||||
|
.filters-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: var(--hairline-width) solid var(--outline-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#filters-add-edit-modal {
|
||||||
|
.filter-form-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
+ .filter-form-row {
|
||||||
|
margin-top: 16px;
|
||||||
|
border-top: 1px solid var(--outline-color);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding-top: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-block: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form-keywords {
|
||||||
|
margin: 0 -16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form-cols {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.filter-form-col {
|
||||||
|
flex-basis: 160px;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
> *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
> *:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-keywords {
|
||||||
|
--gap: 16px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
|
padding: var(--gap);
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 80px;
|
||||||
|
max-height: 25vh;
|
||||||
|
background-color: var(--bg-faded-blur-color);
|
||||||
|
counter-reset: index;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
|
||||||
|
li {
|
||||||
|
counter-increment: index;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&:not(:only-child):before {
|
||||||
|
content: counter(index);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
flex-basis: 160px;
|
||||||
|
flex-grow: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-keyword-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-grow: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-keywords-footer {
|
||||||
|
padding: 8px 16px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type='submit'] {
|
||||||
|
padding-inline: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
581
src/pages/filters.jsx
Normal file
581
src/pages/filters.jsx
Normal file
|
@ -0,0 +1,581 @@
|
||||||
|
import './filters.css';
|
||||||
|
|
||||||
|
import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import Icon from '../components/icon';
|
||||||
|
import Link from '../components/link';
|
||||||
|
import Loader from '../components/loader';
|
||||||
|
import MenuConfirm from '../components/menu-confirm';
|
||||||
|
import Modal from '../components/modal';
|
||||||
|
import NavMenu from '../components/nav-menu';
|
||||||
|
import RelativeTime from '../components/relative-time';
|
||||||
|
import { api } from '../utils/api';
|
||||||
|
import useInterval from '../utils/useInterval';
|
||||||
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
const FILTER_CONTEXT = ['home', 'public', 'notifications', 'thread', 'account'];
|
||||||
|
const FILTER_CONTEXT_UNIMPLEMENTED = ['notifications', 'thread', 'account'];
|
||||||
|
const FILTER_CONTEXT_LABELS = {
|
||||||
|
home: 'Home and lists',
|
||||||
|
notifications: 'Notifications',
|
||||||
|
public: 'Public timelines',
|
||||||
|
thread: 'Conversations',
|
||||||
|
account: 'Profiles',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXPIRY_DURATIONS = [
|
||||||
|
0, // forever
|
||||||
|
30 * 60, // 30 minutes
|
||||||
|
60 * 60, // 1 hour
|
||||||
|
6 * 60 * 60, // 6 hours
|
||||||
|
12 * 60 * 60, // 12 hours
|
||||||
|
60 * 60 * 24, // 24 hours
|
||||||
|
60 * 60 * 24 * 7, // 7 days
|
||||||
|
60 * 60 * 24 * 30, // 30 days
|
||||||
|
];
|
||||||
|
const EXPIRY_DURATIONS_LABELS = {
|
||||||
|
0: 'Never',
|
||||||
|
1800: '30 minutes',
|
||||||
|
3600: '1 hour',
|
||||||
|
21600: '6 hours',
|
||||||
|
43200: '12 hours',
|
||||||
|
86_400: '24 hours',
|
||||||
|
604_800: '7 days',
|
||||||
|
2_592_000: '30 days',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Filters() {
|
||||||
|
const { masto } = api();
|
||||||
|
useTitle(`Filters`, `/ft`);
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const [showFiltersAddEditModal, setShowFiltersAddEditModal] = useState(false);
|
||||||
|
|
||||||
|
const [reloadCount, reload] = useReducer((c) => c + 1, 0);
|
||||||
|
const [filters, setFilters] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const filters = await masto.v2.filters.list();
|
||||||
|
filters.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
filters.forEach((filter) => {
|
||||||
|
if (filter.keywords?.length) {
|
||||||
|
filter.keywords.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(filters);
|
||||||
|
setFilters(filters);
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [reloadCount]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="filters-page" class="deck-container" tabIndex="-1">
|
||||||
|
<div class="timeline-deck deck">
|
||||||
|
<header>
|
||||||
|
<div class="header-grid">
|
||||||
|
<div class="header-side">
|
||||||
|
<NavMenu />
|
||||||
|
<Link to="/" class="button plain">
|
||||||
|
<Icon icon="home" size="l" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<h1>Filters</h1>
|
||||||
|
<div class="header-side">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain"
|
||||||
|
onClick={() => {
|
||||||
|
setShowFiltersAddEditModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="plus" size="l" alt="New filter" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{filters.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ul class="filters-list">
|
||||||
|
{filters.map((filter) => {
|
||||||
|
const { id, title, expiresAt, keywords } = filter;
|
||||||
|
return (
|
||||||
|
<li key={id}>
|
||||||
|
<div>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{keywords?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{keywords.map((k) => (
|
||||||
|
<>
|
||||||
|
<span class="tag collapsed insignificant">
|
||||||
|
{k.wholeWord ? `“${k.keyword}”` : k.keyword}
|
||||||
|
</span>{' '}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<small class="insignificant">
|
||||||
|
<ExpiryStatus expiresAt={expiresAt} />
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain"
|
||||||
|
onClick={() => {
|
||||||
|
setShowFiltersAddEditModal({
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="pencil" size="l" alt="Edit filter" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{filters.length > 1 && (
|
||||||
|
<footer class="ui-state">
|
||||||
|
<small class="insignificant">
|
||||||
|
{filters.length} filter
|
||||||
|
{filters.length === 1 ? '' : 's'}
|
||||||
|
</small>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : uiState === 'loading' ? (
|
||||||
|
<p class="ui-state">
|
||||||
|
<Loader />
|
||||||
|
</p>
|
||||||
|
) : uiState === 'error' ? (
|
||||||
|
<p class="ui-state">Unable to load filters.</p>
|
||||||
|
) : (
|
||||||
|
<p class="ui-state">No filters yet.</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{!!showFiltersAddEditModal && (
|
||||||
|
<Modal
|
||||||
|
title="Add filter"
|
||||||
|
onClose={() => {
|
||||||
|
setShowFiltersAddEditModal(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiltersAddEdit
|
||||||
|
filter={showFiltersAddEditModal?.filter}
|
||||||
|
onClose={(result) => {
|
||||||
|
if (result.state === 'success') {
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
setShowFiltersAddEditModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _id = 1;
|
||||||
|
const incID = () => _id++;
|
||||||
|
function FiltersAddEdit({ filter, onClose }) {
|
||||||
|
const { masto } = api();
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const editMode = !!filter;
|
||||||
|
const { context, expiresAt, id, keywords, title, filterAction } =
|
||||||
|
filter || {};
|
||||||
|
const hasExpiry = !!expiresAt;
|
||||||
|
const expiresAtDate = hasExpiry && new Date(expiresAt);
|
||||||
|
const [editKeywords, setEditKeywords] = useState(keywords || []);
|
||||||
|
const keywordsRef = useRef();
|
||||||
|
|
||||||
|
// Hacky way of handling removed keywords for both existing and new ones
|
||||||
|
const [removedKeywordIDs, setRemovedKeywordIDs] = useState([]);
|
||||||
|
const [removedKeyword_IDs, setRemovedKeyword_IDs] = useState([]);
|
||||||
|
|
||||||
|
const filteredEditKeywords = editKeywords.filter(
|
||||||
|
(k) =>
|
||||||
|
!removedKeywordIDs.includes(k.id) && !removedKeyword_IDs.includes(k._id),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="sheet" id="filters-add-edit-modal">
|
||||||
|
{!!onClose && (
|
||||||
|
<button type="button" class="sheet-close" onClick={onClose}>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<header>
|
||||||
|
<h2>{editMode ? 'Edit filter' : 'New filter'}</h2>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const title = formData.get('title');
|
||||||
|
const keywordIDs = formData.getAll('keyword_attributes[][id]');
|
||||||
|
const keywordKeywords = formData.getAll(
|
||||||
|
'keyword_attributes[][keyword]',
|
||||||
|
);
|
||||||
|
// const keywordWholeWords = formData.getAll(
|
||||||
|
// 'keyword_attributes[][whole_word]',
|
||||||
|
// );
|
||||||
|
// Not using getAll because it skips the empty checkboxes
|
||||||
|
const keywordWholeWords = [
|
||||||
|
...keywordsRef.current.querySelectorAll(
|
||||||
|
'input[name="keyword_attributes[][whole_word]"]',
|
||||||
|
),
|
||||||
|
].map((i) => i.checked);
|
||||||
|
const keywordsAttributes = keywordKeywords.map((k, i) => ({
|
||||||
|
id: keywordIDs[i] || undefined,
|
||||||
|
keyword: k,
|
||||||
|
wholeWord: keywordWholeWords[i],
|
||||||
|
}));
|
||||||
|
// if (editMode && keywords?.length) {
|
||||||
|
// // Find which one got deleted and add to keywordsAttributes
|
||||||
|
// keywords.forEach((k) => {
|
||||||
|
// if (!keywordsAttributes.find((ka) => ka.id === k.id)) {
|
||||||
|
// keywordsAttributes.push({
|
||||||
|
// ...k,
|
||||||
|
// _destroy: true,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
if (editMode && removedKeywordIDs?.length) {
|
||||||
|
removedKeywordIDs.forEach((id) => {
|
||||||
|
keywordsAttributes.push({
|
||||||
|
id,
|
||||||
|
_destroy: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const context = formData.getAll('context');
|
||||||
|
let expiresIn = formData.get('expires_in');
|
||||||
|
const filterAction = formData.get('filter_action');
|
||||||
|
console.log({
|
||||||
|
title,
|
||||||
|
keywordIDs,
|
||||||
|
keywords: keywordKeywords,
|
||||||
|
wholeWords: keywordWholeWords,
|
||||||
|
keywordsAttributes,
|
||||||
|
context,
|
||||||
|
expiresIn,
|
||||||
|
filterAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
if (!title || !context?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUIState('loading');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
let filterResult;
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
if (expiresIn === '' || expiresIn === null) {
|
||||||
|
// No value
|
||||||
|
// Preserve existing expiry if not specified
|
||||||
|
// Seconds from now to expiresAtDate
|
||||||
|
// Other clients don't do this
|
||||||
|
expiresIn = Math.floor((expiresAtDate - new Date()) / 1000);
|
||||||
|
} else if (expiresIn === '0' || expiresIn === 0) {
|
||||||
|
// 0 = Never
|
||||||
|
expiresIn = null;
|
||||||
|
} else {
|
||||||
|
expiresIn = +expiresIn;
|
||||||
|
}
|
||||||
|
filterResult = await masto.v2.filters.$select(id).update({
|
||||||
|
title,
|
||||||
|
context,
|
||||||
|
expiresIn,
|
||||||
|
keywordsAttributes,
|
||||||
|
filterAction,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
expiresIn = +expiresIn || null;
|
||||||
|
filterResult = await masto.v2.filters.create({
|
||||||
|
title,
|
||||||
|
context,
|
||||||
|
expiresIn,
|
||||||
|
keywordsAttributes,
|
||||||
|
filterAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log({ filterResult });
|
||||||
|
setUIState('default');
|
||||||
|
onClose?.({
|
||||||
|
state: 'success',
|
||||||
|
filter: filterResult,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setUIState('error');
|
||||||
|
alert(
|
||||||
|
editMode
|
||||||
|
? 'Unable to edit filter'
|
||||||
|
: 'Unable to create filter',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="filter-form-row">
|
||||||
|
<label>
|
||||||
|
<b>Title</b>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
defaultValue={title}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
dir="auto"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="filter-form-keywords" ref={keywordsRef}>
|
||||||
|
{filteredEditKeywords.length ? (
|
||||||
|
<ul class="filter-keywords">
|
||||||
|
{filteredEditKeywords.map((k) => {
|
||||||
|
const { id, keyword, wholeWord, _id } = k;
|
||||||
|
return (
|
||||||
|
<li key={`${id}-${_id}`}>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="keyword_attributes[][id]"
|
||||||
|
value={id}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="keyword_attributes[][keyword]"
|
||||||
|
type="text"
|
||||||
|
defaultValue={keyword}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div class="filter-keyword-actions">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
name="keyword_attributes[][whole_word]"
|
||||||
|
type="checkbox"
|
||||||
|
value={id} // Hacky way to map checkbox boolean to the keyword id
|
||||||
|
defaultChecked={wholeWord}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>{' '}
|
||||||
|
Whole word
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light danger small"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
if (id) {
|
||||||
|
removedKeywordIDs.push(id);
|
||||||
|
setRemovedKeywordIDs([...removedKeywordIDs]);
|
||||||
|
} else if (_id) {
|
||||||
|
removedKeyword_IDs.push(_id);
|
||||||
|
setRemovedKeyword_IDs([...removedKeyword_IDs]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div class="filter-keywords">
|
||||||
|
<div class="insignificant">No keywords. Add one.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<footer class="filter-keywords-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light"
|
||||||
|
onClick={() => {
|
||||||
|
setEditKeywords([
|
||||||
|
...editKeywords,
|
||||||
|
{
|
||||||
|
_id: incID(),
|
||||||
|
keyword: '',
|
||||||
|
wholeWord: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setTimeout(() => {
|
||||||
|
// Focus last input
|
||||||
|
const fields =
|
||||||
|
keywordsRef.current.querySelectorAll(
|
||||||
|
'input[type="text"]',
|
||||||
|
);
|
||||||
|
fields[fields.length - 1]?.focus?.();
|
||||||
|
}, 10);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add keyword
|
||||||
|
</button>{' '}
|
||||||
|
{filteredEditKeywords?.length > 1 && (
|
||||||
|
<small class="insignificant">
|
||||||
|
{filteredEditKeywords.length} keyword
|
||||||
|
{filteredEditKeywords.length === 1 ? '' : 's'}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<div class="filter-form-cols">
|
||||||
|
<div class="filter-form-col">
|
||||||
|
<div>
|
||||||
|
<b>Filter from…</b>
|
||||||
|
</div>
|
||||||
|
{FILTER_CONTEXT.map((ctx) => (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class={
|
||||||
|
FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx)
|
||||||
|
? 'insignificant'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="context"
|
||||||
|
value={ctx}
|
||||||
|
defaultChecked={!!context ? context.includes(ctx) : true}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>{' '}
|
||||||
|
{FILTER_CONTEXT_LABELS[ctx]}
|
||||||
|
{FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx) ? '*' : ''}
|
||||||
|
</label>{' '}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p>
|
||||||
|
<small class="insignificant">* Not implemented yet</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="filter-form-col">
|
||||||
|
{editMode && (
|
||||||
|
<>
|
||||||
|
Status:{' '}
|
||||||
|
<b>
|
||||||
|
<ExpiryStatus expiresAt={expiresAt} showNeverExpires />
|
||||||
|
</b>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label for="filters-expires_in">
|
||||||
|
{editMode ? 'Change expiry' : 'Expiry'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="filters-expires_in"
|
||||||
|
name="expires_in"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
defaultValue={editMode ? undefined : 0}
|
||||||
|
>
|
||||||
|
{editMode && <option></option>}
|
||||||
|
{EXPIRY_DURATIONS.map((v) => (
|
||||||
|
<option value={v}>{EXPIRY_DURATIONS_LABELS[v]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Filtered post will be…
|
||||||
|
<br />
|
||||||
|
<label class="ib">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="filter_action"
|
||||||
|
value="warn"
|
||||||
|
defaultChecked={filterAction === 'warn' || !editMode}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>{' '}
|
||||||
|
minimized
|
||||||
|
</label>{' '}
|
||||||
|
<label class="ib">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="filter_action"
|
||||||
|
value="hide"
|
||||||
|
defaultChecked={filterAction === 'hide'}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>{' '}
|
||||||
|
hidden
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="filter-form-footer">
|
||||||
|
<span>
|
||||||
|
<button type="submit" disabled={uiState === 'loading'}>
|
||||||
|
{editMode ? 'Save' : 'Create'}
|
||||||
|
</button>{' '}
|
||||||
|
<Loader abrupt hidden={uiState !== 'loading'} />
|
||||||
|
</span>
|
||||||
|
{editMode && (
|
||||||
|
<MenuConfirm
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
align="end"
|
||||||
|
menuItemClassName="danger"
|
||||||
|
confirmLabel="Delete this filter?"
|
||||||
|
onClick={() => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await masto.v2.filters.$select(id).remove();
|
||||||
|
setUIState('default');
|
||||||
|
onClose?.({
|
||||||
|
state: 'success',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUIState('error');
|
||||||
|
alert('Unable to delete filter.');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light danger"
|
||||||
|
onClick={() => {}}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
>
|
||||||
|
Delete…
|
||||||
|
</button>
|
||||||
|
</MenuConfirm>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpiryStatus({ expiresAt, showNeverExpires }) {
|
||||||
|
const hasExpiry = !!expiresAt;
|
||||||
|
const expiresAtDate = hasExpiry && new Date(expiresAt);
|
||||||
|
const expired = hasExpiry && expiresAtDate <= new Date();
|
||||||
|
|
||||||
|
// If less than a minute left, re-render interval every second, else every minute
|
||||||
|
const [_, rerender] = useReducer((c) => c + 1, 0);
|
||||||
|
useInterval(rerender, expired || 30_000);
|
||||||
|
|
||||||
|
return expired ? (
|
||||||
|
'Expired'
|
||||||
|
) : hasExpiry ? (
|
||||||
|
<>
|
||||||
|
Expiring <RelativeTime datetime={expiresAtDate} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
showNeverExpires && 'Never expires'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Filters;
|
|
@ -10,7 +10,7 @@ import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
function FollowedHashtags() {
|
function FollowedHashtags() {
|
||||||
const { masto, instance } = api();
|
const { masto, instance } = api();
|
||||||
useTitle(`Followed Hashtags`, `/ft`);
|
useTitle(`Followed Hashtags`, `/fh`);
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
|
|
||||||
const [followedHashtags, setFollowedHashtags] = useState([]);
|
const [followedHashtags, setFollowedHashtags] = useState([]);
|
||||||
|
|
|
@ -71,7 +71,8 @@ function Following({ title, path, id, ...props }) {
|
||||||
.next();
|
.next();
|
||||||
let { value } = results;
|
let { value } = results;
|
||||||
console.log('checkForUpdates', latestItem.current, value);
|
console.log('checkForUpdates', latestItem.current, value);
|
||||||
if (value?.length) {
|
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
|
||||||
|
if (value?.length && !valueContainsLatestItem) {
|
||||||
latestItem.current = value[0].id;
|
latestItem.current = value[0].id;
|
||||||
value = dedupeBoosts(value, instance);
|
value = dedupeBoosts(value, instance);
|
||||||
value = filteredItems(value, 'home');
|
value = filteredItems(value, 'home');
|
||||||
|
|
|
@ -109,8 +109,9 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
||||||
})
|
})
|
||||||
.next();
|
.next();
|
||||||
let { value } = results;
|
let { value } = results;
|
||||||
value = filteredItems(value, 'public');
|
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
|
||||||
if (value?.length) {
|
if (value?.length && !valueContainsLatestItem) {
|
||||||
|
value = filteredItems(value, 'public');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import './lists.css';
|
import './lists.css';
|
||||||
|
|
||||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { InView } from 'react-intersection-observer';
|
import { InView } from 'react-intersection-observer';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
@ -12,10 +12,12 @@ import Link from '../components/link';
|
||||||
import ListAddEdit from '../components/list-add-edit';
|
import ListAddEdit from '../components/list-add-edit';
|
||||||
import Menu2 from '../components/menu2';
|
import Menu2 from '../components/menu2';
|
||||||
import MenuConfirm from '../components/menu-confirm';
|
import MenuConfirm from '../components/menu-confirm';
|
||||||
|
import MenuLink from '../components/menu-link';
|
||||||
import Modal from '../components/modal';
|
import Modal from '../components/modal';
|
||||||
import Timeline from '../components/timeline';
|
import Timeline from '../components/timeline';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import { filteredItems } from '../utils/filters';
|
import { filteredItems } from '../utils/filters';
|
||||||
|
import { getList, getLists } from '../utils/lists';
|
||||||
import states, { saveStatus } from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
@ -61,8 +63,9 @@ function List(props) {
|
||||||
since_id: latestItem.current,
|
since_id: latestItem.current,
|
||||||
});
|
});
|
||||||
let { value } = results;
|
let { value } = results;
|
||||||
value = filteredItems(value, 'home');
|
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
|
||||||
if (value?.length) {
|
if (value?.length && !valueContainsLatestItem) {
|
||||||
|
value = filteredItems(value, 'home');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
@ -71,13 +74,18 @@ function List(props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [lists, setLists] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
getLists().then(setLists);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [list, setList] = useState({ title: 'List' });
|
const [list, setList] = useState({ title: 'List' });
|
||||||
// const [title, setTitle] = useState(`List`);
|
// const [title, setTitle] = useState(`List`);
|
||||||
useTitle(list.title, `/l/:id`);
|
useTitle(list.title, `/l/:id`);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await masto.v1.lists.$select(id).fetch();
|
const list = await getList(id);
|
||||||
setList(list);
|
setList(list);
|
||||||
// setTitle(list.title);
|
// setTitle(list.title);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -107,9 +115,32 @@ function List(props) {
|
||||||
showReplyParent
|
showReplyParent
|
||||||
// refresh={reloadCount}
|
// refresh={reloadCount}
|
||||||
headerStart={
|
headerStart={
|
||||||
<Link to="/l" class="button plain">
|
// <Link to="/l" class="button plain">
|
||||||
<Icon icon="list" size="l" />
|
// <Icon icon="list" size="l" />
|
||||||
</Link>
|
// </Link>
|
||||||
|
<Menu2
|
||||||
|
overflow="auto"
|
||||||
|
menuButton={
|
||||||
|
<button type="button" class="plain">
|
||||||
|
<Icon icon="list" size="l" alt="Lists" />
|
||||||
|
<Icon icon="chevron-down" size="s" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuLink to="/l">
|
||||||
|
<span>All Lists</span>
|
||||||
|
</MenuLink>
|
||||||
|
{lists?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<MenuDivider />
|
||||||
|
{lists.map((list) => (
|
||||||
|
<MenuLink key={list.id} to={`/l/${list.id}`}>
|
||||||
|
<span>{list.title}</span>
|
||||||
|
</MenuLink>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu2>
|
||||||
}
|
}
|
||||||
headerEnd={
|
headerEnd={
|
||||||
<Menu2
|
<Menu2
|
||||||
|
|
|
@ -8,11 +8,10 @@ import ListAddEdit from '../components/list-add-edit';
|
||||||
import Loader from '../components/loader';
|
import Loader from '../components/loader';
|
||||||
import Modal from '../components/modal';
|
import Modal from '../components/modal';
|
||||||
import NavMenu from '../components/nav-menu';
|
import NavMenu from '../components/nav-menu';
|
||||||
import { api } from '../utils/api';
|
import { fetchLists } from '../utils/lists';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
function Lists() {
|
function Lists() {
|
||||||
const { masto } = api();
|
|
||||||
useTitle(`Lists`, `/l`);
|
useTitle(`Lists`, `/l`);
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
|
|
||||||
|
@ -22,8 +21,7 @@ function Lists() {
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const lists = await masto.v1.lists.list();
|
const lists = await fetchLists();
|
||||||
lists.sort((a, b) => a.title.localeCompare(b.title));
|
|
||||||
console.log(lists);
|
console.log(lists);
|
||||||
setLists(lists);
|
setLists(lists);
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
|
|
|
@ -95,7 +95,9 @@ function Mentions({ columnMode, ...props }) {
|
||||||
latestConversationItem.current,
|
latestConversationItem.current,
|
||||||
value,
|
value,
|
||||||
);
|
);
|
||||||
if (value?.length) {
|
const valueContainsLatestItem =
|
||||||
|
value[0]?.id === latestConversationItem.current; // since_id might not be supported
|
||||||
|
if (value?.length && !valueContainsLatestItem) {
|
||||||
latestConversationItem.current = value[0].lastStatus.id;
|
latestConversationItem.current = value[0].lastStatus.id;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -198,6 +198,7 @@ function Notifications({ columnMode }) {
|
||||||
|
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
setUIState('error');
|
setUIState('error');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
@ -246,7 +247,6 @@ function Notifications({ columnMode }) {
|
||||||
|
|
||||||
const lastHiddenTime = useRef();
|
const lastHiddenTime = useRef();
|
||||||
usePageVisibility((visible) => {
|
usePageVisibility((visible) => {
|
||||||
let unsub;
|
|
||||||
if (visible) {
|
if (visible) {
|
||||||
const timeDiff = Date.now() - lastHiddenTime.current;
|
const timeDiff = Date.now() - lastHiddenTime.current;
|
||||||
if (!lastHiddenTime.current || timeDiff > 1000 * 3) {
|
if (!lastHiddenTime.current || timeDiff > 1000 * 3) {
|
||||||
|
@ -257,20 +257,16 @@ function Notifications({ columnMode }) {
|
||||||
} else {
|
} else {
|
||||||
lastHiddenTime.current = Date.now();
|
lastHiddenTime.current = Date.now();
|
||||||
}
|
}
|
||||||
unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
|
|
||||||
if (uiState === 'loading') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (v) {
|
|
||||||
loadUpdates();
|
|
||||||
}
|
|
||||||
setShowNew(v);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return () => {
|
|
||||||
unsub?.();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
let unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
|
||||||
|
if (uiState === 'loading') return;
|
||||||
|
if (v) loadUpdates();
|
||||||
|
setShowNew(v);
|
||||||
|
});
|
||||||
|
return () => unsub?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const todayDate = new Date();
|
const todayDate = new Date();
|
||||||
const yesterdayDate = new Date(todayDate - 24 * 60 * 60 * 1000);
|
const yesterdayDate = new Date(todayDate - 24 * 60 * 60 * 1000);
|
||||||
|
@ -417,7 +413,7 @@ function Notifications({ columnMode }) {
|
||||||
{supportsFilteredNotifications && (
|
{supportsFilteredNotifications && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button plain"
|
class="button plain4"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowNotificationsSettings(true);
|
setShowNotificationsSettings(true);
|
||||||
}}
|
}}
|
||||||
|
@ -531,66 +527,72 @@ function Notifications({ columnMode }) {
|
||||||
)}
|
)}
|
||||||
{supportsFilteredNotifications &&
|
{supportsFilteredNotifications &&
|
||||||
notificationsPolicy?.summary?.pendingRequestsCount > 0 && (
|
notificationsPolicy?.summary?.pendingRequestsCount > 0 && (
|
||||||
<div class="filtered-notifications">
|
<div class="shazam-container">
|
||||||
<details
|
<div class="shazam-container-inner">
|
||||||
onToggle={async (e) => {
|
<div class="filtered-notifications">
|
||||||
const { open } = e.target;
|
<details
|
||||||
if (open) {
|
onToggle={async (e) => {
|
||||||
const requests = await fetchNotificationsRequest();
|
const { open } = e.target;
|
||||||
setNotificationsRequests(requests);
|
if (open) {
|
||||||
console.log({ open, requests });
|
const requests = await fetchNotificationsRequest();
|
||||||
}
|
setNotificationsRequests(requests);
|
||||||
}}
|
console.log({ open, requests });
|
||||||
>
|
}
|
||||||
<summary>
|
}}
|
||||||
Filtered notifications from{' '}
|
>
|
||||||
{notificationsPolicy.summary.pendingRequestsCount} people
|
<summary>
|
||||||
</summary>
|
Filtered notifications from{' '}
|
||||||
{!notificationsRequests ? (
|
{notificationsPolicy.summary.pendingRequestsCount} people
|
||||||
<p class="ui-state">
|
</summary>
|
||||||
<Loader abrupt />
|
{!notificationsRequests ? (
|
||||||
</p>
|
<p class="ui-state">
|
||||||
) : (
|
<Loader abrupt />
|
||||||
notificationsRequests?.length > 0 && (
|
</p>
|
||||||
<ul>
|
) : (
|
||||||
{notificationsRequests.map((request) => (
|
notificationsRequests?.length > 0 && (
|
||||||
<li key={request.id}>
|
<ul>
|
||||||
<div class="request-notifcations">
|
{notificationsRequests.map((request) => (
|
||||||
{!request.lastStatus?.id && (
|
<li key={request.id}>
|
||||||
<AccountBlock
|
<div class="request-notifcations">
|
||||||
useAvatarStatic
|
{!request.lastStatus?.id && (
|
||||||
showStats
|
<AccountBlock
|
||||||
account={request.account}
|
useAvatarStatic
|
||||||
/>
|
showStats
|
||||||
)}
|
account={request.account}
|
||||||
{request.lastStatus?.id && (
|
|
||||||
<div class="last-post">
|
|
||||||
<Link
|
|
||||||
class="status-link"
|
|
||||||
to={`/${instance}/s/${request.lastStatus.id}`}
|
|
||||||
>
|
|
||||||
<Status
|
|
||||||
status={request.lastStatus}
|
|
||||||
size="s"
|
|
||||||
readOnly
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
)}
|
||||||
|
{request.lastStatus?.id && (
|
||||||
|
<div class="last-post">
|
||||||
|
<Link
|
||||||
|
class="status-link"
|
||||||
|
to={`/${instance}/s/${request.lastStatus.id}`}
|
||||||
|
>
|
||||||
|
<Status
|
||||||
|
status={request.lastStatus}
|
||||||
|
size="s"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<NotificationRequestModalButton
|
||||||
|
request={request}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<NotificationRequestButtons
|
||||||
<NotificationRequestModalButton request={request} />
|
request={request}
|
||||||
</div>
|
onChange={() => {
|
||||||
<NotificationRequestButtons
|
loadNotifications(true);
|
||||||
request={request}
|
}}
|
||||||
onChange={() => {
|
/>
|
||||||
loadNotifications(true);
|
</li>
|
||||||
}}
|
))}
|
||||||
/>
|
</ul>
|
||||||
</li>
|
)
|
||||||
))}
|
)}
|
||||||
</ul>
|
</details>
|
||||||
)
|
</div>
|
||||||
)}
|
</div>
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div id="mentions-option">
|
<div id="mentions-option">
|
||||||
|
@ -606,7 +608,7 @@ function Notifications({ columnMode }) {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="timeline-header">Today</h2>
|
<h2 class="timeline-header">Today</h2>
|
||||||
{showTodayEmpty && !!snapStates.notifications.length && (
|
{showTodayEmpty && (
|
||||||
<p class="ui-state insignificant">
|
<p class="ui-state insignificant">
|
||||||
{uiState === 'default' ? "You're all caught up." : <>…</>}
|
{uiState === 'default' ? "You're all caught up." : <>…</>}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -63,8 +63,9 @@ function Public({ local, columnMode, ...props }) {
|
||||||
})
|
})
|
||||||
.next();
|
.next();
|
||||||
let { value } = results;
|
let { value } = results;
|
||||||
value = filteredItems(value, 'public');
|
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
|
||||||
if (value?.length) {
|
if (value?.length && !valueContainsLatestItem) {
|
||||||
|
value = filteredItems(value, 'public');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -177,6 +177,7 @@ function Search({ columnMode, ...props }) {
|
||||||
['/', 'Slash'],
|
['/', 'Slash'],
|
||||||
(e) => {
|
(e) => {
|
||||||
searchFormRef.current?.focus?.();
|
searchFormRef.current?.focus?.();
|
||||||
|
searchFormRef.current?.select?.();
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
preventDefault: true,
|
preventDefault: true,
|
||||||
|
|
|
@ -28,6 +28,7 @@ const {
|
||||||
PHANPY_WEBSITE: WEBSITE,
|
PHANPY_WEBSITE: WEBSITE,
|
||||||
PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL,
|
PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL,
|
||||||
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
|
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
|
||||||
|
PHANPY_GIPHY_API_KEY: GIPHY_API_KEY,
|
||||||
} = import.meta.env;
|
} = import.meta.env;
|
||||||
|
|
||||||
function Settings({ onClose }) {
|
function Settings({ onClose }) {
|
||||||
|
@ -433,6 +434,37 @@ function Settings({ onClose }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
{!!GIPHY_API_KEY && authenticated && (
|
||||||
|
<li>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={snapStates.settings.composerGIFPicker}
|
||||||
|
onChange={(e) => {
|
||||||
|
states.settings.composerGIFPicker = e.target.checked;
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
GIF Picker for composer
|
||||||
|
</label>
|
||||||
|
<div class="sub-section insignificant">
|
||||||
|
<small>
|
||||||
|
Note: This feature uses external GIF search service, powered
|
||||||
|
by{' '}
|
||||||
|
<a
|
||||||
|
href="https://developers.giphy.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
GIPHY
|
||||||
|
</a>
|
||||||
|
. G-rated (suitable for viewing by all ages), tracking
|
||||||
|
parameters are stripped, referrer information is omitted
|
||||||
|
from requests, but search queries and IP address information
|
||||||
|
will still reach their servers.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
{!!IMG_ALT_API_URL && authenticated && (
|
{!!IMG_ALT_API_URL && authenticated && (
|
||||||
<li>
|
<li>
|
||||||
<label>
|
<label>
|
||||||
|
@ -690,9 +722,10 @@ function PushNotificationsSection({ onClose }) {
|
||||||
) {
|
) {
|
||||||
setAllowNotifications(true);
|
setAllowNotifications(true);
|
||||||
const { alerts, policy } = backendSubscription;
|
const { alerts, policy } = backendSubscription;
|
||||||
|
console.log('backendSubscription', backendSubscription);
|
||||||
previousPolicyRef.current = policy;
|
previousPolicyRef.current = policy;
|
||||||
const { elements } = pushFormRef.current;
|
const { elements } = pushFormRef.current;
|
||||||
const policyEl = elements.namedItem(policy);
|
const policyEl = elements.namedItem('policy');
|
||||||
if (policyEl) policyEl.value = policy;
|
if (policyEl) policyEl.value = policy;
|
||||||
// alerts is {}, iterate it
|
// alerts is {}, iterate it
|
||||||
Object.keys(alerts).forEach((alert) => {
|
Object.keys(alerts).forEach((alert) => {
|
||||||
|
@ -721,65 +754,68 @@ function PushNotificationsSection({ onClose }) {
|
||||||
<form
|
<form
|
||||||
ref={pushFormRef}
|
ref={pushFormRef}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
const values = Object.fromEntries(new FormData(pushFormRef.current));
|
setTimeout(() => {
|
||||||
const allowNotifications = !!values['policy-allow'];
|
const values = Object.fromEntries(new FormData(pushFormRef.current));
|
||||||
const params = {
|
const allowNotifications = !!values['policy-allow'];
|
||||||
policy: values.policy,
|
const params = {
|
||||||
data: {
|
data: {
|
||||||
alerts: {
|
policy: values.policy,
|
||||||
mention: !!values.mention,
|
alerts: {
|
||||||
favourite: !!values.favourite,
|
mention: !!values.mention,
|
||||||
reblog: !!values.reblog,
|
favourite: !!values.favourite,
|
||||||
follow: !!values.follow,
|
reblog: !!values.reblog,
|
||||||
follow_request: !!values.followRequest,
|
follow: !!values.follow,
|
||||||
poll: !!values.poll,
|
follow_request: !!values.followRequest,
|
||||||
update: !!values.update,
|
poll: !!values.poll,
|
||||||
status: !!values.status,
|
update: !!values.update,
|
||||||
|
status: !!values.status,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
|
||||||
|
|
||||||
let alertsCount = 0;
|
let alertsCount = 0;
|
||||||
// Remove false values from data.alerts
|
// Remove false values from data.alerts
|
||||||
// API defaults to false anyway
|
// API defaults to false anyway
|
||||||
Object.keys(params.data.alerts).forEach((key) => {
|
Object.keys(params.data.alerts).forEach((key) => {
|
||||||
if (!params.data.alerts[key]) {
|
if (!params.data.alerts[key]) {
|
||||||
delete params.data.alerts[key];
|
delete params.data.alerts[key];
|
||||||
} else {
|
} else {
|
||||||
alertsCount++;
|
alertsCount++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const policyChanged = previousPolicyRef.current !== params.policy;
|
const policyChanged =
|
||||||
|
previousPolicyRef.current !== params.data.policy;
|
||||||
|
|
||||||
console.log('PN Form', {
|
console.log('PN Form', {
|
||||||
values,
|
values,
|
||||||
allowNotifications: allowNotifications,
|
allowNotifications: allowNotifications,
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (allowNotifications && alertsCount > 0) {
|
if (allowNotifications && alertsCount > 0) {
|
||||||
if (policyChanged) {
|
if (policyChanged) {
|
||||||
console.debug('Policy changed.');
|
console.debug('Policy changed.');
|
||||||
removeSubscription()
|
removeSubscription()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
updateSubscription(params);
|
updateSubscription(params);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
console.warn(err);
|
||||||
|
alert('Failed to update subscription. Please try again.');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateSubscription(params).catch((err) => {
|
||||||
console.warn(err);
|
console.warn(err);
|
||||||
alert('Failed to update subscription. Please try again.');
|
alert('Failed to update subscription. Please try again.');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
updateSubscription(params).catch((err) => {
|
removeSubscription().catch((err) => {
|
||||||
console.warn(err);
|
console.warn(err);
|
||||||
alert('Failed to update subscription. Please try again.');
|
alert('Failed to remove subscription. Please try again.');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
}, 100);
|
||||||
removeSubscription().catch((err) => {
|
|
||||||
console.warn(err);
|
|
||||||
alert('Failed to remove subscription. Please try again.');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3>Push Notifications (beta)</h3>
|
<h3>Push Notifications (beta)</h3>
|
||||||
|
|
|
@ -12,10 +12,10 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
|
import punycode from 'punycode';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { InView } from 'react-intersection-observer';
|
import { InView } from 'react-intersection-observer';
|
||||||
import { matchPath, useSearchParams } from 'react-router-dom';
|
import { matchPath, useSearchParams } from 'react-router-dom';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import Avatar from '../components/avatar';
|
import Avatar from '../components/avatar';
|
||||||
|
@ -122,7 +122,7 @@ function StatusPage(params) {
|
||||||
}, [showMedia]);
|
}, [showMedia]);
|
||||||
|
|
||||||
const mediaAttachments = mediaStatusID
|
const mediaAttachments = mediaStatusID
|
||||||
? mediaStatus?.mediaAttachments
|
? snapStates.statuses[statusKey(mediaStatusID, instance)]?.mediaAttachments
|
||||||
: heroStatus?.mediaAttachments;
|
: heroStatus?.mediaAttachments;
|
||||||
|
|
||||||
const handleMediaClose = useCallback(() => {
|
const handleMediaClose = useCallback(() => {
|
||||||
|
@ -1208,7 +1208,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
{postInstance ? (
|
{postInstance ? (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
(<b>{postInstance}</b>)
|
(<b>{punycode.toUnicode(postInstance)}</b>)
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
|
|
|
@ -3,6 +3,7 @@ import '../components/links-bar.css';
|
||||||
import { MenuItem } from '@szhsin/react-menu';
|
import { MenuItem } from '@szhsin/react-menu';
|
||||||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||||
import { useMemo, useRef, useState } from 'preact/hooks';
|
import { useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
import punycode from 'punycode';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
@ -161,9 +162,9 @@ function Trending({ columnMode, ...props }) {
|
||||||
url,
|
url,
|
||||||
width,
|
width,
|
||||||
} = link;
|
} = link;
|
||||||
const domain = new URL(url).hostname
|
const domain = punycode.toUnicode(
|
||||||
.replace(/^www\./, '')
|
new URL(url).hostname.replace(/^www\./, '').replace(/\/$/, ''),
|
||||||
.replace(/\/$/, '');
|
);
|
||||||
let accentColor;
|
let accentColor;
|
||||||
if (blurhash) {
|
if (blurhash) {
|
||||||
const averageColor = getBlurHashAverageColor(blurhash);
|
const averageColor = getBlurHashAverageColor(blurhash);
|
||||||
|
@ -217,13 +218,23 @@ function Trending({ columnMode, ...props }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!!title && (
|
{!!title && (
|
||||||
<h1 class="title" lang={language} dir="auto">
|
<h1
|
||||||
|
class="title"
|
||||||
|
lang={language}
|
||||||
|
dir="auto"
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
{!!description && (
|
{!!description && (
|
||||||
<p class="description" lang={language} dir="auto">
|
<p
|
||||||
|
class="description"
|
||||||
|
lang={language}
|
||||||
|
dir="auto"
|
||||||
|
title={description}
|
||||||
|
>
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
14
src/utils/format-duration.jsx
Normal file
14
src/utils/format-duration.jsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
export default function formatDuration(time) {
|
||||||
|
if (!time) return;
|
||||||
|
let hours = Math.floor(time / 3600);
|
||||||
|
let minutes = Math.floor((time % 3600) / 60);
|
||||||
|
let seconds = Math.round(time % 60);
|
||||||
|
|
||||||
|
if (hours === 0) {
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
} else {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ const statusPostRegexes = [
|
||||||
/\/notes\/([^\/]+)/i, // Misskey, Firefish
|
/\/notes\/([^\/]+)/i, // Misskey, Firefish
|
||||||
/^\/(?:notice|objects)\/([a-z0-9-]+)/i, // Pleroma
|
/^\/(?:notice|objects)\/([a-z0-9-]+)/i, // Pleroma
|
||||||
/\/@[^@\/]+@?[^\/]+?\/([^\/]+)/i, // Mastodon
|
/\/@[^@\/]+@?[^\/]+?\/([^\/]+)/i, // Mastodon
|
||||||
|
/^\/p\/[^\/]+\/([^\/]+)/i, // Pixelfed
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getInstanceStatusObject(url) {
|
export function getInstanceStatusObject(url) {
|
||||||
|
|
|
@ -63,11 +63,11 @@ function groupNotifications(notifications) {
|
||||||
mappedNotification.id += `-${id}`;
|
mappedNotification.id += `-${id}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
account._types = [type];
|
if (account) account._types = [type];
|
||||||
let n = (notificationsMap[key] = {
|
let n = (notificationsMap[key] = {
|
||||||
...notification,
|
...notification,
|
||||||
type: virtualType,
|
type: virtualType,
|
||||||
_accounts: [account],
|
_accounts: account ? [account] : [],
|
||||||
});
|
});
|
||||||
cleanNotifications[j++] = n;
|
cleanNotifications[j++] = n;
|
||||||
}
|
}
|
||||||
|
|
114
src/utils/lists.js
Normal file
114
src/utils/lists.js
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import { api } from './api';
|
||||||
|
import pmem from './pmem';
|
||||||
|
import store from './store';
|
||||||
|
|
||||||
|
const FETCH_MAX_AGE = 1000 * 60; // 1 minute
|
||||||
|
const MAX_AGE = 24 * 60 * 60 * 1000; // 1 day
|
||||||
|
|
||||||
|
export const fetchLists = pmem(
|
||||||
|
async () => {
|
||||||
|
const { masto } = api();
|
||||||
|
const lists = await masto.v1.lists.list();
|
||||||
|
lists.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
|
||||||
|
if (lists.length) {
|
||||||
|
setTimeout(() => {
|
||||||
|
// Save to local storage, with saved timestamp
|
||||||
|
store.account.set('lists', {
|
||||||
|
lists,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lists;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAge: FETCH_MAX_AGE,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function getLists() {
|
||||||
|
try {
|
||||||
|
const { lists, updatedAt } = store.account.get('lists') || {};
|
||||||
|
if (!lists?.length) return await fetchLists();
|
||||||
|
if (Date.now() - updatedAt > MAX_AGE) {
|
||||||
|
// Stale-while-revalidate
|
||||||
|
fetchLists();
|
||||||
|
return lists;
|
||||||
|
}
|
||||||
|
return lists;
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchList = pmem(
|
||||||
|
(id) => {
|
||||||
|
const { masto } = api();
|
||||||
|
return masto.v1.lists.$select(id).fetch();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAge: FETCH_MAX_AGE,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function getList(id) {
|
||||||
|
const { lists } = store.account.get('lists') || {};
|
||||||
|
console.log({ lists });
|
||||||
|
if (lists?.length) {
|
||||||
|
const theList = lists.find((l) => l.id === id);
|
||||||
|
if (theList) return theList;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return fetchList(id);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getListTitle(id) {
|
||||||
|
const list = await getList(id);
|
||||||
|
return list?.title || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addListStore(list) {
|
||||||
|
const { lists } = store.account.get('lists') || {};
|
||||||
|
if (lists?.length) {
|
||||||
|
lists.push(list);
|
||||||
|
lists.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
store.account.set('lists', {
|
||||||
|
lists,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateListStore(list) {
|
||||||
|
const { lists } = store.account.get('lists') || {};
|
||||||
|
if (lists?.length) {
|
||||||
|
const index = lists.findIndex((l) => l.id === list.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
lists[index] = list;
|
||||||
|
lists.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
store.account.set('lists', {
|
||||||
|
lists,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteListStore(listID) {
|
||||||
|
const { lists } = store.account.get('lists') || {};
|
||||||
|
if (lists?.length) {
|
||||||
|
const index = lists.findIndex((l) => l.id === listID);
|
||||||
|
if (index !== -1) {
|
||||||
|
lists.splice(index, 1);
|
||||||
|
store.account.set('lists', {
|
||||||
|
lists,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,16 @@
|
||||||
export default function localeCode2Text(code) {
|
import mem from './mem';
|
||||||
|
|
||||||
|
const IntlDN = new Intl.DisplayNames(navigator.languages, {
|
||||||
|
type: 'language',
|
||||||
|
});
|
||||||
|
|
||||||
|
function _localeCode2Text(code) {
|
||||||
try {
|
try {
|
||||||
return new Intl.DisplayNames(navigator.languages, {
|
return IntlDN.of(code);
|
||||||
type: 'language',
|
|
||||||
}).of(code);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default mem(_localeCode2Text);
|
||||||
|
|
16
src/utils/open-osk.jsx
Normal file
16
src/utils/open-osk.jsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
|
||||||
|
|
||||||
|
export default function openOSK() {
|
||||||
|
if (isSafari) {
|
||||||
|
const fauxEl = document.createElement('input');
|
||||||
|
fauxEl.style.position = 'absolute';
|
||||||
|
fauxEl.style.top = '0';
|
||||||
|
fauxEl.style.left = '0';
|
||||||
|
fauxEl.style.opacity = '0';
|
||||||
|
document.body.appendChild(fauxEl);
|
||||||
|
fauxEl.focus();
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(fauxEl);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
|
@ -67,6 +67,7 @@ const states = proxy({
|
||||||
contentTranslationAutoInline: false,
|
contentTranslationAutoInline: false,
|
||||||
shortcutSettingsCloudImportExport: false,
|
shortcutSettingsCloudImportExport: false,
|
||||||
mediaAltGenerator: false,
|
mediaAltGenerator: false,
|
||||||
|
composerGIFPicker: false,
|
||||||
cloakMode: false,
|
cloakMode: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -99,6 +100,8 @@ export function initStates() {
|
||||||
store.account.get('settings-shortcutSettingsCloudImportExport') ?? false;
|
store.account.get('settings-shortcutSettingsCloudImportExport') ?? false;
|
||||||
states.settings.mediaAltGenerator =
|
states.settings.mediaAltGenerator =
|
||||||
store.account.get('settings-mediaAltGenerator') ?? false;
|
store.account.get('settings-mediaAltGenerator') ?? false;
|
||||||
|
states.settings.composerGIFPicker =
|
||||||
|
store.account.get('settings-composerGIFPicker') ?? false;
|
||||||
states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false;
|
states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -140,6 +143,9 @@ subscribe(states, (changes) => {
|
||||||
if (path.join('.') === 'settings.mediaAltGenerator') {
|
if (path.join('.') === 'settings.mediaAltGenerator') {
|
||||||
store.account.set('settings-mediaAltGenerator', !!value);
|
store.account.set('settings-mediaAltGenerator', !!value);
|
||||||
}
|
}
|
||||||
|
if (path.join('.') === 'settings.composerGIFPicker') {
|
||||||
|
store.account.set('settings-composerGIFPicker', !!value);
|
||||||
|
}
|
||||||
if (path?.[0] === 'shortcuts') {
|
if (path?.[0] === 'shortcuts') {
|
||||||
store.account.set('shortcuts', states.shortcuts);
|
store.account.set('shortcuts', states.shortcuts);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import store from './store';
|
||||||
|
|
||||||
export function getAccount(id) {
|
export function getAccount(id) {
|
||||||
const accounts = store.local.getJSON('accounts') || [];
|
const accounts = store.local.getJSON('accounts') || [];
|
||||||
|
if (!id) return accounts[0];
|
||||||
return accounts.find((a) => a.info.id === id) || accounts[0];
|
return accounts.find((a) => a.info.id === id) || accounts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -125,3 +126,8 @@ export function getCurrentInstanceConfiguration() {
|
||||||
const instance = getCurrentInstance();
|
const instance = getCurrentInstance();
|
||||||
return getInstanceConfiguration(instance);
|
return getInstanceConfiguration(instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMediaFirstInstance() {
|
||||||
|
const instance = getCurrentInstance();
|
||||||
|
return /pixelfed/i.test(instance?.version);
|
||||||
|
}
|
||||||
|
|
|
@ -83,15 +83,23 @@ function _unfurlMastodonLink(instance, url) {
|
||||||
limit: 1,
|
limit: 1,
|
||||||
})
|
})
|
||||||
.then((results) => {
|
.then((results) => {
|
||||||
if (results.statuses.length > 0) {
|
const { statuses } = results;
|
||||||
const status = results.statuses[0];
|
if (statuses.length > 0) {
|
||||||
return {
|
// Filter out statuses that has content that contains the URL, in-case-sensitive
|
||||||
status,
|
const theStatuses = statuses.filter(
|
||||||
instance,
|
(status) =>
|
||||||
};
|
!status.content?.toLowerCase().includes(theURL.toLowerCase()),
|
||||||
} else {
|
);
|
||||||
throw new Error('No results');
|
|
||||||
|
if (theStatuses.length === 1) {
|
||||||
|
return {
|
||||||
|
status: theStatuses[0],
|
||||||
|
instance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// If there are multiple statuses, give up, something is wrong
|
||||||
}
|
}
|
||||||
|
throw new Error('No results');
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleFulfill(result) {
|
function handleFulfill(result) {
|
||||||
|
|
|
@ -110,6 +110,7 @@ export default defineConfig({
|
||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
cssCodeSplit: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
treeshake: false,
|
treeshake: false,
|
||||||
input: {
|
input: {
|
||||||
|
@ -117,9 +118,9 @@ export default defineConfig({
|
||||||
compose: resolve(__dirname, 'compose/index.html'),
|
compose: resolve(__dirname, 'compose/index.html'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
manualChunks: {
|
// manualChunks: {
|
||||||
'intl-segmenter-polyfill': ['@formatjs/intl-segmenter/polyfill'],
|
// 'intl-segmenter-polyfill': ['@formatjs/intl-segmenter/polyfill'],
|
||||||
},
|
// },
|
||||||
chunkFileNames: (chunkInfo) => {
|
chunkFileNames: (chunkInfo) => {
|
||||||
const { facadeModuleId } = chunkInfo;
|
const { facadeModuleId } = chunkInfo;
|
||||||
if (facadeModuleId && facadeModuleId.includes('icon')) {
|
if (facadeModuleId && facadeModuleId.includes('icon')) {
|
||||||
|
|
Loading…
Reference in a new issue