Merge pull request #228 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-10-02 23:05:44 +08:00 committed by GitHub
commit d5a3b48f0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
73 changed files with 4864 additions and 1806 deletions

16
.github/workflows/prodtag.yml vendored Normal file
View file

@ -0,0 +1,16 @@
name: Auto-create tag on every push to `production`
on:
push:
branches:
- production
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: production
- run: git tag -a "'{date +%Y.%m.%d}.{git rev-parse --short HEAD}'" $(git rev-parse HEAD)
- run: git push

View file

@ -6,6 +6,10 @@ Phanpy does not collect or process any personal information from its users. The
Phanpy is hosted on [Cloudflare Pages](https://pages.cloudflare.com/) as a static website. Read more about [Cloudflare's privacy policy](https://www.cloudflare.com/privacypolicy/).
## Post translations
Phanpy uses [Lingva Translate](https://github.com/thedaviddelta/lingva-translate) to translate posts.
## Error logging
Phanpy dev site (*dev.phanpy.social*) uses [Rollbar](https://rollbar.com/) to log errors for debugging purposes. Read more about [Rollbar's privacy policy](https://rollbar.com/privacy/). The production site (*phanpy.social*) does not use error logging.

View file

@ -93,7 +93,7 @@ Everything is designed and engineered following my taste and vision. This is a p
- Content can be partially revealed by hovering over the post, with tooltip showing the post text.
- Clicking it will open the Post page.
- Long-pressing or right-clicking it will "peek" the post with a bottom sheet UI.
- On boosts carousel, they are not partially hidden, but sorted to the end of the carousel.
- On boosts carousel, they are sorted to the end of the carousel.
## Development
@ -109,7 +109,7 @@ Prerequisites: Node.js 18+
## Self-hosting
This is a **pure static web app**. You can host it anywhere you want. Build it by running `npm run build` and serve the `dist` folder.
This is a **pure static web app**. You can host it anywhere you want. Build it by running `npm run build` (after `npm install`) and serve the `dist` folder.
Try search for "how to self-host static sites" as there are many ways to do it.
@ -125,6 +125,14 @@ Try search for "how to self-host static sites" as there are many ways to do it.
Some of these may change in the future. The front-end world is ever-changing.
## Costs
Costs involved in running and developing this web app:
- Domain name (.social): **USD$23.18/year** (USD$6.87 1st year)
- Hosting: Free
- Development, design, maintenance: "Free" (My precious time)
## Mascot
[Phanpy](https://bulbapedia.bulbagarden.net/wiki/Phanpy_(Pok%C3%A9mon)) is a Ground-type Pokémon.
@ -147,14 +155,14 @@ And here I am. Building a Mastodon web client.
## Alternative web clients
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/))
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - forks ↓
- [Semaphore](https://semaphore.social/)
- [Enafore](https://pinafore.easrng.net/)
- [Enafore](https://enafore.social/)
- [Cuckoo+](https://www.cuckoo.social/)
- [Sengi](https://nicolasconstant.github.io/sengi/)
- [Soapbox](https://fe.soapbox.pub/)
- [Elk](https://elk.zone/)
- Fork https://elk.fedified.com/
- [Elk](https://elk.zone/) - forks ↓
- [elk.fedified.com](https://elk.fedified.com/)
- [Mastodeck](https://mastodeck.com/)
- [Trunks](https://trunks.social/)
- [Tooty](https://github.com/n1k0/tooty)

Binary file not shown.

945
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,24 +10,24 @@
"sourcemap": "npx source-map-explorer dist/assets/*.js"
},
"dependencies": {
"@formatjs/intl-localematcher": "~0.4.0",
"@formatjs/intl-localematcher": "~0.4.2",
"@github/text-expander-element": "~2.5.0",
"@iconify-icons/mingcute": "~1.2.7",
"@iconify-icons/mingcute": "~1.2.8",
"@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~4.0.3",
"@uidotdev/usehooks": "~2.1.0",
"dayjs": "~1.11.9",
"@uidotdev/usehooks": "~2.3.1",
"dayjs": "~1.11.10",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
"fast-deep-equal": "~3.1.3",
"idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0",
"lz-string": "^1.5.0",
"lz-string": "~1.5.0",
"masto": "~5.11.4",
"mem": "~9.0.2",
"p-retry": "~5.1.2",
"p-retry": "~6.1.0",
"p-throttle": "~5.1.0",
"preact": "~10.17.1",
"preact": "~10.18.1",
"react-hotkeys-hook": "~4.4.1",
"react-intersection-observer": "~9.5.2",
"react-quick-pinch-zoom": "~4.9.0",
@ -44,14 +44,14 @@
"devDependencies": {
"@preact/preset-vite": "~2.5.0",
"@trivago/prettier-plugin-sort-imports": "~4.2.0",
"postcss": "~8.4.28",
"postcss": "~8.4.31",
"postcss-dark-theme-class": "~1.0.0",
"postcss-preset-env": "~9.1.1",
"postcss-preset-env": "~9.1.4",
"twitter-text": "~3.1.0",
"vite": "~4.4.9",
"vite-plugin-generate-file": "~0.0.4",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.16.4",
"vite-plugin-pwa": "~0.16.5",
"vite-plugin-remove-console": "~2.1.1",
"workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.0.0",

BIN
public/logo-badge-72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -9,6 +9,25 @@ import {
self.__WB_DISABLE_DEV_LOGS = true;
const assetsRoute = new Route(
({ request, sameOrigin }) => {
const isAsset =
request.destination === 'style' || request.destination === 'script';
const hasHash = /-[0-9a-f]{4,}\./i.test(request.url);
return sameOrigin && isAsset && hasHash;
},
new NetworkFirst({
cacheName: 'assets',
networkTimeoutSeconds: 5,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
}),
);
registerRoute(assetsRoute);
const imageRoute = new Route(
({ request, sameOrigin }) => {
const isRemote = !sameOrigin;
@ -94,3 +113,88 @@ const apiRoute = new RegExpRoute(
}),
);
registerRoute(apiRoute);
// PUSH NOTIFICATIONS
// ==================
self.addEventListener('push', (event) => {
const { data } = event;
if (data) {
const payload = data.json();
console.log('PUSH payload', payload);
const {
access_token,
title,
body,
icon,
notification_id,
notification_type,
preferred_locale,
} = payload;
if (!!navigator.setAppBadge) {
if (notification_type === 'mention') {
navigator.setAppBadge(1);
}
}
event.waitUntil(
self.registration.showNotification(title, {
body,
icon,
dir: 'auto',
badge: '/logo-badge-72.png',
lang: preferred_locale,
tag: notification_id,
timestamp: Date.now(),
data: {
access_token,
notification_type,
},
}),
);
}
});
self.addEventListener('notificationclick', (event) => {
const payload = event.notification;
console.log('NOTIFICATION CLICK payload', payload);
const { badge, body, data, dir, icon, lang, tag, timestamp, title } = payload;
const { access_token, notification_type } = data;
const url = `/#/notifications?id=${tag}&access_token=${btoa(access_token)}`;
event.waitUntil(
(async () => {
const clients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
console.log('NOTIFICATION CLICK clients 1', clients);
if (clients.length && 'navigate' in clients[0]) {
console.log('NOTIFICATION CLICK clients 2', clients);
const bestClient =
clients.find(
(client) => client.focused || client.visibilityState === 'visible',
) || clients[0];
console.log('NOTIFICATION CLICK navigate', url);
if (bestClient) {
console.log('NOTIFICATION CLICK postMessage', bestClient);
bestClient.postMessage?.({
type: 'notification',
id: tag,
accessToken: access_token,
});
bestClient.focus();
} else {
console.log('NOTIFICATION CLICK openWindow', url);
await self.clients.openWindow(url);
}
// }
} else {
console.log('NOTIFICATION CLICK openWindow', url);
await self.clients.openWindow(url);
}
await event.notification.close();
})(),
);
});

View file

@ -747,6 +747,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
transparent 150%
);
position: relative;
container-type: inline-size;
}
.status-carousel:after {
content: '';
@ -787,6 +788,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
gap: 16px;
align-items: flex-start;
counter-reset: index;
min-height: 160px;
}
.status-carousel > ul > li {
scroll-snap-align: center;
@ -806,7 +808,18 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.status-carousel > ul > li:is(:empty, :has(> a:empty)) {
display: none;
}
@media (hover: hover) or (pointer: fine) or (min-width: 40em) {
/*
Assume that browsers that do support inline-size property also support container queries.
https://www.smashingmagazine.com/2021/05/css-container-queries-use-cases-migration-strategies/#progressive-enhancement-polyfills
*/
@supports not (contain: inline-size) {
@media (hover: hover) or (pointer: fine) or (min-width: 40em) {
.status-carousel > ul {
scroll-snap-type: none;
}
}
}
@container (min-width: 640px) {
.status-carousel > ul {
scroll-snap-type: none;
}
@ -1074,6 +1087,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
top: env(safe-area-inset-top, 0);
}
:is(.carousel-top-controls, .carousel-controls) {
mix-blend-mode: luminosity;
position: absolute;
left: 0;
left: env(safe-area-inset-left, 0);
@ -1410,6 +1424,10 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
display: inline-block;
margin: 4px;
align-self: center;
&.clickable {
cursor: pointer;
}
}
.tag .icon {
vertical-align: middle;
@ -1725,25 +1743,22 @@ meter.donut[hidden] {
:is(.shiny-pill, :root .toastify.shiny-pill) {
pointer-events: auto;
color: var(--button-text-color);
text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color);
background-color: var(--button-bg-color);
background-image: linear-gradient(
160deg,
rgba(255, 255, 255, 0.5),
rgba(0, 0, 0, 0.1)
);
box-shadow: 0 3px 8px -1px var(--drop-shadow-color),
0 10px 36px -4px var(--button-bg-blur-color),
inset var(--hairline-width) var(--hairline-width) rgba(255, 255, 255, 0.5);
transition: filter 0.3s;
color: var(--link-text-color);
font-weight: 500;
text-shadow: 0 1px var(--bg-color);
background-color: var(--bg-color);
border: 1px solid var(--outline-color);
box-shadow: 0 3px 16px var(--drop-shadow-color),
0 6px 16px -3px var(--drop-shadow-color);
}
:is(.shiny-pill, :root .toastify.shiny-pill):hover {
filter: brightness(1.2);
}
:is(.shiny-pill, :root .toastify.shiny-pill):active {
transition: none;
filter: brightness(0.9);
:is(.shiny-pill, :root .toastify.shiny-pill):hover:not(:active) {
color: var(--text-color);
border-color: var(--link-color);
filter: none !important;
box-shadow: 0 0 0 1px var(--link-text-color),
0 3px 16px var(--drop-shadow-color),
0 6px 16px -3px var(--drop-shadow-color),
0 6px 16px var(--drop-shadow-color);
}
/* TOAST */
@ -1755,9 +1770,10 @@ meter.donut[hidden] {
pointer-events: none;
color: var(--button-text-color);
text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color);
background-color: var(--button-bg-blur-color);
background-color: var(--button-bg-color);
background-image: none;
backdrop-filter: blur(16px);
box-shadow: 0 3px 8px -1px var(--drop-shadow-color),
0 10px 36px -4px var(--button-bg-blur-color);
}
.toastify-bottom {
margin-bottom: env(safe-area-inset-bottom);
@ -2181,6 +2197,9 @@ ul.link-list li a .icon {
.timeline-deck > header[hidden] {
transform: translate3d(0, calc((100% + var(--margin-top)) * -1), 0);
}
.deck > header {
text-shadow: 0 1px var(--bg-color);
}
.deck > header h1 {
font-size: 1.5em;
}
@ -2214,15 +2233,32 @@ ul.link-list li a .icon {
transition: var(--back-transition);
transform: translate3d(-2.5vw, 0, 0);
}
.timeline:not(.flat)
> li.timeline-item-container:has(.status-link.is-active) {
border-top-left-radius: var(--item-radius);
border-bottom-left-radius: var(--item-radius);
}
.timeline:not(.flat)
> li:not(:has(.status-carousel)):has(+ li .status-link.is-active),
.timeline:not(.flat) > li.timeline-item-container:has(.status-link.is-active),
.timeline:not(.flat)
> li:not(:has(.status-carousel)):has(.status-link.is-active)
+ li {
transition: var(--back-transition);
transform: translate3d(-1.25vw, 0, 0);
}
.timeline:not(.flat)
> li.timeline-item-container:not(:has(.status-carousel)):has(
+ li .status-link.is-active
) {
border-top-left-radius: var(--item-radius);
}
.timeline:not(.flat)
> li.timeline-item-container:not(:has(.status-carousel)):has(
.status-link.is-active
)
+ li.timeline-item-container {
border-bottom-left-radius: var(--item-radius);
}
.box {
padding: 32px;
}

View file

@ -7,36 +7,28 @@ import {
useRef,
useState,
} from 'preact/hooks';
import {
matchPath,
Route,
Routes,
useLocation,
useNavigate,
useParams,
} from 'react-router-dom';
import { matchPath, Route, Routes, useLocation } from 'react-router-dom';
import 'swiped-events';
import { useSnapshot } from 'valtio';
import AccountSheet from './components/account-sheet';
import Compose from './components/compose';
import Drafts from './components/drafts';
import Icon, { ICONS } from './components/icon';
import BackgroundService from './components/background-service';
import ComposeButton from './components/compose-button';
import { ICONS } from './components/icon';
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
import Loader from './components/loader';
import MediaModal from './components/media-modal';
import Modal from './components/modal';
import Modals from './components/modals';
import NotificationService from './components/notification-service';
import SearchCommand from './components/search-command';
import Shortcuts from './components/shortcuts';
import ShortcutsSettings from './components/shortcuts-settings';
import NotFound from './pages/404';
import AccountStatuses from './pages/account-statuses';
import Accounts from './pages/accounts';
import Bookmarks from './pages/bookmarks';
import Favourites from './pages/favourites';
import FollowedHashtags from './pages/followed-hashtags';
import Following from './pages/following';
import Hashtag from './pages/hashtag';
import Home from './pages/home';
import HttpRoute from './pages/HttpRoute';
import HttpRoute from './pages/http-route';
import List from './pages/list';
import Lists from './pages/lists';
import Login from './pages/login';
@ -44,8 +36,7 @@ import Mentions from './pages/mentions';
import Notifications from './pages/notifications';
import Public from './pages/public';
import Search from './pages/search';
import Settings from './pages/settings';
import Status from './pages/status';
import StatusRoute from './pages/status-route';
import Trending from './pages/trending';
import Welcome from './pages/welcome';
import {
@ -56,13 +47,11 @@ import {
initPreferences,
} from './utils/api';
import { getAccessToken } from './utils/auth';
import openCompose from './utils/open-compose';
import showToast from './utils/show-toast';
import states, { initStates, saveStatus } from './utils/states';
import focusDeck from './utils/focus-deck';
import states, { initStates } from './utils/states';
import store from './utils/store';
import { getCurrentAccount } from './utils/store-utils';
import useInterval from './utils/useInterval';
import usePageVisibility from './utils/usePageVisibility';
import './utils/toast-alert';
window.__STATES__ = states;
@ -83,7 +72,6 @@ function App() {
const snapStates = useSnapshot(states);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [uiState, setUIState] = useState('loading');
const navigate = useNavigate();
useLayoutEffect(() => {
const theme = store.local.get('theme');
@ -115,6 +103,7 @@ function App() {
const clientID = store.session.get('clientID');
const clientSecret = store.session.get('clientSecret');
const vapidKey = store.session.get('vapidKey');
(async () => {
setUIState('loading');
@ -128,7 +117,7 @@ function App() {
const masto = initClient({ instance: instanceURL, accessToken });
await Promise.allSettled([
initInstance(masto, instanceURL),
initAccount(masto, instanceURL, accessToken),
initAccount(masto, instanceURL, accessToken, vapidKey),
]);
initStates();
initPreferences(masto);
@ -162,44 +151,16 @@ function App() {
let location = useLocation();
states.currentLocation = location.pathname;
const focusDeck = () => {
let timer = setTimeout(() => {
const columns = document.getElementById('columns');
if (columns) {
// Focus first column
// columns.querySelector('.deck-container')?.focus?.();
} else {
const backDrop = document.querySelector('.deck-backdrop');
if (backDrop) return;
// Focus last deck
const pages = document.querySelectorAll('.deck-container');
const page = pages[pages.length - 1]; // last one
if (page && page.tabIndex === -1) {
console.log('FOCUS', page);
page.focus();
}
}
}, 100);
return () => clearTimeout(timer);
};
useEffect(focusDeck, [location]);
const showModal =
snapStates.showCompose ||
snapStates.showSettings ||
snapStates.showAccounts ||
snapStates.showAccount ||
snapStates.showDrafts ||
snapStates.showMediaModal ||
snapStates.showShortcutsSettings;
useEffect(() => {
if (!showModal) focusDeck();
}, [showModal]);
useEffect(focusDeck, [location, isLoggedIn]);
const { prevLocation } = snapStates;
const prevLocation = snapStates.prevLocation;
const backgroundLocation = useRef(prevLocation || null);
const isModalPage =
matchPath('/:instance/s/:id', location.pathname) ||
matchPath('/s/:id', location.pathname);
const isModalPage = useMemo(() => {
return (
matchPath('/:instance/s/:id', location.pathname) ||
matchPath('/s/:id', location.pathname)
);
}, [location.pathname, matchPath]);
if (isModalPage) {
if (!backgroundLocation.current) backgroundLocation.current = prevLocation;
} else {
@ -223,9 +184,11 @@ function App() {
useEffect(() => {
const $app = document.getElementById('app');
if ($app) {
$app.dataset.shortcutsViewMode = snapStates.settings.shortcutsViewMode;
$app.dataset.shortcutsViewMode = snapStates.shortcuts?.length
? snapStates.settings.shortcutsViewMode
: '';
}
}, [snapStates.settings.shortcutsViewMode]);
}, [snapStates.shortcuts, snapStates.settings.shortcutsViewMode]);
// Add/Remove cloak class to body
useEffect(() => {
@ -281,266 +244,19 @@ function App() {
<Route path="/:instance?/s/:id" element={<StatusRoute />} />
</Routes>
)}
{isLoggedIn && (
<button
type="button"
id="compose-button"
onClick={(e) => {
if (e.shiftKey) {
const newWin = openCompose();
if (!newWin) {
alert('Looks like your browser is blocking popups.');
states.showCompose = true;
}
} else {
states.showCompose = true;
}
}}
>
<Icon icon="quill" size="xl" alt="Compose" />
</button>
)}
{isLoggedIn && <ComposeButton />}
{isLoggedIn &&
!snapStates.settings.shortcutsColumnsMode &&
snapStates.settings.shortcutsViewMode !== 'multi-column' && (
<Shortcuts />
)}
{!!snapStates.showCompose && (
<Modal>
<Compose
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? 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 } = results || {};
states.showCompose = false;
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
showToast({
text: 'Post published. Check it out.',
delay: 1000,
duration: 10_000, // 10 seconds
onClick: (toast) => {
toast.hideToast();
states.prevLocation = location;
navigate(
instance
? `/${instance}/s/${newStatus.id}`
: `/s/${newStatus.id}`,
);
},
});
}
}}
/>
</Modal>
)}
{!!snapStates.showSettings && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showSettings = false;
}
}}
>
<Settings
onClose={() => {
states.showSettings = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccounts && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showAccounts = false;
}
}}
>
<Accounts
onClose={() => {
states.showAccounts = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccount && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showAccount = false;
}
}}
>
<AccountSheet
account={snapStates.showAccount?.account || snapStates.showAccount}
instance={snapStates.showAccount?.instance}
onClose={({ destination } = {}) => {
states.showAccount = false;
if (destination) {
states.showAccounts = false;
}
}}
/>
</Modal>
)}
{!!snapStates.showDrafts && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showDrafts = false;
}
}}
>
<Drafts onClose={() => (states.showDrafts = false)} />
</Modal>
)}
{!!snapStates.showMediaModal && (
<Modal
onClick={(e) => {
if (
e.target === e.currentTarget ||
e.target.classList.contains('media')
) {
states.showMediaModal = false;
}
}}
>
<MediaModal
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
instance={snapStates.showMediaModal.instance}
index={snapStates.showMediaModal.index}
statusID={snapStates.showMediaModal.statusID}
onClose={() => {
states.showMediaModal = false;
}}
/>
</Modal>
)}
{!!snapStates.showShortcutsSettings && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showShortcutsSettings = false;
}
}}
>
<ShortcutsSettings
onClose={() => (states.showShortcutsSettings = false)}
/>
</Modal>
)}
<Modals />
<NotificationService />
<BackgroundService isLoggedIn={isLoggedIn} />
<SearchCommand onClose={focusDeck} />
<KeyboardShortcutsHelp />
</>
);
}
function BackgroundService({ isLoggedIn }) {
// Notifications service
// - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true);
usePageVisibility(setVisible);
const notificationStream = useRef();
useEffect(() => {
if (isLoggedIn && visible) {
const { masto, instance } = api();
(async () => {
// 1. Get the latest notification
if (states.notificationsLast) {
const notificationsIterator = masto.v1.notifications.list({
limit: 1,
since_id: states.notificationsLast.id,
});
const { value: notifications } = await notificationsIterator.next();
if (notifications?.length) {
states.notificationsShowNew = true;
}
}
// 2. Start streaming
notificationStream.current = await masto.ws.stream(
'/api/v1/streaming',
{
stream: 'user:notification',
},
);
console.log('🎏 Streaming notification', notificationStream.current);
notificationStream.current.on('notification', (notification) => {
console.log('🔔🔔 Notification', notification);
if (notification.status) {
saveStatus(notification.status, instance, {
skipThreading: true,
});
}
states.notificationsShowNew = true;
});
notificationStream.current.ws.onclose = () => {
console.log('🔔🔔 Notification stream closed');
};
})();
}
return () => {
if (notificationStream.current) {
notificationStream.current.ws.close();
notificationStream.current = null;
}
};
}, [visible, isLoggedIn]);
// Check for updates service
const lastCheckDate = useRef();
const checkForUpdates = () => {
lastCheckDate.current = Date.now();
console.log('✨ Check app update');
fetch('./version.json')
.then((r) => r.json())
.then((info) => {
if (info) states.appVersion = info;
})
.catch((e) => {
console.error(e);
});
};
useInterval(checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes
usePageVisibility((visible) => {
if (visible) {
if (!lastCheckDate.current) {
checkForUpdates();
} else {
const diff = Date.now() - lastCheckDate.current;
if (diff > 1000 * 60 * 60) {
// 1 hour
checkForUpdates();
}
}
}
});
return null;
}
function StatusRoute() {
const params = useParams();
const { id, instance } = params;
return <Status id={id} instance={instance} />;
}
export { App };

View file

@ -1,7 +1,6 @@
import './account-block.css';
import { useNavigate } from 'react-router-dom';
// import { useNavigate } from 'react-router-dom';
import enhanceContent from '../utils/enhance-content';
import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number';
@ -21,6 +20,8 @@ function AccountBlock({
onClick,
showActivity = false,
showStats = false,
accountInstance,
hideDisplayName = false,
}) {
if (skeleton) {
return (
@ -35,7 +36,7 @@ function AccountBlock({
);
}
const navigate = useNavigate();
// const navigate = useNavigate();
const {
id,
@ -53,7 +54,10 @@ function AccountBlock({
note,
group,
} = account;
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
if (accountInstance) {
acct2 = `@${accountInstance}`;
}
const verifiedField = fields?.find((f) => !!f.verifiedAt && !!f.value);
@ -68,7 +72,8 @@ function AccountBlock({
e.preventDefault();
if (onClick) return onClick(e);
if (internal) {
navigate(`/${instance}/a/${id}`);
// navigate(`/${instance}/a/${id}`);
location.hash = `/${instance}/a/${id}`;
} else {
states.showAccount = {
account,
@ -79,14 +84,18 @@ function AccountBlock({
>
<Avatar url={avatar} size={avatarSize} squircle={bot} />
<span>
{displayName ? (
<b>
<EmojiText text={displayName} emojis={emojis} />
</b>
) : (
<b>{username}</b>
{!hideDisplayName && (
<>
{displayName ? (
<b>
<EmojiText text={displayName} emojis={emojis} />
</b>
) : (
<b>{username}</b>
)}
<br />
</>
)}
<br />
<span class="account-block-acct">
@{acct1}
<wbr />

View file

@ -9,6 +9,46 @@
color: var(--outline-color);
}
.account-container .account-moved {
animation: fade-in 0.3s both ease-in-out 0.3s;
padding: 16px;
background-color: var(--bg-color);
position: absolute;
top: 8px;
inset-inline: 8px;
z-index: 2;
border: 1px solid var(--outline-color);
box-shadow: 0 8px 16px var(--drop-shadow-color);
border-radius: calc(16px - 8px);
overflow: hidden;
p {
margin: 0 0 8px;
padding: 0;
}
.account-block {
background-color: var(--bg-faded-color);
padding: 8px;
border-radius: 8px;
border: 1px solid var(--link-faded-color);
&:hover {
background-color: var(--link-bg-hover-color);
border-color: var(--link-color);
}
b {
color: var(--link-color);
}
}
~ * {
/* pointer-events: none; */
filter: grayscale(0.75) brightness(0.75);
}
}
.account-container .header-banner {
/* pointer-events: none; */
aspect-ratio: 6 / 1;
@ -139,15 +179,29 @@
/* flex-wrap: wrap; */
column-gap: 24px;
row-gap: 8px;
opacity: 0.75;
/* opacity: 0.75; */
font-size: 90%;
background-color: var(--bg-faded-color);
padding: 12px;
border-radius: 16px;
/* border-radius: 16px; */
line-height: 1.25;
overflow-x: auto;
overflow-x: auto !important;
justify-content: flex-start;
position: relative;
[tabindex='0']:is(:hover, :focus) {
color: var(--text-color);
cursor: pointer;
text-decoration-color: var(--text-insignificant-color);
}
.stats-avatars-bunch {
animation: appear 1s both ease-in-out;
> *:not(:first-child) {
margin: 0 0 0 -4px;
}
}
}
.timeline-start .account-container .stats {
flex-wrap: wrap;
@ -158,6 +212,9 @@
display: flex;
gap: 0.5em;
}
.account-container .stats a:not(.insignificant) {
color: inherit;
}
.account-container .stats a:hover {
color: inherit;
}
@ -176,11 +233,35 @@
display: flex;
}
.account-container .account-metadata-box {
overflow: hidden;
border-radius: 16px;
display: block;
text-decoration: none;
& > * {
margin-bottom: 2px;
border-radius: 4px;
overflow: hidden;
}
&:has(+ .account-metadata-box) {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
+ .account-metadata-box {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
}
.account-container .profile-metadata {
display: flex;
/* flex-wrap: wrap; */
gap: 2px;
border-radius: 16px;
overflow: hidden;
overflow-x: auto;
}
@ -226,12 +307,11 @@
margin: 0;
}
.account-container .common-followers p {
.account-container .common-followers {
font-size: 90%;
color: var(--text-insignificant-color);
border-top: 1px solid var(--outline-color);
border-bottom: 1px solid var(--outline-color);
padding: 8px 0;
background-color: var(--bg-faded-color);
padding: 8px 12px;
margin: 0;
}
@ -252,6 +332,84 @@
opacity: 0.5;
}
@keyframes swoosh-bg-image {
0% {
background-position: -320px 0;
opacity: 0.25;
}
100% {
background-position: 0 0;
opacity: 1;
}
}
.account-container .posting-stats {
font-size: 90%;
color: var(--text-insignificant-color);
background-color: var(--bg-faded-color);
padding: 8px 12px;
--size: 8px;
--original-color: var(--link-color);
&:is(:hover, :focus-within) {
background-color: var(--link-bg-hover-color);
}
.posting-stats-bar {
--gap: 0.5px;
--gap-color: var(--outline-color);
height: var(--size);
border-radius: var(--size);
overflow: hidden;
margin: 8px 0;
box-shadow: inset 0 0 0 1px var(--outline-color),
inset 0 0 0 1.5px var(--bg-blur-color);
background-color: var(--bg-color);
background-repeat: no-repeat;
animation: swoosh-bg-image 0.3s ease-in-out 0.3s both;
background-image: linear-gradient(
to right,
var(--original-color) 0%,
var(--original-color) calc(var(--originals-percentage) - var(--gap)),
var(--gap-color) calc(var(--originals-percentage) - var(--gap)),
var(--gap-color) calc(var(--originals-percentage) + var(--gap)),
var(--reply-to-color) calc(var(--originals-percentage) + var(--gap)),
var(--reply-to-color) calc(var(--replies-percentage) - var(--gap)),
var(--gap-color) calc(var(--replies-percentage) - var(--gap)),
var(--gap-color) calc(var(--replies-percentage) + var(--gap)),
var(--reblog-color) calc(var(--replies-percentage) + var(--gap)),
var(--reblog-color) 100%
);
}
.posting-stats-legends {
font-size: 12px;
text-transform: uppercase;
}
.posting-stats-legend-item {
display: inline-block;
width: var(--size);
height: var(--size);
border-radius: var(--size);
background-color: var(--text-insignificant-color);
vertical-align: middle;
margin: 0 4px 2px;
/* border: 1px solid var(--outline-color); */
box-shadow: inset 0 0 0 1px var(--outline-color),
inset 0 0 0 1.5px var(--bg-blur-color);
&.posting-stats-legend-item-originals {
background-color: var(--original-color);
}
&.posting-stats-legend-item-replies {
background-color: var(--reply-to-color);
}
&.posting-stats-legend-item-boosts {
background-color: var(--reblog-color);
}
}
}
@keyframes shine {
0% {
left: -100%;
@ -340,7 +498,7 @@
}
.timeline-start .account-container header .account-block {
font-size: 175%;
margin-bottom: -8px;
/* margin-bottom: -8px; */
line-height: 1.1;
letter-spacing: -0.5px;
mix-blend-mode: multiply;

View file

@ -1,7 +1,8 @@
import './account-info.css';
import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu';
import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
import { useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks';
import { proxy, useSnapshot } from 'valtio';
import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content';
@ -46,6 +47,12 @@ const MUTE_DURATIONS_LABELS = {
604_800_000: '1 week',
};
const LIMIT = 80;
const accountInfoStates = proxy({
familiarFollowers: [],
});
function AccountInfo({
account,
fetchAccount = () => {},
@ -53,9 +60,23 @@ function AccountInfo({
instance,
authenticated,
}) {
const { masto } = api({
instance,
});
const [uiState, setUIState] = useState('default');
const isString = typeof account === 'string';
const [info, setInfo] = useState(isString ? null : account);
const snapAccountInfoStates = useSnapshot(accountInfoStates);
const isSelf = useMemo(
() => account.id === store.session.get('currentAccount'),
[account?.id],
);
const sameCurrentInstance = useMemo(
() => instance === api().instance,
[instance],
);
useEffect(() => {
if (!isString) {
@ -98,6 +119,9 @@ function AccountInfo({
statusesCount,
url,
username,
memorial,
moved,
roles,
} = info || {};
let headerIsAvatar = false;
let { header, headerStatic } = info || {};
@ -111,8 +135,73 @@ function AccountInfo({
}
}
const accountInstance = useMemo(() => {
if (!url) return null;
const domain = new URL(url).hostname;
return domain;
}, [url]);
const [headerCornerColors, setHeaderCornerColors] = useState([]);
const followersIterator = useRef();
const familiarFollowersCache = useRef([]);
async function fetchFollowers(firstLoad) {
if (firstLoad || !followersIterator.current) {
followersIterator.current = masto.v1.accounts.listFollowers(id, {
limit: LIMIT,
});
}
const results = await followersIterator.current.next();
if (isSelf) return results;
if (!sameCurrentInstance) return results;
const { value } = results;
let newValue = [];
// On first load, fetch familiar followers, merge to top of results' `value`
// Remove dups on every fetch
if (firstLoad) {
const familiarFollowers = await masto.v1.accounts.fetchFamiliarFollowers(
id,
);
familiarFollowersCache.current = familiarFollowers[0].accounts;
newValue = [
...familiarFollowersCache.current,
...value.filter(
(account) =>
!familiarFollowersCache.current.some(
(familiar) => familiar.id === account.id,
),
),
];
} else if (value?.length) {
newValue = value.filter(
(account) =>
!familiarFollowersCache.current.some(
(familiar) => familiar.id === account.id,
),
);
}
return {
...results,
value: newValue,
};
}
const followingIterator = useRef();
async function fetchFollowing(firstLoad) {
if (firstLoad || !followingIterator.current) {
followingIterator.current = masto.v1.accounts.listFollowing(id, {
limit: LIMIT,
});
}
const results = await followingIterator.current.next();
return results;
}
const LinkOrDiv = standalone ? 'div' : Link;
const accountLink = instance ? `/${instance}/a/${id}` : `/a/${id}`;
return (
<div
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
@ -127,7 +216,11 @@ function AccountInfo({
<div class="ui-state">
<p>Unable to load account.</p>
<p>
<a href={account} target="_blank">
<a
href={isString ? account : url}
target="_blank"
rel="noopener noreferrer"
>
Go to account page <Icon icon="external" />
</a>
</p>
@ -160,6 +253,22 @@ function AccountInfo({
) : (
info && (
<>
{!!moved && (
<div class="account-moved">
<p>
<b>{displayName}</b> has indicated that their new account is
now:
</p>
<AccountBlock
account={moved}
instance={instance}
onClick={(e) => {
e.stopPropagation();
states.showAccount = moved;
}}
/>
</div>
)}
{header && !/missing\.png$/.test(header) && (
<img
src={header}
@ -187,7 +296,9 @@ function AccountInfo({
try {
// Get color from four corners of image
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', {
willReadFrequently: true,
});
canvas.width = e.target.width;
canvas.height = e.target.height;
ctx.drawImage(e.target, 0, 0);
@ -266,20 +377,28 @@ function AccountInfo({
/>
</header>
<main tabIndex="-1">
{bot && (
<>
<span class="tag">
<Icon icon="bot" /> Automated
</span>
</>
{!!memorial && <span class="tag">In Memoriam</span>}
{!!bot && (
<span class="tag">
<Icon icon="bot" /> Automated
</span>
)}
{group && (
<>
<span class="tag">
<Icon icon="group" /> Group
</span>
</>
{!!group && (
<span class="tag">
<Icon icon="group" /> Group
</span>
)}
{roles?.map((role) => (
<span class="tag">
{role.name}
{!!accountInstance && (
<>
{' '}
<span class="more-insignificant">{accountInstance}</span>
</>
)}
</span>
))}
<div
class="note"
onClick={handleContentLinks({
@ -289,78 +408,115 @@ function AccountInfo({
__html: enhanceContent(note, { emojis }),
}}
/>
{fields?.length > 0 && (
<div class="profile-metadata">
{fields.map(({ name, value, verifiedAt }, i) => (
<div
class={`profile-field ${
verifiedAt ? 'profile-verified' : ''
}`}
key={name + i}
>
<b>
<EmojiText text={name} emojis={emojis} />{' '}
{!!verifiedAt && <Icon icon="check-circle" size="s" />}
</b>
<p
dangerouslySetInnerHTML={{
__html: enhanceContent(value, { emojis }),
}}
/>
</div>
))}
</div>
)}
<p class="stats">
<div>
<span title={followersCount}>
{shortenNumber(followersCount)}
</span>{' '}
Followers
</div>
<div class="insignificant">
<span title={followingCount}>
{shortenNumber(followingCount)}
</span>{' '}
Following
<br />
</div>
{standalone ? (
<div class="insignificant">
<span title={statusesCount}>
{shortenNumber(statusesCount)}
</span>{' '}
Posts
<div class="account-metadata-box">
{fields?.length > 0 && (
<div class="profile-metadata">
{fields.map(({ name, value, verifiedAt }, i) => (
<div
class={`profile-field ${
verifiedAt ? 'profile-verified' : ''
}`}
key={name + i}
>
<b>
<EmojiText text={name} emojis={emojis} />{' '}
{!!verifiedAt && (
<Icon icon="check-circle" size="s" />
)}
</b>
<p
dangerouslySetInnerHTML={{
__html: enhanceContent(value, { emojis }),
}}
/>
</div>
))}
</div>
) : (
<Link
class="insignificant"
to={instance ? `/${instance}/a/${id}` : `/a/${id}`}
)}
<div class="stats">
<LinkOrDiv
tabIndex={0}
to={accountLink}
onClick={() => {
hideAllModals();
states.showAccount = false;
states.showGenericAccounts = {
heading: 'Followers',
fetchAccounts: fetchFollowers,
};
}}
>
{!!snapAccountInfoStates.familiarFollowers.length && (
<span class="shazam-container-horizontal">
<span class="shazam-container-inner stats-avatars-bunch">
{(snapAccountInfoStates.familiarFollowers || []).map(
(follower) => (
<Avatar
url={follower.avatarStatic}
size="s"
alt={`${follower.displayName} @${follower.acct}`}
squircle={follower?.bot}
/>
),
)}
</span>
</span>
)}
<span title={followersCount}>
{shortenNumber(followersCount)}
</span>{' '}
Followers
</LinkOrDiv>
<LinkOrDiv
class="insignificant"
tabIndex={0}
to={accountLink}
onClick={() => {
states.showAccount = false;
states.showGenericAccounts = {
heading: 'Following',
fetchAccounts: fetchFollowing,
};
}}
>
<span title={followingCount}>
{shortenNumber(followingCount)}
</span>{' '}
Following
<br />
</LinkOrDiv>
<LinkOrDiv
class="insignificant"
to={accountLink}
onClick={
standalone
? undefined
: () => {
hideAllModals();
}
}
>
<span title={statusesCount}>
{shortenNumber(statusesCount)}
</span>{' '}
Posts
</Link>
)}
{!!createdAt && (
<div class="insignificant">
Joined{' '}
<time datetime={createdAt}>
{niceDateTime(createdAt, {
hideTime: true,
})}
</time>
</div>
)}
</p>
</LinkOrDiv>
{!!createdAt && (
<div class="insignificant">
Joined{' '}
<time datetime={createdAt}>
{niceDateTime(createdAt, {
hideTime: true,
})}
</time>
</div>
)}
</div>
</div>
<RelatedActions
info={info}
instance={instance}
authenticated={authenticated}
standalone={standalone}
/>
</main>
</>
@ -370,7 +526,9 @@ function AccountInfo({
);
}
function RelatedActions({ info, instance, authenticated }) {
const FAMILIAR_FOLLOWERS_LIMIT = 3;
function RelatedActions({ info, instance, authenticated, standalone }) {
if (!info) return null;
const {
masto: currentMasto,
@ -381,9 +539,10 @@ function RelatedActions({ info, instance, authenticated }) {
const [relationshipUIState, setRelationshipUIState] = useState('default');
const [relationship, setRelationship] = useState(null);
const [familiarFollowers, setFamiliarFollowers] = useState([]);
const [postingStats, setPostingStats] = useState();
const { id, acct, url, username, locked, lastStatusAt, note, fields } = info;
const { id, acct, url, username, locked, lastStatusAt, note, fields, moved } =
info;
const accountID = useRef(id);
const {
@ -440,33 +599,82 @@ function RelatedActions({ info, instance, authenticated }) {
accountID.current = currentID;
if (moved) return;
setRelationshipUIState('loading');
setFamiliarFollowers([]);
accountInfoStates.familiarFollowers = [];
setPostingStats(null);
const fetchRelationships = currentMasto.v1.accounts.fetchRelationships([
currentID,
]);
const fetchFamiliarFollowers =
currentMasto.v1.accounts.fetchFamiliarFollowers(currentID);
try {
const relationships = await fetchRelationships;
console.log('fetched relationship', relationships);
setRelationshipUIState('default');
if (relationships.length) {
const relationship = relationships[0];
setRelationship(relationship);
if (!relationship.following) {
try {
const fetchFamiliarFollowers =
currentMasto.v1.accounts.fetchFamiliarFollowers(currentID);
const fetchStatuses = currentMasto.v1.accounts
.listStatuses(currentID, {
limit: 20,
})
.next();
const followers = await fetchFamiliarFollowers;
console.log('fetched familiar followers', followers);
setFamiliarFollowers(followers[0].accounts.slice(0, 10));
accountInfoStates.familiarFollowers =
followers[0].accounts.slice(0, FAMILIAR_FOLLOWERS_LIMIT);
if (!standalone) {
const { value: statuses } = await fetchStatuses;
console.log('fetched statuses', statuses);
const stats = {
total: statuses.length,
originals: 0,
replies: 0,
boosts: 0,
};
// Categories statuses by type
// - Original posts (not replies to others)
// - Threads (self-replies + 1st original post)
// - Boosts (reblogs)
// - Replies (not-self replies)
statuses.forEach((status) => {
if (status.reblog) {
stats.boosts++;
} else if (
status.inReplyToAccountId !== currentID &&
!!status.inReplyToId
) {
stats.replies++;
} else {
stats.originals++;
}
});
// Count days since last post
stats.daysSinceLastPost = Math.ceil(
(Date.now() -
new Date(statuses[statuses.length - 1].createdAt)) /
86400000,
);
console.log('posting stats', stats);
setPostingStats(stats);
}
} catch (e) {
console.error(e);
}
}
}
setRelationshipUIState('default');
} catch (e) {
console.error(e);
setRelationshipUIState('error');
@ -487,40 +695,74 @@ function RelatedActions({ info, instance, authenticated }) {
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
const hasPostingStats = postingStats?.total >= 3;
const accountLink = instance ? `/${instance}/a/${id}` : `/a/${id}`;
return (
<>
<div
class="common-followers shazam-container no-animation"
hidden={!familiarFollowers?.length}
>
<div class="shazam-container-inner">
<p>
Followed by{' '}
<span class="ib">
{familiarFollowers.map((follower) => (
<a
href={follower.url}
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
states.showAccount = {
account: follower,
instance,
};
{hasPostingStats && (
<Link
to={accountLink}
class="account-metadata-box"
onClick={() => {
states.showAccount = false;
}}
>
<div class="shazam-container">
<div class="shazam-container-inner">
<div
class="posting-stats"
title={`${Math.round(
(postingStats.originals / postingStats.total) * 100,
)}% original posts, ${Math.round(
(postingStats.replies / postingStats.total) * 100,
)}% replies, ${Math.round(
(postingStats.boosts / postingStats.total) * 100,
)}% boosts`}
>
<div>
{postingStats.daysSinceLastPost < 365
? `Last ${postingStats.total} posts in the past
${postingStats.daysSinceLastPost} day${
postingStats.daysSinceLastPost > 1 ? 's' : ''
}`
: `
Last ${postingStats.total} posts in the past year(s)
`}
</div>
<div
class="posting-stats-bar"
style={{
// [originals | replies | boosts]
'--originals-percentage': `${
(postingStats.originals / postingStats.total) * 100
}%`,
'--replies-percentage': `${
((postingStats.originals + postingStats.replies) /
postingStats.total) *
100
}%`,
}}
>
<Avatar
url={follower.avatarStatic}
size="l"
alt={`${follower.displayName} @${follower.acct}`}
squircle={follower?.bot}
/>
</a>
))}
</span>
</p>
</div>
</div>
/>
<div class="posting-stats-legends">
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-originals" />{' '}
Original
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-replies" />{' '}
Replies
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-boosts" />{' '}
Boosts
</span>
</div>
</div>
</div>
</div>
</Link>
)}
<p class="actions">
<span>
{followedBy ? (
@ -664,6 +906,8 @@ function RelatedActions({ info, instance, authenticated }) {
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`Unmuted @${username}`);
states.reloadGenericAccounts.id = 'mute';
states.reloadGenericAccounts.counter++;
} catch (e) {
console.error(e);
setRelationshipUIState('error');
@ -715,6 +959,8 @@ function RelatedActions({ info, instance, authenticated }) {
showToast(
`Muted @${username} for ${MUTE_DURATIONS_LABELS[duration]}`,
);
states.reloadGenericAccounts.id = 'mute';
states.reloadGenericAccounts.counter++;
} catch (e) {
console.error(e);
setRelationshipUIState('error');
@ -765,6 +1011,8 @@ function RelatedActions({ info, instance, authenticated }) {
setRelationshipUIState('default');
showToast(`Blocked @${username}`);
}
states.reloadGenericAccounts.id = 'block';
states.reloadGenericAccounts.counter++;
} catch (e) {
console.error(e);
setRelationshipUIState('error');
@ -876,10 +1124,8 @@ function RelatedActions({ info, instance, authenticated }) {
{!!showTranslatedBio && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowTranslatedBio(false);
}
onClose={() => {
setShowTranslatedBio(false);
}}
>
<TranslatedBioSheet
@ -892,10 +1138,8 @@ function RelatedActions({ info, instance, authenticated }) {
{!!showAddRemoveLists && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowAddRemoveLists(false);
}
onClose={() => {
setShowAddRemoveLists(false);
}}
>
<AddRemoveListsSheet

View file

@ -1,5 +1,4 @@
import { useEffect } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { api } from '../utils/api';
import states from '../utils/states';
@ -11,8 +10,6 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
const { masto, instance, authenticated } = api({ instance: propInstance });
const isString = typeof account === 'string';
const escRef = useHotkeys('esc', onClose, [onClose]);
useEffect(() => {
if (!isString) {
states.accounts[`${account.id}@${instance}`] = account;
@ -21,7 +18,6 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
return (
<div
ref={escRef}
class="sheet"
onClick={(e) => {
const accountBlock = e.target.closest('.account-block');
@ -58,6 +54,18 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
});
if (result.accounts.length) {
return result.accounts[0];
} else if (/https?:\/\/[^/]+\/@/.test(account)) {
const accountURL = new URL(account);
const acct = accountURL.pathname.replace(/^\//, '');
const result = await masto.v2.search({
q: acct,
type: 'accounts',
limit: 1,
resolve: authenticated,
});
if (result.accounts.length) {
return result.accounts[0];
}
}
}
} else {

View file

@ -16,7 +16,9 @@ const alphaCache = {};
const canvas = window.OffscreenCanvas
? new OffscreenCanvas(1, 1)
: document.createElement('canvas');
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', {
willReadFrequently: true,
});
function Avatar({ url, size, alt = '', squircle, ...props }) {
size = SIZES[size] || size || SIZES.m;

View file

@ -0,0 +1,106 @@
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api';
import states, { saveStatus } from '../utils/states';
import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility';
export default memo(function BackgroundService({ isLoggedIn }) {
// Notifications service
// - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true);
usePageVisibility(setVisible);
const notificationStream = useRef();
useEffect(() => {
if (isLoggedIn && visible) {
const { masto, instance } = api();
(async () => {
// 1. Get the latest notification
if (states.notificationsLast) {
const notificationsIterator = masto.v1.notifications.list({
limit: 1,
since_id: states.notificationsLast.id,
});
const { value: notifications } = await notificationsIterator.next();
if (notifications?.length) {
let lastReadId;
try {
const markers = await masto.v1.markers.fetch({
timeline: 'notifications',
});
lastReadId = markers?.notifications?.lastReadId;
} catch (e) {}
if (lastReadId) {
if (notifications[0].id !== lastReadId) {
states.notificationsShowNew = true;
}
} else {
states.notificationsShowNew = true;
}
}
}
// 2. Start streaming
notificationStream.current = await masto.ws.stream(
'/api/v1/streaming',
{
stream: 'user:notification',
},
);
console.log('🎏 Streaming notification', notificationStream.current);
notificationStream.current.on('notification', (notification) => {
console.log('🔔🔔 Notification', notification);
if (notification.status) {
saveStatus(notification.status, instance, {
skipThreading: true,
});
}
states.notificationsShowNew = true;
});
notificationStream.current.ws.onclose = () => {
console.log('🔔🔔 Notification stream closed');
};
})();
}
return () => {
if (notificationStream.current) {
notificationStream.current.ws.close();
notificationStream.current = null;
}
};
}, [visible, isLoggedIn]);
// Check for updates service
const lastCheckDate = useRef();
const checkForUpdates = () => {
lastCheckDate.current = Date.now();
console.log('✨ Check app update');
fetch('./version.json')
.then((r) => r.json())
.then((info) => {
if (info) states.appVersion = info;
})
.catch((e) => {
console.error(e);
});
};
useInterval(checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes
usePageVisibility((visible) => {
if (visible) {
if (!lastCheckDate.current) {
checkForUpdates();
} else {
const diff = Date.now() - lastCheckDate.current;
if (diff > 1000 * 60 * 60) {
// 1 hour
checkForUpdates();
}
}
}
});
return null;
});

View file

@ -0,0 +1,34 @@
import { useHotkeys } from 'react-hotkeys-hook';
import openCompose from '../utils/open-compose';
import states from '../utils/states';
import Icon from './icon';
export default function ComposeButton() {
function handleButton(e) {
if (e.shiftKey) {
const newWin = openCompose();
if (!newWin) {
alert('Looks like your browser is blocking popups.');
states.showCompose = true;
}
} else {
states.showCompose = true;
}
}
useHotkeys('c, shift+c', handleButton, {
ignoreEventWhen: (e) => {
const hasModal = !!document.querySelector('#modal-container > *');
return hasModal;
},
});
return (
<button type="button" id="compose-button" onClick={handleButton}>
<Icon icon="quill" size="xl" alt="Compose" />
</button>
);
}

View file

@ -25,6 +25,19 @@
position: sticky;
top: 0;
z-index: 100;
white-space: nowrap;
}
#compose-container .compose-top .account-block {
text-align: start;
pointer-events: none;
overflow: hidden;
color: var(--text-insignificant-color);
line-height: 1.1;
font-size: 90%;
background-color: var(--bg-faded-blur-color);
backdrop-filter: blur(16px);
padding-inline-end: 1em;
border-radius: 9999px;
}
#compose-container textarea {
@ -326,7 +339,7 @@
#compose-container .media-preview > * {
width: 80px;
height: 80px;
object-fit: contain;
object-fit: scale-down;
vertical-align: middle;
pointer-events: none;
}
@ -507,7 +520,7 @@
width: 100%;
height: 100%;
max-height: 50vh;
object-fit: contain;
object-fit: scale-down;
vertical-align: middle;
}

View file

@ -28,7 +28,8 @@ import supports from '../utils/supports';
import useInterval from '../utils/useInterval';
import visibilityIconsMap from '../utils/visibility-icons-map';
import Avatar from './avatar';
import AccountBlock from './account-block';
// import Avatar from './avatar';
import Icon from './icon';
import Loader from './loader';
import Modal from './modal';
@ -508,11 +509,16 @@ function Compose({
<div id="compose-container" class={standalone ? 'standalone' : ''}>
<div class="compose-top">
{currentAccountInfo?.avatarStatic && (
<Avatar
url={currentAccountInfo.avatarStatic}
size="xl"
alt={currentAccountInfo.username}
squircle={currentAccountInfo?.bot}
// <Avatar
// url={currentAccountInfo.avatarStatic}
// size="xl"
// alt={currentAccountInfo.username}
// squircle={currentAccountInfo?.bot}
// />
<AccountBlock
account={currentAccountInfo}
accountInstance={currentAccount.instanceURL}
hideDisplayName
/>
)}
{!standalone ? (
@ -833,6 +839,8 @@ function Compose({
// Close
onClose({
// type: post, reply, edit
type: editStatus ? 'edit' : replyToStatus ? 'reply' : 'post',
newStatus,
instance,
});
@ -925,6 +933,14 @@ function Compose({
}}
maxCharacters={maxCharacters}
performSearch={(params) => {
const { type, q, limit } = params;
if (type === 'accounts') {
return masto.v1.accounts.search({
q,
limit,
resolve: false,
});
}
return masto.v2.search(params);
}}
/>
@ -1169,6 +1185,17 @@ function Compose({
);
}
function autoResizeTextarea(textarea) {
if (!textarea) return;
const { value, offsetHeight, scrollHeight, clientHeight } = textarea;
if (offsetHeight < window.innerHeight) {
// NOTE: This check is needed because the offsetHeight return 50000 (really large number) on first render
// No idea why it does that, will re-investigate in far future
const offset = offsetHeight - clientHeight;
textarea.style.height = value ? scrollHeight + offset + 'px' : null;
}
}
const Textarea = forwardRef((props, ref) => {
const { masto } = api();
const [text, setText] = useState(ref.current?.value || '');
@ -1252,7 +1279,7 @@ const Textarea = forwardRef((props, ref) => {
return;
}
console.log({ value, type, v: value[type] });
const results = value[type];
const results = value[type] || value;
console.log('RESULTS', value, results);
let html = '';
results.forEach((result) => {
@ -1361,11 +1388,46 @@ const Textarea = forwardRef((props, ref) => {
ref={ref}
name="status"
value={text}
onKeyDown={(e) => {
// Get line before cursor position after pressing 'Enter'
const { key, target } = e;
if (key === 'Enter') {
try {
const { value, selectionStart } = target;
const textBeforeCursor = value.slice(0, selectionStart);
const lastLine = textBeforeCursor.split('\n').slice(-1)[0];
if (lastLine) {
// If line starts with "- " or "12. "
if (/^\s*(-|\d+\.)\s/.test(lastLine)) {
// insert "- " at cursor position
const [_, preSpaces, bullet, postSpaces, anything] =
lastLine.match(/^(\s*)(-|\d+\.)(\s+)(.+)?/) || [];
if (anything) {
e.preventDefault();
const [number] = bullet.match(/\d+/) || [];
const newBullet = number ? `${+number + 1}.` : '-';
const text = `\n${preSpaces}${newBullet}${postSpaces}`;
target.setRangeText(text, selectionStart, selectionStart);
const pos = selectionStart + text.length;
target.setSelectionRange(pos, pos);
} else {
// trim the line before the cursor, then insert new line
const pos = selectionStart - lastLine.length;
target.setRangeText('', pos, selectionStart);
}
autoResizeTextarea(target);
}
}
} catch (e) {
// silent fail
console.error(e);
}
}
}}
onInput={(e) => {
const { scrollHeight, offsetHeight, clientHeight, value } = e.target;
setText(value);
const offset = offsetHeight - clientHeight;
e.target.style.height = value ? scrollHeight + offset + 'px' : null;
const { target } = e;
setText(target.value);
autoResizeTextarea(target);
props.onInput?.(e);
}}
style={{
@ -1841,14 +1903,23 @@ function CustomEmojisModal({
}}
title={`:${emoji.shortcode}:`}
>
<img
src={emoji.url || emoji.staticUrl}
alt={emoji.shortcode}
width="16"
height="16"
loading="lazy"
decoding="async"
/>
<picture>
{!!emoji.staticUrl && (
<source
srcset={emoji.staticUrl}
media="(prefers-reduced-motion: reduce)"
/>
)}
<img
class="shortcode-emoji"
src={emoji.url || emoji.staticUrl}
alt={emoji.shortcode}
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>
</button>
))}
</section>

View file

@ -18,8 +18,8 @@ function EmojiText({ text, emojis }) {
src={url}
alt={word}
class="shortcode-emoji emoji"
width="12"
height="12"
width="16"
height="16"
loading="lazy"
decoding="async"
/>

View file

@ -0,0 +1,42 @@
#generic-accounts-container {
.accounts-list {
list-style: none;
margin: 0;
padding: 8px 0;
display: flex;
flex-wrap: wrap;
flex-direction: row;
column-gap: 1.5em;
row-gap: 16px;
li {
display: flex;
flex-grow: 1;
flex-basis: 16em;
align-items: center;
margin: 0;
padding: 0;
gap: 8px;
}
.account-block-acct {
font-size: 80%;
color: var(--text-insignificant-color);
display: block;
}
}
.reactions-block {
display: flex;
flex-direction: column;
align-self: center;
.favourite-icon {
color: var(--favourite-color);
}
.reblog-icon {
color: var(--reblog-color);
}
}
}

View file

@ -0,0 +1,145 @@
import './generic-accounts.css';
import { useEffect, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer';
import { useSnapshot } from 'valtio';
import states from '../utils/states';
import AccountBlock from './account-block';
import Icon from './icon';
import Loader from './loader';
export default function GenericAccounts({ onClose = () => {} }) {
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const [accounts, setAccounts] = useState([]);
const [showMore, setShowMore] = useState(false);
if (!snapStates.showGenericAccounts) {
return null;
}
const {
id,
heading,
fetchAccounts,
accounts: staticAccounts,
showReactions,
} = snapStates.showGenericAccounts;
const loadAccounts = (firstLoad) => {
if (!fetchAccounts) return;
if (firstLoad) setAccounts([]);
setUIState('loading');
(async () => {
try {
const { done, value } = await fetchAccounts(firstLoad);
if (Array.isArray(value)) {
if (firstLoad) {
setAccounts(value);
} else {
setAccounts((prev) => [...prev, ...value]);
}
setShowMore(!done);
} else {
setShowMore(false);
}
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
};
useEffect(() => {
if (staticAccounts?.length > 0) {
setAccounts(staticAccounts);
} else {
loadAccounts(true);
}
}, [staticAccounts, fetchAccounts]);
useEffect(() => {
// reloadGenericAccounts contains value like {id: 'mute', counter: 1}
// We only need to reload if the id matches
if (snapStates.reloadGenericAccounts?.id === id) {
loadAccounts(true);
}
}, [snapStates.reloadGenericAccounts.counter]);
return (
<div id="generic-accounts-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<h2>{heading || 'Accounts'}</h2>
</header>
<main>
{accounts.length > 0 ? (
<>
<ul class="accounts-list">
{accounts.map((account) => (
<li key={account.id + (account._types || '')}>
{showReactions && account._types?.length > 0 && (
<div class="reactions-block">
{account._types.map((type) => (
<Icon
icon={
{
reblog: 'rocket',
favourite: 'heart',
}[type]
}
class={`${type}-icon`}
/>
))}
</div>
)}
<AccountBlock account={account} />
</li>
))}
</ul>
{uiState === 'default' ? (
showMore ? (
<InView
onChange={(inView) => {
if (inView) {
loadAccounts();
}
}}
>
<button
type="button"
class="plain block"
onClick={() => loadAccounts()}
>
Show more&hellip;
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
)
) : (
uiState === 'loading' && (
<p class="ui-state">
<Loader abrupt />
</p>
)
)}
</>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Error loading accounts</p>
) : (
<p class="ui-state insignificant">Nothing to show</p>
)}
</main>
</div>
);
}

View file

@ -45,6 +45,7 @@ export const ICONS = {
plus: () => import('@iconify-icons/mingcute/add-circle-line'),
'chevron-left': () => import('@iconify-icons/mingcute/left-line'),
'chevron-right': () => import('@iconify-icons/mingcute/right-line'),
'chevron-down': () => import('@iconify-icons/mingcute/down-line'),
reply: [
() => import('@iconify-icons/mingcute/share-forward-line'),
'180deg',
@ -95,6 +96,10 @@ export const ICONS = {
'arrow-down-circle': () =>
import('@iconify-icons/mingcute/arrow-down-circle-line'),
clipboard: () => import('@iconify-icons/mingcute/clipboard-line'),
'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'),
'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
};
function Icon({
@ -121,7 +126,7 @@ function Icon({
}, [iconBlock]);
return (
<div
<span
class={`icon ${className}`}
title={title || alt}
style={{
@ -146,7 +151,7 @@ function Icon({
}}
/>
)}
</div>
</span>
);
}

View file

@ -0,0 +1,44 @@
#keyboard-shortcuts-help-container {
table {
tr > * {
border-top: 1px solid var(--outline-color);
vertical-align: middle;
}
th {
font-weight: normal;
text-align: start;
padding: 0.25em 0;
line-height: 1;
width: 60%;
}
td {
padding: 0.25em 1em;
}
}
kbd {
border-radius: 4px;
display: inline-block;
padding: 0.2em 0.3em;
margin: 1px 0;
line-height: 1;
border: 1px solid var(--outline-color);
background-color: var(--bg-faded-color);
background-image: linear-gradient(
to top,
var(--bg-blur-color),
transparent
);
text-shadow: 0 1px var(--bg-color);
box-shadow: 0 1px var(--drop-shadow-color),
0 1px 1px var(--drop-shadow-color), 0 1px 8px var(--drop-shadow-color),
inset 0 1px var(--bg-blur-color);
&:active {
box-shadow: 0 1px 4px var(--drop-shadow-color),
inset 0 1px var(--bg-blur-color);
transform: translateY(1px);
filter: brightness(0.95);
}
}
}

View file

@ -0,0 +1,165 @@
import './keyboard-shortcuts-help.css';
import { memo } from 'preact/compat';
import { useHotkeys } from 'react-hotkeys-hook';
import { useSnapshot } from 'valtio';
import states from '../utils/states';
import Icon from './icon';
import Modal from './modal';
export default memo(function KeyboardShortcutsHelp() {
const snapStates = useSnapshot(states);
function onClose() {
states.showKeyboardShortcutsHelp = false;
}
useHotkeys(
'?, shift+?',
(e) => {
console.log('help');
states.showKeyboardShortcutsHelp = true;
},
{
ignoreEventWhen: (e) => {
const hasModal = !!document.querySelector('#modal-container > *');
return hasModal;
},
},
);
const escRef = useHotkeys('esc', onClose, []);
return (
!!snapStates.showKeyboardShortcutsHelp && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
id="keyboard-shortcuts-help-container"
class="sheet"
tabindex="-1"
ref={escRef}
>
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<h2>Keyboard shortcuts</h2>
</header>
<main>
<table>
{[
{
action: 'Keyboard shortcuts help',
keys: <kbd>?</kbd>,
},
{
action: 'Next post',
keys: <kbd>j</kbd>,
},
{
action: 'Previous post',
keys: <kbd>k</kbd>,
},
{
action: 'Skip carousel to next post',
keys: (
<>
<kbd>Shift</kbd> + <kbd>j</kbd>
</>
),
},
{
action: 'Skip carousel to previous post',
keys: (
<>
<kbd>Shift</kbd> + <kbd>k</kbd>
</>
),
},
{
action: 'Open post details',
keys: (
<>
<kbd>Enter</kbd> or <kbd>o</kbd>
</>
),
},
{
action: 'Toggle expanded/collapsed thread',
keys: <kbd>x</kbd>,
},
{
action: 'Close post or dialogs',
keys: (
<>
<kbd>Esc</kbd> or <kbd>Backspace</kbd>
</>
),
},
{
action: 'Focus column in multi-column mode',
keys: (
<>
<kbd>1</kbd> to <kbd>9</kbd>
</>
),
},
{
action: 'Compose new post',
keys: <kbd>c</kbd>,
},
{
action: 'Send post',
keys: (
<>
<kbd>Ctrl</kbd> + <kbd>Enter</kbd> or <kbd></kbd> +{' '}
<kbd>Enter</kbd>
</>
),
},
{
action: 'Search',
keys: <kbd>/</kbd>,
},
{
action: 'Reply',
keys: <kbd>r</kbd>,
},
{
action: 'Favourite',
keys: <kbd>f</kbd>,
},
{
action: 'Boost',
keys: (
<>
<kbd>Shift</kbd> + <kbd>b</kbd>
</>
),
},
{
action: 'Bookmark',
keys: <kbd>d</kbd>,
},
].map(({ action, keys }) => (
<tr key={action}>
<th>{action}</th>
<td>{keys}</td>
</tr>
))}
</table>
</main>
</div>
</Modal>
)
);
});

View file

@ -19,7 +19,19 @@ const Link = forwardRef((props, ref) => {
let hash = (location.hash || '').replace(/^#/, '').trim();
if (hash === '') hash = '/';
const { to, ...restProps } = props;
const isActive = decodeURIComponent(hash) === to;
// Handle encodeURIComponent of searchParams values
if (!!hash && hash !== '/' && hash.includes('?')) {
const parsedHash = new URL(hash, location.origin); // Fake base URL
if (parsedHash.searchParams.size) {
const searchParamsStr = Array.from(parsedHash.searchParams.entries())
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
hash = parsedHash.pathname + '?' + searchParamsStr;
}
}
const isActive = hash === to || decodeURIComponent(hash) === to;
return (
<a
ref={ref}
@ -27,6 +39,10 @@ const Link = forwardRef((props, ref) => {
{...restProps}
class={`${props.class || ''} ${isActive ? 'is-active' : ''}`}
onClick={(e) => {
if (e.currentTarget?.parentNode?.closest('a')) {
// If this <a> is nested inside another <a>
e.stopPropagation();
}
if (routerLocation) states.prevLocation = routerLocation;
props.onClick?.(e);
}}

View file

@ -0,0 +1,74 @@
import { Menu, MenuItem } from '@szhsin/react-menu';
import { useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import localeMatch from '../utils/locale-match';
import states from '../utils/states';
import Icon from './icon';
import TranslationBlock from './translation-block';
export default function MediaAltModal({ alt, lang, onClose }) {
const snapStates = useSnapshot(states);
const [forceTranslate, setForceTranslate] = useState(false);
const targetLanguage = getTranslateTargetLanguage(true);
const contentTranslationHideLanguages =
snapStates.settings.contentTranslationHideLanguages || [];
const differentLanguage =
!!lang &&
lang !== targetLanguage &&
!localeMatch([lang], [targetLanguage]) &&
!contentTranslationHideLanguages.find(
(l) => lang === l || localeMatch([lang], [l]),
);
return (
<div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header class="header-grid">
<h2>Media description</h2>
<div class="header-side">
<Menu
align="end"
menuButton={
<button type="button" class="plain4">
<Icon icon="more" alt="More" size="xl" />
</button>
}
>
<MenuItem
disabled={forceTranslate}
onClick={() => {
setForceTranslate(true);
}}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuItem>
</Menu>
</div>
</header>
<main lang={lang} dir="auto">
<p
style={{
whiteSpace: 'pre-wrap',
}}
>
{alt}
</p>
{(differentLanguage || forceTranslate) && (
<TranslationBlock
forceTranslate={forceTranslate}
sourceLanguage={lang}
text={alt}
/>
)}
</main>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { Menu, MenuItem } from '@szhsin/react-menu';
import { Menu } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash';
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
@ -6,14 +6,15 @@ import { useHotkeys } from 'react-hotkeys-hook';
import Icon from './icon';
import Link from './link';
import Media from './media';
import MediaAltModal from './media-alt-modal';
import MenuLink from './menu-link';
import Modal from './modal';
import TranslationBlock from './translation-block';
function MediaModal({
mediaAttachments,
statusID,
instance,
lang,
index = 0,
onClose = () => {},
}) {
@ -138,14 +139,19 @@ function MediaModal({
class="media-alt"
hidden={!showControls}
onClick={() => {
setShowMediaAlt(media.description);
setShowMediaAlt({
alt: media.description,
lang,
});
}}
>
<Icon icon="info" />
<span class="media-alt-desc">{media.description}</span>
<span class="alt-badge">ALT</span>
<span class="media-alt-desc" lang={lang} dir="auto">
{media.description}
</span>
</button>
)}
<Media media={media} showOriginal />
<Media media={media} showOriginal lang={lang} />
</div>
);
})}
@ -279,7 +285,8 @@ function MediaModal({
}}
>
<MediaAltModal
alt={showMediaAlt}
alt={showMediaAlt.alt || showMediaAlt}
lang={showMediaAlt?.lang}
onClose={() => setShowMediaAlt(false)}
/>
</Modal>
@ -288,52 +295,4 @@ function MediaModal({
);
}
function MediaAltModal({ alt, onClose }) {
const [forceTranslate, setForceTranslate] = useState(false);
return (
<div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header class="header-grid">
<h2>Media description</h2>
<div class="header-side">
<Menu
align="end"
menuButton={
<button type="button" class="plain4">
<Icon icon="more" alt="More" size="xl" />
</button>
}
>
<MenuItem
disabled={forceTranslate}
onClick={() => {
setForceTranslate(true);
}}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuItem>
</Menu>
</div>
</header>
<main>
<p
style={{
whiteSpace: 'pre-wrap',
}}
>
{alt}
</p>
{forceTranslate && (
<TranslationBlock forceTranslate={forceTranslate} text={alt} />
)}
</main>
</div>
);
}
export default MediaModal;

View file

@ -1,4 +1,6 @@
import { getBlurHashAverageColor } from 'fast-blurhash';
import mem from 'mem';
import { Fragment } from 'preact';
import {
useCallback,
useLayoutEffect,
@ -8,6 +10,8 @@ import {
} from 'preact/hooks';
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
import states from '../utils/states';
import Icon from './icon';
import Link from './link';
import { formatDuration } from './status';
@ -24,7 +28,49 @@ video = Video clip
audio = Audio track
*/
function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
const dataAltLabel = 'ALT';
const AltBadge = (props) => {
const { alt, lang, index, ...rest } = props;
if (!alt || !alt.trim()) return null;
return (
<button
type="button"
class="alt-badge clickable"
{...rest}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
states.showMediaAlt = {
alt,
lang,
};
}}
title="Media description"
>
{dataAltLabel}
{!!index && <sup>{index}</sup>}
</button>
);
};
const MEDIA_CAPTION_LIMIT = 140;
export const isMediaCaptionLong = mem((caption) =>
caption?.length
? caption.length > MEDIA_CAPTION_LIMIT ||
/[\n\r].*[\n\r]/.test(caption.trim())
: false,
);
function Media({
media,
to,
lang,
showOriginal,
autoAnimate,
showCaption,
altIndex,
onClick = () => {},
}) {
const {
blurhash,
description,
@ -103,7 +149,11 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
[to],
);
const isImage = type === 'image' || (type === 'unknown' && previewUrl);
const isVideoMaybe =
type === 'unknown' &&
/\.(mp4|m4a|m4p|m4b|m4r|m4v|mov|webm)$/i.test(remoteMediaURL);
const isImage =
type === 'image' || (type === 'unknown' && previewUrl && !isVideoMaybe);
const parentRef = useRef();
const [imageSmallerThanParent, setImageSmallerThanParent] = useState(false);
@ -129,6 +179,35 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
aspectRatio: `${width} / ${height}`,
};
const longDesc = isMediaCaptionLong(description);
const showInlineDesc =
!!showCaption && !showOriginal && !!description && !longDesc;
const Figure = !showInlineDesc
? Fragment
: (props) => {
const { children, ...restProps } = props;
return (
<figure {...restProps}>
{children}
<figcaption
class="media-caption"
lang={lang}
dir="auto"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showMediaAlt = {
alt: description,
lang,
};
}}
>
{description}
</figcaption>
</figure>
);
};
if (isImage) {
// Note: type: unknown might not have width/height
quickPinchZoomProps.containerProps.style.display = 'inherit';
@ -147,81 +226,89 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
}, [mediaURL]);
return (
<Parent
ref={parentRef}
class={`media media-image`}
onClick={onClick}
data-orientation={orientation}
style={
showOriginal
? {
backgroundImage: `url(${previewUrl})`,
backgroundSize: imageSmallerThanParent
? `${width}px ${height}px`
: undefined,
}
: mediaStyles
}
>
{showOriginal ? (
<QuickPinchZoom {...quickPinchZoomProps}>
<img
ref={mediaRef}
src={mediaURL}
alt={description}
width={width}
height={height}
data-orientation={orientation}
loading="eager"
decoding="sync"
onLoad={(e) => {
e.target.closest('.media-image').style.backgroundImage = '';
e.target.closest('.media-zoom').style.display = '';
setPinchZoomEnabled(true);
}}
onError={(e) => {
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
<Figure>
<Parent
ref={parentRef}
class={`media media-image`}
onClick={onClick}
data-orientation={orientation}
data-has-alt={!showInlineDesc}
style={
showOriginal
? {
backgroundImage: `url(${previewUrl})`,
backgroundSize: imageSmallerThanParent
? `${width}px ${height}px`
: undefined,
}
}}
/>
</QuickPinchZoom>
) : (
<img
src={mediaURL}
alt={description}
width={width}
height={height}
data-orientation={orientation}
loading="lazy"
style={{
backgroundColor:
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
backgroundPosition: focalBackgroundPosition || 'center',
// Duration based on width or height in pixels
// 100px per second (rough estimate)
// Clamp between 5s and 120s
'--anim-duration': `${Math.min(
Math.max(Math.max(width, height) / 100, 5),
120,
)}s`,
}}
onLoad={(e) => {
e.target.closest('.media-image').style.backgroundImage = '';
e.target.dataset.loaded = true;
}}
onError={(e) => {
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
}
}}
/>
)}
</Parent>
: mediaStyles
}
>
{showOriginal ? (
<QuickPinchZoom {...quickPinchZoomProps}>
<img
ref={mediaRef}
src={mediaURL}
alt={description}
width={width}
height={height}
data-orientation={orientation}
loading="eager"
decoding="sync"
onLoad={(e) => {
e.target.closest('.media-image').style.backgroundImage = '';
e.target.closest('.media-zoom').style.display = '';
setPinchZoomEnabled(true);
}}
onError={(e) => {
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
}
}}
/>
</QuickPinchZoom>
) : (
<>
<img
src={mediaURL}
alt={showInlineDesc ? '' : description}
width={width}
height={height}
data-orientation={orientation}
loading="lazy"
style={{
backgroundColor:
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
backgroundPosition: focalBackgroundPosition || 'center',
// Duration based on width or height in pixels
// 100px per second (rough estimate)
// Clamp between 5s and 120s
'--anim-duration': `${Math.min(
Math.max(Math.max(width, height) / 100, 5),
120,
)}s`,
}}
onLoad={(e) => {
e.target.closest('.media-image').style.backgroundImage = '';
e.target.dataset.loaded = true;
}}
onError={(e) => {
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
}
}}
/>
{!showInlineDesc && (
<AltBadge alt={description} lang={lang} index={altIndex} />
)}
</>
)}
</Parent>
</Figure>
);
} else if (type === 'gifv' || type === 'video') {
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
const shortDuration = original.duration < 31;
const isGIF = type === 'gifv' && shortDuration;
// If GIF is too long, treat it as a video
@ -248,117 +335,145 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
`;
return (
<Parent
class={`media media-${isGIF ? 'gif' : 'video'} ${
autoGIFAnimate ? 'media-contain' : ''
}`}
data-orientation={orientation}
data-formatted-duration={formattedDuration}
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
// style={{
// backgroundColor:
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
// }}
style={!showOriginal && mediaStyles}
onClick={(e) => {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
}
onClick(e);
}}
onMouseEnter={() => {
if (hoverAnimate) {
try {
videoRef.current.play();
} catch (e) {}
}
}}
onMouseLeave={() => {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
}
}}
>
{showOriginal || autoGIFAnimate ? (
isGIF && showOriginal ? (
<QuickPinchZoom {...quickPinchZoomProps} enabled>
<Figure>
<Parent
class={`media media-${isGIF ? 'gif' : 'video'} ${
autoGIFAnimate ? 'media-contain' : ''
}`}
data-orientation={orientation}
data-formatted-duration={formattedDuration}
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
data-has-alt={!showInlineDesc}
// style={{
// backgroundColor:
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
// }}
style={!showOriginal && mediaStyles}
onClick={(e) => {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
}
onClick(e);
}}
onMouseEnter={() => {
if (hoverAnimate) {
try {
videoRef.current.play();
} catch (e) {}
}
}}
onMouseLeave={() => {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
}
}}
onFocus={() => {
if (hoverAnimate) {
try {
videoRef.current.play();
} catch (e) {}
}
}}
onBlur={() => {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
}
}}
>
{showOriginal || autoGIFAnimate ? (
isGIF && showOriginal ? (
<QuickPinchZoom {...quickPinchZoomProps} enabled>
<div
ref={mediaRef}
dangerouslySetInnerHTML={{
__html: videoHTML,
}}
/>
</QuickPinchZoom>
) : (
<div
ref={mediaRef}
class="video-container"
dangerouslySetInnerHTML={{
__html: videoHTML,
}}
/>
</QuickPinchZoom>
) : (
<div
class="video-container"
dangerouslySetInnerHTML={{
__html: videoHTML,
}}
)
) : isGIF ? (
<video
ref={videoRef}
src={url}
poster={previewUrl}
width={width}
height={height}
data-orientation={orientation}
preload="auto"
// controls
playsinline
loop
muted
/>
)
) : isGIF ? (
<video
ref={videoRef}
src={url}
poster={previewUrl}
width={width}
height={height}
data-orientation={orientation}
preload="auto"
// controls
playsinline
loop
muted
/>
) : (
<>
) : (
<>
<img
src={previewUrl}
alt={showInlineDesc ? '' : description}
width={width}
height={height}
data-orientation={orientation}
loading="lazy"
/>
<div class="media-play">
<Icon icon="play" size="xl" />
</div>
</>
)}
{!showOriginal && !showInlineDesc && (
<AltBadge alt={description} lang={lang} index={altIndex} />
)}
</Parent>
</Figure>
);
} else if (type === 'audio') {
const formattedDuration = formatDuration(original.duration);
return (
<Figure>
<Parent
class="media media-audio"
data-formatted-duration={formattedDuration}
data-has-alt={!showInlineDesc}
onClick={onClick}
style={!showOriginal && mediaStyles}
>
{showOriginal ? (
<audio src={remoteUrl || url} preload="none" controls autoplay />
) : previewUrl ? (
<img
src={previewUrl}
alt={description}
alt={showInlineDesc ? '' : description}
width={width}
height={height}
data-orientation={orientation}
loading="lazy"
/>
<div class="media-play">
<Icon icon="play" size="xl" />
</div>
</>
)}
</Parent>
);
} else if (type === 'audio') {
const formattedDuration = formatDuration(original.duration);
return (
<Parent
class="media media-audio"
data-formatted-duration={formattedDuration}
onClick={onClick}
style={!showOriginal && mediaStyles}
>
{showOriginal ? (
<audio src={remoteUrl || url} preload="none" controls autoplay />
) : previewUrl ? (
<img
src={previewUrl}
alt={description}
width={width}
height={height}
data-orientation={orientation}
loading="lazy"
/>
) : null}
{!showOriginal && (
<div class="media-play">
<Icon icon="play" size="xl" />
</div>
)}
</Parent>
) : null}
{!showOriginal && (
<>
<div class="media-play">
<Icon icon="play" size="xl" />
</div>
{!showInlineDesc && (
<AltBadge alt={description} lang={lang} index={altIndex} />
)}
</>
)}
</Parent>
</Figure>
);
}
}

View file

@ -2,10 +2,11 @@ import './modal.css';
import { createPortal } from 'preact/compat';
import { useEffect, useRef } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
const $modalContainer = document.getElementById('modal-container');
function Modal({ children, onClick, class: className }) {
function Modal({ children, onClose, onClick, class: className }) {
if (!children) return null;
const modalRef = useRef();
@ -19,8 +20,30 @@ function Modal({ children, onClick, class: className }) {
return () => clearTimeout(timer);
}, []);
const escRef = useHotkeys('esc', onClose, [onClose], {
enabled: !!onClose,
});
const Modal = (
<div ref={modalRef} className={className} onClick={onClick}>
<div
ref={(node) => {
modalRef.current = node;
escRef.current = node?.querySelector?.('[tabindex="-1"]') || node;
}}
className={className}
onClick={(e) => {
onClick?.(e);
if (e.target === e.currentTarget) {
onClose?.(e);
}
}}
tabIndex="-1"
onFocus={(e) => {
if (e.target === e.currentTarget) {
modalRef.current?.querySelector?.('[tabindex="-1"]')?.focus?.();
}
}}
>
{children}
</div>
);

202
src/components/modals.jsx Normal file
View file

@ -0,0 +1,202 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { subscribe, useSnapshot } from 'valtio';
import Accounts from '../pages/accounts';
import Settings from '../pages/settings';
import focusDeck from '../utils/focus-deck';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import AccountSheet from './account-sheet';
import Compose from './compose';
import Drafts from './drafts';
import GenericAccounts from './generic-accounts';
import MediaAltModal from './media-alt-modal';
import MediaModal from './media-modal';
import Modal from './modal';
import ShortcutsSettings from './shortcuts-settings';
subscribe(states, (changes) => {
for (const [action, path, value, prevValue] of changes) {
// When closing modal, focus on deck
if (/^show/i.test(path) && !value) {
focusDeck();
}
}
});
export default function Modals() {
const snapStates = useSnapshot(states);
const navigate = useNavigate();
const location = useLocation();
return (
<>
{!!snapStates.showCompose && (
<Modal>
<Compose
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? 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}`,
);
},
});
}
}}
/>
</Modal>
)}
{!!snapStates.showSettings && (
<Modal
onClose={() => {
states.showSettings = false;
}}
>
<Settings
onClose={() => {
states.showSettings = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccounts && (
<Modal
onClose={() => {
states.showAccounts = false;
}}
>
<Accounts
onClose={() => {
states.showAccounts = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccount && (
<Modal
class="light"
onClose={() => {
states.showAccount = false;
}}
>
<AccountSheet
account={snapStates.showAccount?.account || snapStates.showAccount}
instance={snapStates.showAccount?.instance}
onClose={({ destination } = {}) => {
states.showAccount = false;
if (destination) {
states.showAccounts = false;
}
}}
/>
</Modal>
)}
{!!snapStates.showDrafts && (
<Modal
onClose={() => {
states.showDrafts = false;
}}
>
<Drafts onClose={() => (states.showDrafts = false)} />
</Modal>
)}
{!!snapStates.showMediaModal && (
<Modal
onClick={(e) => {
if (
e.target === e.currentTarget ||
e.target.classList.contains('media')
) {
states.showMediaModal = false;
}
}}
>
<MediaModal
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
instance={snapStates.showMediaModal.instance}
index={snapStates.showMediaModal.index}
statusID={snapStates.showMediaModal.statusID}
onClose={() => {
states.showMediaModal = false;
}}
/>
</Modal>
)}
{!!snapStates.showShortcutsSettings && (
<Modal
class="light"
onClose={() => {
states.showShortcutsSettings = false;
}}
>
<ShortcutsSettings
onClose={() => (states.showShortcutsSettings = false)}
/>
</Modal>
)}
{!!snapStates.showGenericAccounts && (
<Modal
class="light"
onClose={() => {
states.showGenericAccounts = false;
}}
>
<GenericAccounts
onClose={() => (states.showGenericAccounts = false)}
/>
</Modal>
)}
{!!snapStates.showMediaAlt && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showMediaAlt = false;
}
}}
>
<MediaAltModal
alt={snapStates.showMediaAlt.alt || snapStates.showMediaAlt}
lang={snapStates.showMediaAlt?.lang}
onClose={() => {
states.showMediaAlt = false;
}}
/>
</Modal>
)}
</>
);
}

View file

@ -1,5 +1,7 @@
import './name-text.css';
import { memo } from 'preact/compat';
import states from '../utils/states';
import Avatar from './avatar';
@ -43,6 +45,7 @@ function NameText({
onClick={(e) => {
if (external) return;
e.preventDefault();
e.stopPropagation();
if (onClick) return onClick(e);
states.showAccount = {
account,
@ -68,9 +71,9 @@ function NameText({
)}
</>
) : short ? (
<i>@{username}</i>
<i>{username}</i>
) : (
<b>@{username}</b>
<b>{username}</b>
)}
{showAcct && (
<>
@ -85,4 +88,4 @@ function NameText({
);
}
export default NameText;
export default memo(NameText);

View file

@ -1,12 +1,22 @@
@media (min-width: 23em) {
.nav-menu {
display: flex;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
'top top'
'left right';
padding: 0;
width: 22em;
}
.nav-menu .top-menu {
grid-area: top;
padding-top: 8px;
margin-bottom: -8px;
}
.nav-menu section {
padding: 8px 0;
width: 50%;
/* width: 50%; */
}
@keyframes phanpying {
0% {
@ -49,3 +59,17 @@
width: 28em;
}
}
@keyframes sparkle-icon {
0% {
transform: scale(1);
color: var(--red-color);
}
100% {
transform: scale(1.2);
color: var(--orange-color);
}
}
.sparkle-icon {
animation: sparkle-icon 0.3s ease-in-out infinite alternate;
}

View file

@ -1,6 +1,11 @@
import './nav-menu.css';
import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import {
ControlledMenu,
Menu,
MenuDivider,
MenuItem,
} from '@szhsin/react-menu';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useLongPress } from 'use-long-press';
import { useSnapshot } from 'valtio';
@ -16,7 +21,7 @@ import MenuLink from './menu-link';
function NavMenu(props) {
const snapStates = useSnapshot(states);
const { instance, authenticated } = api();
const { masto, instance, authenticated } = api();
const [currentAccount, setCurrentAccount] = useState();
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
@ -60,6 +65,28 @@ function NavMenu(props) {
0,
]);
const mutesIterator = useRef();
async function fetchMutes(firstLoad) {
if (firstLoad || !mutesIterator.current) {
mutesIterator.current = masto.v1.mutes.list({
limit: 80,
});
}
const results = await mutesIterator.current.next();
return results;
}
const blocksIterator = useRef();
async function fetchBlocks(firstLoad) {
if (firstLoad || !blocksIterator.current) {
blocksIterator.current = masto.v1.blocks.list({
limit: 80,
});
}
const results = await blocksIterator.current.next();
return results;
}
return (
<>
<button
@ -115,28 +142,28 @@ function NavMenu(props) {
boundingBoxPadding={boundingBoxPadding}
unmountOnClose
>
{!!snapStates.appVersion?.commitHash &&
__COMMIT_HASH__ !== snapStates.appVersion.commitHash && (
<div class="top-menu">
<MenuItem
onClick={() => {
const yes = confirm('Reload page now to update?');
if (yes) {
(async () => {
try {
location.reload();
} catch (e) {}
})();
}
}}
>
<Icon icon="sparkles" class="sparkle-icon" size="l" />{' '}
<span>New update available</span>
</MenuItem>
<MenuDivider />
</div>
)}
<section>
{!!snapStates.appVersion?.commitHash &&
__COMMIT_HASH__ !== snapStates.appVersion.commitHash && (
<>
<MenuItem
onClick={() => {
const yes = confirm('Reload page now to update?');
if (yes) {
(async () => {
try {
location.reload();
} catch (e) {}
})();
}
}}
>
<Icon icon="sparkles" size="l" />{' '}
<span>New update available</span>
</MenuItem>
<MenuDivider />
</>
)}
<MenuLink to="/">
<Icon icon="home" size="l" /> <span>Home</span>
</MenuLink>
@ -204,6 +231,37 @@ function NavMenu(props) {
>
<Icon icon="group" size="l" /> <span>Accounts&hellip;</span>
</MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'mute',
heading: 'Muted users',
fetchAccounts: fetchMutes,
};
}}
>
<Icon icon="mute" size="l" /> Muted users&hellip;
</MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'block',
heading: 'Blocked users',
fetchAccounts: fetchBlocks,
};
}}
>
<Icon icon="block" size="l" />
Blocked users&hellip;
</MenuItem>
<MenuItem
onClick={() => {
states.showKeyboardShortcutsHelp = true;
}}
>
<Icon icon="keyboard" size="l" />{' '}
<span>Keyboard shortcuts</span>
</MenuItem>
<MenuItem
onClick={() => {
states.showShortcutsSettings = true;
@ -226,6 +284,13 @@ function NavMenu(props) {
<MenuLink to="/login">
<Icon icon="user" size="l" /> <span>Log in</span>
</MenuLink>
<MenuItem
onClick={() => {
states.showSettings = true;
}}
>
<Icon icon="gear" size="l" /> <span>Settings&hellip;</span>
</MenuItem>
</>
)}
</section>

View file

@ -0,0 +1,188 @@
import { memo } from 'preact/compat';
import { useLayoutEffect, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import { api } from '../utils/api';
import states from '../utils/states';
import {
getAccountByAccessToken,
getCurrentAccount,
} from '../utils/store-utils';
import usePageVisibility from '../utils/usePageVisibility';
import Icon from './icon';
import Link from './link';
import Modal from './modal';
import Notification from './notification';
export default memo(function NotificationService() {
if (!('serviceWorker' in navigator)) return null;
const snapStates = useSnapshot(states);
const { routeNotification } = snapStates;
console.log('🛎️ Notification service', routeNotification);
const { id, accessToken } = routeNotification || {};
const [showNotificationSheet, setShowNotificationSheet] = useState(false);
useLayoutEffect(() => {
if (!id || !accessToken) return;
const { instance: currentInstance } = api();
const { masto, instance } = api({
accessToken,
});
console.log('API', { accessToken, currentInstance, instance });
const sameInstance = currentInstance === instance;
const account = accessToken
? getAccountByAccessToken(accessToken)
: getCurrentAccount();
(async () => {
const notification = await masto.v1.notifications.fetch(id);
if (notification && account) {
console.log('🛎️ Notification', { id, notification, account });
const accountInstance = account.instanceURL;
const { type, status, account: notificationAccount } = notification;
const hasModal = !!document.querySelector('#modal-container > *');
const isFollow = type === 'follow' && !!notificationAccount?.id;
const hasAccount = !!notificationAccount?.id;
const hasStatus = !!status?.id;
if (isFollow && sameInstance) {
// Show account sheet, can handle different instances
states.showAccount = {
account: notificationAccount,
instance: accountInstance,
};
} else if (hasModal || !sameInstance || (hasAccount && hasStatus)) {
// Show sheet of notification, if
// - there is a modal open
// - the notification is from another instance
// - the notification has both account and status, gives choice for users to go to account or status
setShowNotificationSheet({
id,
account,
notification,
sameInstance,
});
} else {
if (hasStatus) {
// Go to status page
location.hash = `/${currentInstance}/s/${status.id}`;
} else if (isFollow) {
// Go to profile page
location.hash = `/${currentInstance}/a/${notificationAccount.id}`;
} else {
// Go to notifications page
location.hash = '/notifications';
}
}
} else {
console.warn(
'🛎️ Notification not found',
notificationID,
notificationAccessToken,
);
}
})();
}, [id, accessToken]);
useLayoutEffect(() => {
// Listen to message from service worker
const handleMessage = (event) => {
console.log('💥💥💥 Message event', event);
const { type, id, accessToken } = event?.data || {};
if (type === 'notification') {
states.routeNotification = {
id,
accessToken,
};
}
};
console.log('👂👂👂 Listen to message');
navigator.serviceWorker.addEventListener('message', handleMessage);
return () => {
console.log('👂👂👂 Remove listen to message');
navigator.serviceWorker.removeEventListener('message', handleMessage);
};
}, []);
useLayoutEffect(() => {
if (navigator?.clearAppBadge) {
navigator.clearAppBadge();
}
}, []);
usePageVisibility((visible) => {
if (visible && navigator?.clearAppBadge) {
console.log('🔰 Clear app badge');
navigator.clearAppBadge();
}
});
const onClose = () => {
setShowNotificationSheet(false);
states.routeNotification = null;
// If url is #/notifications?id=123, go to #/notifications
if (/\/notifications\?id=/i.test(location.hash)) {
location.hash = '/notifications';
}
};
if (showNotificationSheet) {
const { id, account, notification, sameInstance } = showNotificationSheet;
return (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div class="sheet" tabIndex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<b>Notification</b>
</header>
<main>
{!sameInstance && (
<p>This notification is from your other account.</p>
)}
<div
class="notification-peek"
// style={{
// pointerEvents: sameInstance ? '' : 'none',
// }}
onClick={(e) => {
const { target } = e;
// If button or links
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A') {
onClose();
}
}}
>
<Notification
instance={account.instanceURL}
notification={notification}
isStatic
/>
</div>
<div
style={{
textAlign: 'end',
}}
>
<Link to="/notifications" class="button light" onClick={onClose}>
<span>View all notifications</span> <Icon icon="arrow-right" />
</Link>
</div>
</main>
</div>
</Modal>
);
}
return null;
});

View file

@ -1,5 +1,7 @@
import shortenNumber from '../utils/shorten-number';
import states from '../utils/states';
import store from '../utils/store';
import useTruncated from '../utils/useTruncated';
import Avatar from './avatar';
import FollowRequestButtons from './follow-request-buttons';
@ -18,6 +20,8 @@ const NOTIFICATION_ICONS = {
favourite: 'heart',
poll: 'poll',
update: 'pencil',
'admin.signup': 'account-edit',
'admin.report': 'account-warning',
};
/*
@ -54,14 +58,19 @@ const contentText = {
'favourite+reblog+account': (count) =>
`boosted & favourited ${count} of your posts.`,
'favourite+reblog_reply': 'boosted & favourited your reply.',
'admin.signup': 'signed up.',
'admin.report': 'reported a post.',
};
function Notification({ notification, instance, reload }) {
const AVATARS_LIMIT = 50;
function Notification({ notification, instance, reload, isStatic }) {
const { id, status, account, _accounts, _statuses } = notification;
let { type } = notification;
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
const actualStatusID = status?.reblog?.id || status?.id;
const actualStatus = status?.reblog || status;
const actualStatusID = actualStatus?.id;
const currentAccount = store.session.get('currentAccount');
const isSelf = currentAccount === account?.id;
@ -101,9 +110,14 @@ function Notification({ notification, instance, reload }) {
} else {
text = contentText[type];
}
} else {
} else if (contentText[type]) {
text = contentText[type];
} else {
// Anticipate unhandled notification types, possibly from Mastodon forks or non-Mastodon instances
// This surfaces the error to the user, hoping that users will report it
text = `[Unknown notification type: ${type}]`;
}
if (typeof text === 'function') {
text = text(_statuses?.length || _accounts?.length);
}
@ -113,11 +127,29 @@ function Notification({ notification, instance, reload }) {
return null;
}
const formattedCreatedAt =
notification.createdAt && new Date(notification.createdAt).toLocaleString();
const genericAccountsHeading =
{
'favourite+reblog': 'Boosted/Favourited by…',
favourite: 'Favourited by…',
reblog: 'Boosted by…',
follow: 'Followed by…',
}[type] || 'Accounts';
const handleOpenGenericAccounts = () => {
states.showGenericAccounts = {
heading: genericAccountsHeading,
accounts: _accounts,
showReactions: type === 'favourite+reblog',
};
};
return (
<div class={`notification notification-${type}`} tabIndex="0">
<div
class={`notification-type notification-${type}`}
title={new Date(notification.createdAt).toLocaleString()}
title={formattedCreatedAt}
>
{type === 'favourite+reblog' ? (
<>
@ -140,7 +172,12 @@ function Notification({ notification, instance, reload }) {
<>
{_accounts?.length > 1 ? (
<>
<b>{_accounts.length} people</b>{' '}
<b tabIndex="0" onClick={handleOpenGenericAccounts}>
<span title={_accounts.length}>
{shortenNumber(_accounts.length)}
</span>{' '}
people
</b>{' '}
</>
) : (
<>
@ -173,7 +210,7 @@ function Notification({ notification, instance, reload }) {
)}
{_accounts?.length > 1 && (
<p class="avatars-stack">
{_accounts.map((account, i) => (
{_accounts.slice(0, AVATARS_LIMIT).map((account, i) => (
<>
<a
href={account.url}
@ -189,11 +226,11 @@ function Notification({ notification, instance, reload }) {
size={
_accounts.length <= 10
? 'xxl'
: _accounts.length < 100
: _accounts.length < 20
? 'xl'
: _accounts.length < 1000
: _accounts.length < 30
? 'l'
: _accounts.length < 2000
: _accounts.length < 40
? 'm'
: 's' // My god, this person is popular!
}
@ -215,39 +252,71 @@ function Notification({ notification, instance, reload }) {
</a>{' '}
</>
))}
<button
type="button"
class="small plain"
onClick={handleOpenGenericAccounts}
>
{_accounts.length > AVATARS_LIMIT &&
`+${_accounts.length - AVATARS_LIMIT}`}
<Icon icon="chevron-down" />
</button>
</p>
)}
{_statuses?.length > 1 && (
<ul class="notification-group-statuses">
{_statuses.map((status) => (
<li key={status.id}>
<Link
<TruncatedLink
class={`status-link status-type-${type}`}
to={
instance ? `/${instance}/s/${status.id}` : `/s/${status.id}`
}
>
<Status status={status} size="s" />
</Link>
</TruncatedLink>
</li>
))}
</ul>
)}
{status && (!_statuses?.length || _statuses?.length <= 1) && (
<Link
<TruncatedLink
class={`status-link status-type-${type}`}
to={
instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`
}
onContextMenu={(e) => {
const post = e.target.querySelector('.status');
if (post) {
// Fire a custom event to open the context menu
if (e.metaKey) return;
e.preventDefault();
post.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: e.clientX,
clientY: e.clientY,
}),
);
}
}}
>
<Status statusID={actualStatusID} size="s" />
</Link>
{isStatic ? (
<Status status={actualStatus} size="s" />
) : (
<Status statusID={actualStatusID} size="s" />
)}
</TruncatedLink>
)}
</div>
</div>
);
}
function TruncatedLink(props) {
const ref = useTruncated();
return <Link {...props} data-read-more="Read more →" ref={ref} />;
}
export default Notification;

View file

@ -199,6 +199,7 @@ export default function Poll({
setUIState('default');
})();
}}
title="Refresh"
>
<Icon icon="refresh" alt="Refresh" />
</button>
@ -212,6 +213,7 @@ export default function Poll({
e.preventDefault();
setShowResults(!showResults);
}}
title={showResults ? 'Hide results' : 'Show results'}
>
<Icon
icon={showResults ? 'eye-open' : 'eye-close'}

View file

@ -0,0 +1,54 @@
#search-command-container {
position: fixed;
inset: 0;
z-index: 1002;
background-color: var(--backdrop-darker-color);
background-image: radial-gradient(
farthest-corner at top,
var(--backdrop-color),
transparent
);
display: flex;
justify-content: center;
align-items: flex-start;
padding: 16px;
transition: opacity 0.1s ease-in-out;
}
#search-command-container[hidden] {
opacity: 0;
pointer-events: none;
}
#search-command-container form {
width: calc(40em - 32px);
max-width: 100%;
transition: transform 0.1s ease-in-out;
}
#search-command-container[hidden] form {
transform: translateY(-64px) scale(0.9);
}
#search-command-container input {
width: 100%;
padding: 16px;
border-radius: 999px;
background-color: var(--bg-faded-color);
border: 2px solid var(--outline-color);
box-shadow: 0 2px 16px var(--drop-shadow-color),
0 32px 64px var(--drop-shadow-color);
}
#search-command-container input:focus {
outline: 0;
background-color: var(--bg-color);
border-color: var(--link-color);
}
@media (min-width: 40em) {
#search-command-container {
align-items: center;
background-image: radial-gradient(
closest-side,
var(--backdrop-color),
transparent
);
}
}

View file

@ -0,0 +1,69 @@
import './search-command.css';
import { memo } from 'preact/compat';
import { useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import SearchForm from './search-form';
export default memo(function SearchCommand({ onClose = () => {} }) {
const [showSearch, setShowSearch] = useState(false);
const searchFormRef = useRef(null);
useHotkeys(
'/',
(e) => {
setShowSearch(true);
setTimeout(() => {
searchFormRef.current?.focus?.();
searchFormRef.current?.select?.();
}, 0);
},
{
preventDefault: true,
ignoreEventWhen: (e) => {
const isSearchPage = /\/search/.test(location.hash);
const hasModal = !!document.querySelector('#modal-container > *');
return isSearchPage || hasModal;
},
},
);
const closeSearch = () => {
setShowSearch(false);
onClose();
};
useHotkeys(
'esc',
(e) => {
searchFormRef.current?.blur?.();
closeSearch();
},
{
enabled: showSearch,
enableOnFormTags: true,
preventDefault: true,
},
);
return (
<div
id="search-command-container"
hidden={!showSearch}
onClick={(e) => {
console.log(e);
if (e.target === e.currentTarget) {
closeSearch();
}
}}
>
<SearchForm
ref={searchFormRef}
onSubmit={() => {
closeSearch();
}}
/>
</div>
);
});

View file

@ -0,0 +1,250 @@
import { forwardRef } from 'preact/compat';
import { useImperativeHandle, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
import { api } from '../utils/api';
import Icon from './icon';
import Link from './link';
const SearchForm = forwardRef((props, ref) => {
const { instance } = api();
const [searchParams, setSearchParams] = useSearchParams();
const [searchMenuOpen, setSearchMenuOpen] = useState(false);
const [query, setQuery] = useState(searchParams.get('q') || '');
const type = searchParams.get('type');
const formRef = useRef(null);
const searchFieldRef = useRef(null);
useImperativeHandle(ref, () => ({
setValue: (value) => {
setQuery(value);
},
focus: () => {
searchFieldRef.current.focus();
},
select: () => {
searchFieldRef.current.select();
},
blur: () => {
searchFieldRef.current.blur();
},
}));
return (
<form
ref={formRef}
class="search-popover-container"
onSubmit={(e) => {
e.preventDefault();
const isSearchPage = /\/search/.test(location.hash);
if (isSearchPage) {
if (query) {
const params = {
q: query,
};
if (type) params.type = type; // Preserve type
setSearchParams(params);
} else {
setSearchParams({});
}
} else {
if (query) {
location.hash = `/search?q=${encodeURIComponent(query)}${
type ? `&type=${type}` : ''
}`;
} else {
location.hash = `/search`;
}
}
props?.onSubmit?.(e);
}}
>
<input
ref={searchFieldRef}
value={query}
name="q"
type="search"
// autofocus
placeholder="Search"
dir="auto"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
onSearch={(e) => {
if (!e.target.value) {
setSearchParams({});
}
}}
onInput={(e) => {
setQuery(e.target.value);
setSearchMenuOpen(true);
}}
onFocus={() => {
setSearchMenuOpen(true);
}}
onBlur={() => {
setTimeout(() => {
setSearchMenuOpen(false);
}, 100);
formRef.current
?.querySelector('.search-popover-item.focus')
?.classList.remove('focus');
}}
onKeyDown={(e) => {
const { key } = e;
switch (key) {
case 'Escape':
setSearchMenuOpen(false);
break;
case 'Down':
case 'ArrowDown':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = formRef.current.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let nextItem = focusItem.nextElementSibling;
while (nextItem && nextItem.hidden) {
nextItem = nextItem.nextElementSibling;
}
if (nextItem) {
nextItem.classList.add('focus');
const siblings = Array.from(
nextItem.parentElement.children,
).filter((el) => el !== nextItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const firstItem = formRef.current.querySelector(
'.search-popover-item',
);
if (firstItem) {
firstItem.classList.add('focus');
}
}
}
break;
case 'Up':
case 'ArrowUp':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let prevItem = focusItem.previousElementSibling;
while (prevItem && prevItem.hidden) {
prevItem = prevItem.previousElementSibling;
}
if (prevItem) {
prevItem.classList.add('focus');
const siblings = Array.from(
prevItem.parentElement.children,
).filter((el) => el !== prevItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const lastItem = document.querySelector(
'.search-popover-item:last-child',
);
if (lastItem) {
lastItem.classList.add('focus');
}
}
}
break;
case 'Enter':
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
e.preventDefault();
focusItem.click();
}
setSearchMenuOpen(false);
props?.onSubmit?.(e);
}
break;
}
}}
/>
<div class="search-popover" hidden={!searchMenuOpen || !query}>
{!!query &&
[
{
label: (
<>
Posts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=statuses`,
hidden: /^https?:/.test(query),
},
{
label: (
<>
Posts tagged with <mark>#{query.replace(/^#/, '')}</mark>
</>
),
to: `/${instance}/t/${query.replace(/^#/, '')}`,
hidden:
/^@/.test(query) || /^https?:/.test(query) || /\s/.test(query),
top: /^#/.test(query),
type: 'link',
},
{
label: (
<>
Look up <mark>{query}</mark>
</>
),
to: `/${query}`,
hidden: !/^https?:/.test(query),
top: /^https?:/.test(query),
type: 'link',
},
{
label: (
<>
Accounts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=accounts`,
},
]
.sort((a, b) => {
if (a.top && !b.top) return -1;
if (!a.top && b.top) return 1;
return 0;
})
.map(({ label, to, hidden, type }) => (
<Link
to={to}
class="search-popover-item"
hidden={hidden}
onClick={(e) => {
props?.onSubmit?.(e);
}}
>
<Icon
icon={type === 'link' ? 'arrow-right' : 'search'}
class="more-insignificant"
/>
<span>{label}</span>{' '}
</Link>
))}
</div>
</form>
);
});
export default SearchForm;

View file

@ -93,6 +93,38 @@
text-decoration: none;
color: var(--text-color);
}
.status-card-link:not(
.truncated .status-card-link, /* parent status already truncated */
.status-card-link .status-card-link /* nested status cards */
):has(.truncated) {
display: block;
position: relative;
&:after {
content: attr(data-read-more);
line-height: 1;
display: inline-block;
position: absolute;
--inset-offset: 16px;
inset-block-end: var(--inset-offset);
inset-inline-end: var(--inset-offset);
color: var(--text-color);
background-color: var(--bg-faded-color);
border: 1px dashed var(--link-color);
box-shadow: 0 0 0 1px var(--bg-color), 0 -5px 10px var(--bg-color),
0 -5px 15px var(--bg-color), 0 -5px 20px var(--bg-color);
padding: 0.5em 0.75em;
border-radius: 10em;
font-size: 90%;
white-space: nowrap;
transition: transform 0.2s ease-out;
}
&:is(:hover, :focus):after {
color: var(--link-color);
transform: translate(2px, 0);
}
}
.status-card-link:is(:hover, :focus) .status-card {
border-color: var(--outline-hover-color);
box-shadow: inset 0 0 0 4px var(--bg-faded-blur-color);
@ -131,11 +163,19 @@
:is(.content, .poll, .media-container) {
max-height: 80px !important;
}
.status-card :is(.content, .poll) {
.status.large .status-card :is(.content, .poll, .media-container) {
max-height: 80vh !important;
}
.status-card :is(.content, .poll, .media-container) {
font-size: inherit !important;
}
.status-card :is(.content.truncated, .poll, .media-container.truncated) {
/* font-size: inherit !important; */
mask-image: linear-gradient(to bottom, #000 80px, transparent);
}
.status.small .status-card :is(.content, .poll) {
.status.small
.status-card
:is(.content.truncated, .poll, .media-container.truncated) {
mask-image: linear-gradient(to bottom, #000 40px, transparent);
}
.status-card .card {
@ -174,6 +214,10 @@
display: flex;
gap: 8px;
align-items: center;
.status-carousel & {
padding: 16px 16px 16px 24px;
}
}
.status.filtered .status-filtered-info {
pointer-events: none;
@ -246,8 +290,8 @@
.status > .container > .meta {
display: flex;
gap: 8px;
justify-content: space-between;
gap: 4px;
/* justify-content: space-between; */
white-space: nowrap;
}
.status.small > .container > .meta {
@ -256,7 +300,11 @@
.status > .container > .meta > * {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
/* text-overflow: ellipsis; */
}
.status > .container > .meta .meta-name {
mask-image: linear-gradient(to left, transparent, black 16px);
flex-grow: 1;
}
.status.large > .container > .meta {
min-height: 50px;
@ -412,12 +460,17 @@
.status
.content-container.has-spoiler:not(.show-spoiler)
.spoiler
~ *:not(.media-container, .card),
~ *:not(.media-container, .card, .media-figure-multiple),
.status
.content-container.has-spoiler:not(.show-spoiler)
.spoiler
~ .card
.meta-container {
.meta-container,
.status
.content-container.has-spoiler:not(.show-spoiler)
.spoiler
~ :is(.media-container, .media-figure-multiple)
figcaption {
filter: blur(5px) invert(0.5);
image-rendering: crisp-edges;
image-rendering: pixelated;
@ -430,7 +483,7 @@
.status
.content-container.has-spoiler:not(.show-spoiler)
.spoiler
~ .media-container
~ :is(.media-container, .media-figure-multiple)
.media
> *,
.status
@ -494,7 +547,7 @@
max-height: 40vh;
max-height: 40dvh;
}
.timeline-deck .status .content.truncated {
.timeline-deck .status:not(.truncated .status) .content.truncated {
mask-image: linear-gradient(
to top,
transparent,
@ -502,7 +555,7 @@
black 1.5em
);
}
.timeline-deck .status .content.truncated:after {
.timeline-deck .status:not(.truncated .status) .content.truncated:after {
content: attr(data-read-more);
line-height: 1;
display: inline-block;
@ -560,8 +613,20 @@
.status .content blockquote {
margin-block: min(0.75em, 12px);
margin-inline: 0;
padding: 0 0 0 8px;
border-left: 4px solid var(--link-faded-color);
padding-block: 0;
padding-inline: 12px 0;
/* border-left: 4px solid var(--link-faded-color); */
position: relative;
&:before {
position: absolute;
content: '';
width: 3px;
background-color: var(--link-faded-color);
inset-block: 0;
inset-inline-start: 0;
border-radius: 9999px;
}
}
.status .content > :is(ul, ol),
.status .content > div > :is(ul, ol) {
@ -601,7 +666,7 @@
grid-auto-rows: 1fr;
gap: 2px;
/* height: 160px; */
min-height: 44px;
min-height: 88px;
height: auto;
max-height: max(160px, 33vh);
}
@ -626,6 +691,7 @@
max-height: 60vh;
}
.status .media-container .media {
box-sizing: content-box;
--media-border-width: 1px;
--media-radius: 16px;
--media-radius-inner: 4px;
@ -641,13 +707,37 @@
width: auto !important;
max-width: 100%;
display: block;
figure {
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
column-gap: 4px;
figcaption {
padding: 4px;
font-size: 90%;
color: var(--text-insignificant-color);
line-height: 1.2;
cursor: pointer;
white-space: pre-line;
flex-basis: 15em;
flex-grow: 1;
}
}
&:hover figure figcaption {
color: var(--text-color);
}
}
.status .media-container.media-eq1 .media {
display: inline-block;
max-width: 100% !important;
min-width: 44px;
min-width: 88px;
/* width: auto; */
min-height: 44px;
min-height: 88px;
/* --maxAspectHeight: max(160px, 33vh);
--aspectWidth: calc(--width / --height * var(--maxAspectHeight)); */
width: min(var(--aspectWidth), var(--width), 100%);
@ -745,8 +835,9 @@
.status .media:is(:hover, :focus) {
border-color: var(--outline-hover-color);
}
.status .media:active {
.status .media:active:not(:has(button:active)) {
filter: brightness(0.8);
transform: scale(0.99);
}
.status .media :is(img, video) {
width: 100%;
@ -756,6 +847,22 @@
}
.status .media {
cursor: pointer;
&[data-has-alt] {
position: relative;
.alt-badge {
position: absolute;
bottom: 8px;
left: 8px;
&:before {
content: '';
position: absolute;
inset: -12px;
}
}
}
}
.status .media img:is(:hover, :focus),
a:focus-visible .status .media img {
@ -769,7 +876,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
.status .media video {
width: 100%;
height: 100%;
object-fit: contain;
object-fit: scale-down;
/* border-radius: calc(var(--media-radius) - var(--media-border-width)); */
border-radius: inherit;
}
@ -777,7 +884,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: relative;
background-clip: padding-box;
}
.status :is(.media-video, .media-audio)[data-formatted-duration] .media-play {
.status :is(.media-video, .media-audio) .media-play {
pointer-events: none;
width: 44px;
height: 44px;
@ -785,23 +892,17 @@ body:has(#modal-container .carousel) .status .media img:hover {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: var(--text-color);
background-color: var(--bg-blur-color);
/* backdrop-filter: blur(6px) saturate(3) invert(0.2); */
box-shadow: 0 0 16px var(--drop-shadow-color);
color: var(--media-fg-color);
background-color: var(--media-bg-color);
box-shadow: inset 0 0 0 2px var(--media-outline-color);
display: flex;
place-content: center;
place-items: center;
border-radius: 70px;
transition: all 0.2s ease-in-out;
transition: transform 0.2s ease-in-out;
}
.status
:is(.media-video, .media-audio)[data-formatted-duration]:hover:not(:active)
.media-play {
.status :is(.media-video, .media-audio):hover:not(:active) .media-play {
transform: translate(-50%, -50%) scale(1.1);
background-color: var(--bg-color);
box-shadow: 0 0 16px var(--drop-shadow-color),
0 0 8px var(--drop-shadow-color);
}
.status :is(.media-video, .media-audio)[data-formatted-duration]:after {
font-size: 12px;
@ -810,9 +911,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: absolute;
bottom: 8px;
right: 8px;
color: var(--bg-color);
background-color: var(--text-color);
backdrop-filter: blur(6px) saturate(3) invert(0.2);
color: var(--media-fg-color);
background-color: var(--media-bg-color);
border: var(--hairline-width) solid var(--media-outline-color);
border-radius: 4px;
padding: 0 4px;
}
@ -827,9 +928,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: absolute;
bottom: 8px;
right: 8px;
color: var(--bg-faded-color);
background-color: var(--text-insignificant-color);
backdrop-filter: blur(6px) saturate(3) invert(0.2);
color: var(--media-fg-color);
background-color: var(--media-bg-color);
border: var(--hairline-width) solid var(--media-outline-color);
border-radius: 4px;
padding: 0 4px;
}
@ -842,7 +943,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
width: fit-content;
}
.status .media-contain video {
object-fit: contain !important;
object-fit: scale-down !important;
}
/* .status .media-audio {
border: 0;
@ -879,6 +980,15 @@ body:has(#modal-container .carousel) .status .media img:hover {
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
/* Convert breaks to spaces */
br {
display: none;
+ * {
margin-left: 1ex;
}
}
}
.status:not(.large) .hashtag-stuffing:first-child {
display: -webkit-box;
@ -887,6 +997,37 @@ body:has(#modal-container .carousel) .status .media img:hover {
white-space: normal;
}
.media-figure-multiple {
margin: 0;
padding: 0;
figcaption {
padding: 4px;
font-size: 90%;
color: var(--text-insignificant-color);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
& > * {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
color: var(--text-color);
cursor: pointer;
}
}
sup {
opacity: 0.75;
font-variant-numeric: tabular-nums;
}
}
}
.carousel-item {
position: relative;
}
@ -911,6 +1052,12 @@ body:has(#modal-container .carousel) .status .media img:hover {
font-size: 90%;
z-index: 1;
text-shadow: 0 var(--hairline-width) var(--bg-color);
mix-blend-mode: luminosity;
white-space: pre-line;
&:is(:hover, :focus) {
mix-blend-mode: normal;
}
}
.carousel-item button.media-alt .media-alt-desc {
overflow: hidden;
@ -1055,6 +1202,9 @@ a:focus-visible .card img {
overflow: hidden;
display: block;
}
.card:visited .meta.domain {
color: var(--link-visited-color);
}
.card .meta.domain * {
vertical-align: middle;
}
@ -1066,6 +1216,9 @@ a.card:is(:hover, :focus) {
border: 1px solid var(--link-color);
box-shadow: 0 0 0 2px var(--link-faded-color);
}
a.card:is(:hover, :focus):visited {
border-color: var(--link-visited-color);
}
.card.video {
max-width: 320px;
max-height: 320px;
@ -1392,10 +1545,13 @@ a.card:is(:hover, :focus) {
}
.shortcode-emoji {
width: 1.2em;
width: auto;
min-width: 1.2em;
max-width: 100%;
height: 1.2em;
vertical-align: text-bottom;
object-fit: contain;
object-fit: cover;
object-position: left;
}
/* EDIT HISTORY */
@ -1452,26 +1608,52 @@ a.card:is(:hover, :focus) {
text-overflow: ellipsis;
}
#filtered-status-peek .status-link {
border-radius: 16px;
border: var(--hairline-width) dashed var(--text-insignificant-color);
max-height: 33vh;
max-height: 33dvh;
overflow: hidden;
}
#filtered-status-peek .status-link .status {
pointer-events: none;
font-size: 90%;
max-height: 33vh;
max-height: 33dvh;
overflow: hidden;
mask-image: linear-gradient(black 80%, transparent 95%);
}
#filtered-status-peek .status-post-link {
float: right;
position: sticky;
bottom: 8px;
right: 8px;
#filtered-status-peek {
.status-link {
margin: 8px 0 0;
border-radius: 16px;
border: var(--hairline-width) solid var(--divider-color);
position: relative;
max-height: 33vh;
max-height: 33dvh;
overflow: hidden;
&.truncated {
.status {
mask-image: linear-gradient(to bottom, #000 80px, transparent);
}
&:after {
content: attr(data-read-more);
line-height: 1;
display: inline-block;
position: absolute;
--inset-offset: 16px;
inset-block-end: var(--inset-offset);
inset-inline-end: var(--inset-offset);
color: var(--text-color);
background-color: var(--bg-faded-color);
border: 1px dashed var(--link-color);
box-shadow: 0 0 0 1px var(--bg-color), 0 -5px 10px var(--bg-color),
0 -5px 15px var(--bg-color), 0 -5px 20px var(--bg-color);
padding: 0.5em 0.75em;
border-radius: 10em;
font-size: 90%;
white-space: nowrap;
transition: transform 0.2s ease-out;
}
&:is(:hover, :focus):after {
color: var(--link-color);
transform: translate(2px, 0);
}
}
.status {
pointer-events: none;
font-size: 90%;
}
}
}
/* REACTIONS */
@ -1511,3 +1693,37 @@ a.card:is(:hover, :focus) {
#reactions-container .reactions-block .reblog-icon {
color: var(--reblog-color);
}
/* ALT BADGE */
.alt-badge {
font-size: 12px;
font-weight: bold;
color: var(--media-fg-color);
background-color: var(--media-bg-color);
border: var(--hairline-width) solid var(--media-outline-color);
mix-blend-mode: luminosity;
border-radius: 4px;
padding: 4px;
opacity: 0.65;
sup {
vertical-align: super;
font-weight: normal;
line-height: 0;
padding-left: 2px;
}
&.clickable {
opacity: 0.75;
border-width: 2px;
&:is(:hover, :focus):not(:active) {
transition: 0.15s ease-out;
transition-property: transform, opacity, mix-blend-mode;
transform: scale(1.15);
opacity: 0.9;
mix-blend-mode: normal;
}
}
}

View file

@ -19,9 +19,9 @@ import {
useRef,
useState,
} from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useLongPress } from 'use-long-press';
import useResizeObserver from 'use-resize-observer';
import { useSnapshot } from 'valtio';
import { snapshot } from 'valtio/vanilla';
@ -33,6 +33,7 @@ import Modal from '../components/modal';
import NameText from '../components/name-text';
import Poll from '../components/poll';
import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
import enhanceContent from '../utils/enhance-content';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import getHTMLText from '../utils/getHTMLText';
@ -47,12 +48,14 @@ import showToast from '../utils/show-toast';
import states, { getStatus, saveStatus, statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import store from '../utils/store';
import useTruncated from '../utils/useTruncated';
import visibilityIconsMap from '../utils/visibility-icons-map';
import Avatar from './avatar';
import Icon from './icon';
import Link from './link';
import Media from './media';
import { isMediaCaptionLong } from './media';
import MenuLink from './menu-link';
import RelativeTime from './relative-time';
import TranslationBlock from './translation-block';
@ -227,7 +230,12 @@ function Status({
inReplyToAccountId === currentAccount ||
mentions?.find((mention) => mention.id === currentAccount);
const showSpoiler = previewMode || !!snapStates.spoilers[id] || false;
const readingExpandSpoilers = useMemo(() => {
const prefs = store.account.get('preferences') || {};
return !!prefs['reading:expand:spoilers'];
}, []);
const showSpoiler =
previewMode || readingExpandSpoilers || !!snapStates.spoilers[id] || false;
if (reblog) {
// If has statusID, means useItemID (cached in states)
@ -312,40 +320,9 @@ function Status({
const [showEdited, setShowEdited] = useState(false);
const [showReactions, setShowReactions] = useState(false);
const spoilerContentRef = useRef(null);
useResizeObserver({
ref: spoilerContentRef,
onResize: () => {
if (spoilerContentRef.current) {
const { scrollHeight, clientHeight } = spoilerContentRef.current;
if (scrollHeight < window.innerHeight * 0.4) {
spoilerContentRef.current.classList.remove('truncated');
} else {
spoilerContentRef.current.classList.toggle(
'truncated',
scrollHeight > clientHeight,
);
}
}
},
});
const contentRef = useRef(null);
useResizeObserver({
ref: contentRef,
onResize: () => {
if (contentRef.current) {
const { scrollHeight, clientHeight } = contentRef.current;
if (scrollHeight < window.innerHeight * 0.4) {
contentRef.current.classList.remove('truncated');
} else {
contentRef.current.classList.toggle(
'truncated',
scrollHeight > clientHeight,
);
}
}
},
});
const spoilerContentRef = useTruncated();
const contentRef = useTruncated();
const mediaContainerRef = useTruncated();
const readMoreText = 'Read more →';
const statusRef = useRef(null);
@ -507,7 +484,7 @@ function Status({
};
const differentLanguage =
language &&
!!language &&
language !== targetLanguage &&
!localeMatch([language], [targetLanguage]) &&
!contentTranslationHideLanguages.find(
@ -549,7 +526,9 @@ function Status({
</MenuHeader>
<MenuLink
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
onClick={onStatusLinkClick}
onClick={(e) => {
onStatusLinkClick(e, status);
}}
>
<Icon icon="arrow-right" />
<span>View post by @{username || acct}</span>
@ -621,8 +600,9 @@ function Status({
onClick={() => {
try {
favouriteStatus();
if (!isSizeLarge)
if (!isSizeLarge) {
showToast(favourited ? 'Unfavourited' : 'Favourited');
}
} catch (e) {}
}}
>
@ -644,8 +624,9 @@ function Status({
onClick={() => {
try {
bookmarkStatus();
if (!isSizeLarge)
if (!isSizeLarge) {
showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked');
}
} catch (e) {}
}}
>
@ -809,35 +790,110 @@ function Status({
x: 0,
y: 0,
});
const isIOS =
window.ontouchstart !== undefined &&
/iPad|iPhone|iPod/.test(navigator.userAgent);
// Only iOS/iPadOS browsers don't support contextmenu
// Some comments report iPadOS might support contextmenu if a mouse is connected
const bindLongPressContext = useLongPress(
(e) => {
const { clientX, clientY } = e.touches?.[0] || e;
// link detection copied from onContextMenu because here it works
const link = e.target.closest('a');
if (link && /^https?:\/\//.test(link.getAttribute('href'))) return;
e.preventDefault();
setContextMenuAnchorPoint({
x: clientX,
y: clientY,
});
setIsContextMenuOpen(true);
},
isIOS
? (e) => {
if (e.pointerType === 'mouse') return;
// There's 'pen' too, but not sure if contextmenu event would trigger from a pen
const { clientX, clientY } = e.touches?.[0] || e;
// link detection copied from onContextMenu because here it works
const link = e.target.closest('a');
if (link && /^https?:\/\//.test(link.getAttribute('href'))) return;
e.preventDefault();
setContextMenuAnchorPoint({
x: clientX,
y: clientY,
});
setIsContextMenuOpen(true);
}
: null,
{
threshold: 600,
captureEvent: true,
detect: 'touch',
cancelOnMovement: 4, // true allows movement of up to 25 pixels
cancelOnMovement: 2, // true allows movement of up to 25 pixels
},
);
const showContextMenu = size !== 'l' && !previewMode && !_deleted && !quoted;
const hotkeysEnabled = !readOnly && !previewMode;
const rRef = useHotkeys('r', replyStatus, {
enabled: hotkeysEnabled,
});
const fRef = useHotkeys(
'f',
() => {
try {
favouriteStatus();
if (!isSizeLarge) {
showToast(favourited ? 'Unfavourited' : 'Favourited');
}
} catch (e) {}
},
{
enabled: hotkeysEnabled,
},
);
const dRef = useHotkeys(
'd',
() => {
try {
bookmarkStatus();
if (!isSizeLarge) {
showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked');
}
} catch (e) {}
},
{
enabled: hotkeysEnabled,
},
);
const bRef = useHotkeys(
'shift+b',
() => {
(async () => {
try {
const done = await confirmBoostStatus();
if (!isSizeLarge && done) {
showToast(reblogged ? 'Unboosted' : 'Boosted');
}
} catch (e) {}
})();
},
{
enabled: hotkeysEnabled && canBoost,
},
);
return (
<article
ref={statusRef}
ref={(node) => {
statusRef.current = node;
// Use parent node if it's in focus
// Use case: <a><status /></a>
// When navigating (j/k), the <a> is focused instead of <status />
// Hotkey binding doesn't bubble up thus this hack
const nodeRef =
node?.closest?.(
'.timeline-item, .timeline-item-alt, .status-link, .status-focus',
) || node;
rRef.current = nodeRef;
fRef.current = nodeRef;
dRef.current = nodeRef;
bRef.current = nodeRef;
}}
tabindex="-1"
class={`status ${
!withinContext && inReplyToAccount ? 'status-reply-to' : ''
!withinContext && inReplyToId && inReplyToAccount
? 'status-reply-to'
: ''
} visibility-${visibility} ${_pinned ? 'status-pinned' : ''} ${
{
s: 'small',
@ -922,13 +978,14 @@ function Status({
)}
<div class="container">
<div class="meta">
{/* <span> */}
<NameText
account={status.account}
instance={instance}
showAvatar={size === 's'}
showAcct={isSizeLarge}
/>
<span class="meta-name">
<NameText
account={status.account}
instance={instance}
showAvatar={size === 's'}
showAcct={isSizeLarge}
/>
</span>
{/* {inReplyToAccount && !withinContext && size !== 's' && (
<>
{' '}
@ -970,7 +1027,7 @@ function Status({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStatusLinkClick?.();
onStatusLinkClick?.(e, status);
}}
class={`time ${open ? 'is-open' : ''}`}
>
@ -1003,7 +1060,7 @@ function Status({
)}
{!withinContext && (
<>
{inReplyToAccountId === status.account?.id ||
{(!!inReplyToId && inReplyToAccountId === status.account?.id) ||
!!snapStates.statusThreadNumber[sKey] ? (
<div class="status-thread-badge">
<Icon icon="thread" size="s" />
@ -1058,6 +1115,7 @@ function Status({
<button
class={`light spoiler ${showSpoiler ? 'spoiling' : ''}`}
type="button"
disabled={readingExpandSpoilers}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -1069,7 +1127,11 @@ function Status({
}}
>
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
{showSpoiler ? 'Show less' : 'Show more'}
{readingExpandSpoilers
? 'Content warning'
: showSpoiler
? 'Show less'
: 'Show more'}
</button>
</>
)}
@ -1078,7 +1140,12 @@ function Status({
lang={language}
dir="auto"
class="inner-content"
onClick={handleContentLinks({ mentions, instance, previewMode })}
onClick={handleContentLinks({
mentions,
instance,
previewMode,
statusURL: url,
})}
dangerouslySetInnerHTML={{
__html: enhanceContent(content, {
emojis,
@ -1155,6 +1222,7 @@ function Status({
)}
{(((enableTranslate || inlineTranslate) &&
!!content.trim() &&
!!getHTMLText(emojifyText(content, emojis)) &&
differentLanguage) ||
forceTranslate) && (
<TranslationBlock
@ -1198,31 +1266,74 @@ function Status({
</button>
)}
{!!mediaAttachments.length && (
<div
class={`media-container media-eq${mediaAttachments.length} ${
mediaAttachments.length > 2 ? 'media-gt2' : ''
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
<MultipleMediaFigure
lang={language}
enabled={
mediaAttachments.length > 1 &&
mediaAttachments.some(
(media) =>
!!media.description &&
!isMediaCaptionLong(media.description),
)
}
captionChildren={() => {
return mediaAttachments.map(
(media, i) =>
!!media.description &&
!isMediaCaptionLong(media.description) && (
<div
key={media.id}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showMediaAlt = {
alt: media.description,
lang: language,
};
}}
title={media.description}
>
<sup>{i + 1}</sup> {media.description}
</div>
),
);
}}
>
{mediaAttachments
.slice(0, isSizeLarge ? undefined : 4)
.map((media, i) => (
<Media
key={media.id}
media={media}
autoAnimate={isSizeLarge}
to={`/${instance}/s/${id}?${
withinContext ? 'media' : 'media-only'
}=${i + 1}`}
onClick={
onMediaClick
? (e) => {
onMediaClick(e, i, media, status);
}
: undefined
}
/>
))}
</div>
<div
ref={mediaContainerRef}
class={`media-container media-eq${mediaAttachments.length} ${
mediaAttachments.length > 2 ? 'media-gt2' : ''
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
>
{mediaAttachments
.slice(0, isSizeLarge ? undefined : 4)
.map((media, i) => (
<Media
key={media.id}
media={media}
autoAnimate={isSizeLarge}
showCaption={mediaAttachments.length === 1}
lang={language}
altIndex={
mediaAttachments.length > 1 &&
!!media.description &&
!isMediaCaptionLong(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 &&
card?.url !== status.url &&
@ -1247,7 +1358,7 @@ function Status({
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
/>{' '}
<a href={url} target="_blank">
<a href={url} target="_blank" rel="noopener noreferrer">
<time
class="created"
datetime={createdAtDate.toISOString()}
@ -1419,6 +1530,19 @@ function Status({
);
}
function MultipleMediaFigure(props) {
const { enabled, children, lang, captionChildren } = props;
if (!enabled || !captionChildren) return children;
return (
<figure class="media-figure-multiple">
{children}
<figcaption lang={lang} dir="auto">
{captionChildren?.()}
</figcaption>
</figure>
);
}
function Card({ card, instance }) {
const snapStates = useSnapshot(states);
const {
@ -1499,6 +1623,7 @@ function Card({ card, instance }) {
rel="nofollow noopener noreferrer"
class={`card link ${blurhashImage ? '' : size}`}
lang={language}
dir="auto"
>
<div class="card-image">
<img
@ -1515,9 +1640,15 @@ function Card({ card, instance }) {
/>
</div>
<div class="meta-container">
<p class="meta domain">{domain}</p>
<p class="title">{title}</p>
<p class="meta">{description || providerName || authorName}</p>
<p class="meta domain" dir="auto">
{domain}
</p>
<p class="title" dir="auto">
{title}
</p>
<p class="meta" dir="auto">
{description || providerName || authorName}
</p>
</div>
</a>
);
@ -2022,10 +2153,12 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
threshold: 600,
captureEvent: true,
detect: 'touch',
cancelOnMovement: 4, // true allows movement of up to 25 pixels
cancelOnMovement: 2, // true allows movement of up to 25 pixels
},
);
const statusPeekRef = useTruncated();
return (
<div
class={isReblog ? (group ? 'status-group' : 'status-reblog') : ''}
@ -2099,16 +2232,15 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
</header>
<main tabIndex="-1">
<Link
ref={statusPeekRef}
class="status-link"
to={`/${instance}/s/${status.id}`}
onClick={() => {
setShowPeek(false);
}}
data-read-more="Read more →"
>
<Status status={status} instance={instance} size="s" readOnly />
<button type="button" class="status-post-link plain3">
See post &raquo;
</button>
</Link>
</main>
</div>
@ -2136,6 +2268,7 @@ const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
key={q.instance + q.id}
to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
class="status-card-link"
data-read-more="Read more →"
>
<Status
statusID={q.id}

View file

@ -206,7 +206,6 @@ function Timeline({
}
}, [nearReachEnd, showMore]);
const isHovering = useRef(false);
const idle = useIdle(5000);
console.debug('🧘‍♀️ IDLE', idle);
const loadOrCheckUpdates = useCallback(
@ -275,12 +274,6 @@ function Timeline({
oRef.current = node;
}}
tabIndex="-1"
onPointerEnter={(e) => {
isHovering.current = true;
}}
onPointerLeave={() => {
isHovering.current = false;
}}
>
<div class="timeline-deck deck">
<header
@ -392,6 +385,7 @@ function Timeline({
instance={instance}
size="s"
contentTextWeight
allowFilters={allowFilters}
/>
) : (
<Status
@ -399,6 +393,7 @@ function Timeline({
instance={instance}
size="s"
contentTextWeight
allowFilters={allowFilters}
/>
)}
</Link>
@ -419,7 +414,13 @@ function Timeline({
const isSpoiler = item.sensitive && !!item.spoilerText;
const showCompact =
(isSpoiler && i > 0) ||
(manyItems && isMiddle && type === 'thread');
(manyItems &&
isMiddle &&
(type === 'thread' ||
(type === 'conversation' &&
!_differentAuthor &&
!items[i - 1]._differentAuthor &&
!items[i + 1]._differentAuthor)));
return (
<li
key={`timeline-${statusID}`}

View file

@ -1,5 +1,6 @@
import './translation-block.css';
import pRetry from 'p-retry';
import pThrottle from 'p-throttle';
import { useEffect, useRef, useState } from 'preact/hooks';
@ -15,24 +16,45 @@ const throttle = pThrottle({
interval: 2000,
});
// Using other API instances instead of lingva.ml because of this bug (slashes don't work):
// https://github.com/thedaviddelta/lingva-translate/issues/68
const LINGVA_INSTANCES = [
'lingva.garudalinux.org',
'lingva.lunar.icu',
'translate.plausibility.cloud',
];
let currentLingvaInstance = 0;
function lingvaTranslate(text, source, target) {
console.log('TRANSLATE', text, source, target);
// Using another API instance instead of lingva.ml because of this bug (slashes don't work):
// https://github.com/thedaviddelta/lingva-translate/issues/68
return fetch(
`https://lingva.garudalinux.org/api/v1/${source}/${target}/${encodeURIComponent(
text,
)}`,
)
.then((res) => res.json())
.then((res) => {
return {
provider: 'lingva',
content: res.translation,
detectedSourceLanguage: res.info?.detectedSource,
info: res.info,
};
});
const fetchCall = () => {
let instance = LINGVA_INSTANCES[currentLingvaInstance];
return fetch(
`https://${instance}/api/v1/${source}/${target}/${encodeURIComponent(
text,
)}`,
)
.then((res) => res.json())
.then((res) => {
return {
provider: 'lingva',
content: res.translation,
detectedSourceLanguage: res.info?.detectedSource,
info: res.info,
};
});
};
return pRetry(fetchCall, {
retries: 3,
onFailedAttempt: (e) => {
currentLingvaInstance =
(currentLingvaInstance + 1) % LINGVA_INSTANCES.length;
console.log(
'Retrying translation with another instance',
currentLingvaInstance,
);
},
});
// return masto.v1.statuses.translate(id, {
// lang: DEFAULT_LANG,
// });
@ -66,7 +88,7 @@ function TranslationBlock({
const translate = async () => {
setUIState('loading');
try {
const { content, detectedSourceLanguage, provider, ...props } =
const { content, detectedSourceLanguage, provider, error, ...props } =
await onTranslate(text, apiSourceLang.current, targetLang);
if (content) {
if (detectedSourceLanguage) {
@ -89,7 +111,7 @@ function TranslationBlock({
});
}
} else {
console.error(result);
if (error) console.error(error);
setUIState('error');
}
} catch (e) {

View file

@ -934,6 +934,11 @@
"Asturian",
"Asturianu"
],
[
"chr",
"Cherokee",
"ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ"
],
[
"ckb",
"Sorani (Kurdish)",
@ -994,6 +999,11 @@
"Toki Pona",
"toki pona"
],
[
"xal",
"Kalmyk",
"Хальмг келн"
],
[
"zba",
"Balaibalan",

View file

@ -47,11 +47,12 @@
--reply-to-color: var(--orange-color);
--reply-to-text-color: #b36200;
--favourite-color: var(--red-color);
--reply-to-faded-color: #ffa60030;
--reply-to-faded-color: #ffa60020;
--outline-color: rgba(128, 128, 128, 0.2);
--outline-hover-color: rgba(128, 128, 128, 0.7);
--divider-color: rgba(0, 0, 0, 0.1);
--backdrop-color: rgba(0, 0, 0, 0.05);
--backdrop-darker-color: rgba(0, 0, 0, 0.25);
--backdrop-solid-color: #ccc;
--img-bg-color: rgba(128, 128, 128, 0.2);
--loader-color: #1c1e2199;
@ -62,6 +63,11 @@
--close-button-color: rgba(0, 0, 0, 0.5);
--close-button-hover-color: rgba(0, 0, 0, 1);
/* Video colors won't change based on color scheme */
--media-fg-color: #f0f2f5;
--media-bg-color: #242526;
--media-outline-color: color-mix(in lch, var(--media-fg-color), transparent);
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
}
@ -88,7 +94,7 @@
--link-bg-hover-color: #34353799;
--reblog-faded-color: #b190f141;
--reply-to-text-color: var(--reply-to-color);
--reply-to-faded-color: #ffa60027;
--reply-to-faded-color: #ffa60017;
--divider-color: rgba(255, 255, 255, 0.1);
--bg-blur-color: #24252699;
--backdrop-color: rgba(0, 0, 0, 0.5);
@ -314,7 +320,8 @@ pre {
tab-size: 2;
}
pre code,
code {
code,
kbd {
font-size: 90%;
font-family: var(--monospace-font);
}
@ -436,3 +443,24 @@ code {
.shazam-container-inner {
overflow: hidden;
}
@keyframes shazam-horizontal {
0% {
grid-template-columns: 0fr;
}
100% {
grid-template-columns: 1fr;
}
}
.shazam-container-horizontal {
display: grid;
grid-template-columns: 1fr;
transition: grid-template-columns 0.5s ease-in-out;
white-space: nowrap;
}
.shazam-container-horizontal:not(.no-animation) {
animation: shazam-horizontal 0.5s ease-in-out both !important;
}
.shazam-container-horizontal[hidden] {
grid-template-columns: 0fr;
}

View file

@ -258,11 +258,14 @@ function Hashtags({ columnMode, ...props }) {
onClick={(e) => {
hashtags.splice(i, 1);
hashtags.sort();
navigate(
instance
? `/${instance}/t/${hashtags.join('+')}`
: `/t/${hashtags.join('+')}`,
);
// navigate(
// instance
// ? `/${instance}/t/${hashtags.join('+')}`
// : `/t/${hashtags.join('+')}`,
// );
location.hash = instance
? `/${instance}/t/${hashtags.join('+')}`
: `/t/${hashtags.join('+')}`;
}}
>
<Icon icon="x" alt="Remove hashtag" class="danger-icon" />
@ -317,7 +320,8 @@ function Hashtags({ columnMode, ...props }) {
}
if (newInstance) {
newInstance = newInstance.toLowerCase().trim();
navigate(`/${newInstance}/t/${hashtags.join('+')}`);
// navigate(`/${newInstance}/t/${hashtags.join('+')}`);
location.hash = `/${newInstance}/t/${hashtags.join('+')}`;
}
}}
>

View file

@ -128,6 +128,15 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
states.notificationsLast = notifications[0];
states.notifications = groupedNotifications;
// Update last read marker
masto.v1.markers
.create({
notifications: {
lastReadId: notifications[0].id,
},
})
.catch(() => {});
}
states.notificationsShowNew = false;

View file

@ -30,7 +30,7 @@ export default function HttpRoute() {
<>
<h2>Unable to process URL</h2>
<p>
<a href={url} target="_blank">
<a href={url} target="_blank" rel="noopener noreferrer">
{url}
</a>
</p>

View file

@ -1,6 +1,7 @@
import './login.css';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
import Link from '../components/link';
import Loader from '../components/loader';
@ -14,8 +15,10 @@ function Login() {
const instanceURLRef = useRef();
const cachedInstanceURL = store.local.get('instanceURL');
const [uiState, setUIState] = useState('default');
const [searchParams] = useSearchParams();
const instance = searchParams.get('instance');
const [instanceText, setInstanceText] = useState(
cachedInstanceURL?.toLowerCase() || '',
instance || cachedInstanceURL?.toLowerCase() || '',
);
const [instancesList, setInstancesList] = useState([]);
@ -44,13 +47,15 @@ function Login() {
(async () => {
setUIState('loading');
try {
const { client_id, client_secret } = await registerApplication({
instanceURL,
});
const { client_id, client_secret, vapid_key } =
await registerApplication({
instanceURL,
});
if (client_id && client_secret) {
store.session.set('clientID', client_id);
store.session.set('clientSecret', client_secret);
store.session.set('vapidKey', vapid_key);
location.href = await getAuthorizationURL({
instanceURL,

View file

@ -4,6 +4,11 @@
gap: 12px;
animation: appear 0.2s ease-out;
clear: both;
b[tabindex='0']:is(:hover, :focus) {
text-decoration: underline;
cursor: pointer;
}
}
.notification.notification-mention {
margin-top: 16px;
@ -93,8 +98,8 @@
}
.notification .status-link:not(.status-type-mention) > .status {
font-size: calc(var(--text-size) * 0.9);
max-height: 160px;
overflow: hidden;
}
.notification .status-link.truncated:not(.status-type-mention) > .status {
/* fade out mask gradient bottom */
mask-image: linear-gradient(
rgba(0, 0, 0, 1) 130px,
@ -102,6 +107,33 @@
transparent 159px
);
}
.notification .status-link.truncated {
position: relative;
}
.notification .status-link.truncated:after {
content: attr(data-read-more);
line-height: 1;
display: inline-block;
position: absolute;
--inset-offset: 16px;
inset-block-end: var(--inset-offset);
inset-inline-end: var(--inset-offset);
color: var(--text-color);
background-color: var(--bg-faded-color);
border: 1px dashed var(--link-color);
box-shadow: 0 0 0 1px var(--bg-color), 0 -5px 10px var(--bg-color),
0 -5px 15px var(--bg-color), 0 -5px 20px var(--bg-color);
padding: 0.5em 0.75em;
border-radius: 10em;
font-size: 90%;
white-space: nowrap;
text-shadow: 0 -1px var(--bg-color);
transition: transform 0.2s ease-out;
}
.notification .status-link:is(:hover, :focus).truncated:after {
color: var(--link-color);
transform: translate(2px, 0);
}
.notification .status-link.status-type-mention {
max-height: 320px;
filter: none;

View file

@ -3,6 +3,7 @@ import './notifications.css';
import { useIdle } from '@uidotdev/usehooks';
import { memo } from 'preact/compat';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
import AccountBlock from '../components/account-block';
@ -17,6 +18,7 @@ import enhanceContent from '../utils/enhance-content';
import groupNotifications from '../utils/group-notifications';
import handleContentLinks from '../utils/handle-content-links';
import niceDateTime from '../utils/nice-date-time';
import { getRegistration } from '../utils/push-notifications';
import shortenNumber from '../utils/shorten-number';
import states, { saveStatus } from '../utils/states';
import { getCurrentInstance } from '../utils/store-utils';
@ -24,12 +26,16 @@ import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
const LIMIT = 30; // 30 is the maximum limit :(
const emptySearchParams = new URLSearchParams();
function Notifications() {
function Notifications({ columnMode }) {
useTitle('Notifications', '/notifications');
const { masto, instance } = api();
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const [searchParams] = columnMode ? [emptySearchParams] : useSearchParams();
const notificationID = searchParams.get('id');
const notificationAccessToken = searchParams.get('access_token');
const [showMore, setShowMore] = useState(false);
const [onlyMentions, setOnlyMentions] = useState(false);
const scrollableRef = useRef();
@ -67,6 +73,15 @@ function Notifications() {
if (firstLoad) {
states.notificationsLast = notifications[0];
states.notifications = groupedNotifications;
// Update last read marker
masto.v1.markers
.create({
notifications: {
lastReadId: notifications[0].id,
},
})
.catch(() => {});
} else {
states.notifications.push(...groupedNotifications);
}
@ -147,14 +162,12 @@ function Notifications() {
}
}, [nearReachEnd, showMore]);
const isHovering = useRef(false);
const idle = useIdle(5000);
console.debug('🧘‍♀️ IDLE', idle);
const loadUpdates = useCallback(() => {
console.log('✨ Load updates', {
autoRefresh: snapStates.settings.autoRefresh,
scrollTop: scrollableRef.current?.scrollTop === 0,
isHovering: isHovering.current,
inBackground: inBackground(),
notificationsShowNew: snapStates.notificationsShowNew,
uiState,
@ -162,7 +175,7 @@ function Notifications() {
if (
snapStates.settings.autoRefresh &&
scrollableRef.current?.scrollTop === 0 &&
(!isHovering.current || idle) &&
idle &&
!inBackground() &&
snapStates.notificationsShowNew &&
uiState !== 'loading'
@ -188,20 +201,39 @@ function Notifications() {
const announcementsListRef = useRef();
useEffect(() => {
if (notificationID) {
states.routeNotification = {
id: notificationID,
accessToken: atob(notificationAccessToken),
};
}
}, [notificationID, notificationAccessToken]);
useEffect(() => {
if (uiState === 'default') {
(async () => {
try {
const registration = await getRegistration();
if (registration?.getNotifications) {
const notifications = await registration.getNotifications();
console.log('🔔 Push notifications', notifications);
// Close all notifications?
// notifications.forEach((notification) => {
// notification.close();
// });
}
} catch (e) {}
})();
}
}, [uiState]);
return (
<div
id="notifications-page"
class="deck-container"
ref={scrollableRef}
tabIndex="-1"
onPointerEnter={() => {
console.log('👆 Pointer enter');
isHovering.current = true;
}}
onPointerLeave={() => {
console.log('👇 Pointer leave');
isHovering.current = false;
}}
>
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
<header

View file

@ -1,13 +1,7 @@
import './search.css';
import { forwardRef } from 'preact/compat';
import {
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from 'preact/hooks';
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useParams, useSearchParams } from 'react-router-dom';
@ -16,6 +10,7 @@ import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import NavMenu from '../components/nav-menu';
import SearchForm from '../components/search-form';
import Status from '../components/status';
import { api } from '../utils/api';
import useTitle from '../utils/useTitle';
@ -92,7 +87,7 @@ function Search(props) {
if (type) {
params.limit = LIMIT;
params.type = type;
params.offset = offsetRef.current;
if (authenticated) params.offset = offsetRef.current;
}
try {
const results = await masto.v2.search(params);
@ -125,15 +120,24 @@ function Search(props) {
}
useEffect(() => {
// searchFieldRef.current?.focus?.();
// searchFormRef.current?.focus?.();
if (q) {
// searchFieldRef.current.value = q;
searchFormRef.current?.setValue?.(q);
loadResults(true);
} else {
searchFormRef.current?.focus?.();
}
}, [q, type, instance]);
useHotkeys(
'/',
(e) => {
searchFormRef.current?.focus?.();
},
{
preventDefault: true,
},
);
return (
<div id="search-page" class="deck-container" ref={scrollableRef}>
<div class="timeline-deck deck">
@ -149,22 +153,26 @@ function Search(props) {
<main>
{!!q && (
<div class="filter-bar">
{!!type && <Link to={`/search${q ? `?q=${q}` : ''}`}> All</Link>}
{!!type && (
<Link to={`/search${q ? `?q=${encodeURIComponent(q)}` : ''}`}>
All
</Link>
)}
{[
{
label: 'Accounts',
type: 'accounts',
to: `/search?q=${q}&type=accounts`,
to: `/search?q=${encodeURIComponent(q)}&type=accounts`,
},
{
label: 'Hashtags',
type: 'hashtags',
to: `/search?q=${q}&type=hashtags`,
to: `/search?q=${encodeURIComponent(q)}&type=hashtags`,
},
{
label: 'Posts',
type: 'statuses',
to: `/search?q=${q}&type=statuses`,
to: `/search?q=${encodeURIComponent(q)}&type=statuses`,
},
]
.sort((a, b) => {
@ -358,213 +366,3 @@ function Search(props) {
}
export default Search;
const SearchForm = forwardRef((props, ref) => {
const { instance } = api();
const [searchParams, setSearchParams] = useSearchParams();
const [searchMenuOpen, setSearchMenuOpen] = useState(false);
const [query, setQuery] = useState(searchParams.get('q') || '');
const type = searchParams.get('type');
const formRef = useRef(null);
const searchFieldRef = useRef(null);
useImperativeHandle(ref, () => ({
setValue: (value) => {
setQuery(value);
},
focus: () => {
searchFieldRef.current.focus();
},
}));
return (
<form
ref={formRef}
class="search-popover-container"
onSubmit={(e) => {
e.preventDefault();
if (query) {
const params = {
q: query,
};
if (type) params.type = type; // Preserve type
setSearchParams(params);
} else {
setSearchParams({});
}
}}
>
<input
ref={searchFieldRef}
value={query}
name="q"
type="search"
// autofocus
placeholder="Search"
dir="auto"
onSearch={(e) => {
if (!e.target.value) {
setSearchParams({});
}
}}
onInput={(e) => {
setQuery(e.target.value);
setSearchMenuOpen(true);
}}
onFocus={() => {
setSearchMenuOpen(true);
}}
onBlur={() => {
setTimeout(() => {
setSearchMenuOpen(false);
}, 100);
formRef.current
?.querySelector('.search-popover-item.focus')
?.classList.remove('focus');
}}
onKeyDown={(e) => {
const { key } = e;
switch (key) {
case 'Escape':
setSearchMenuOpen(false);
break;
case 'Down':
case 'ArrowDown':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = formRef.current.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let nextItem = focusItem.nextElementSibling;
while (nextItem && nextItem.hidden) {
nextItem = nextItem.nextElementSibling;
}
if (nextItem) {
nextItem.classList.add('focus');
const siblings = Array.from(
nextItem.parentElement.children,
).filter((el) => el !== nextItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const firstItem = formRef.current.querySelector(
'.search-popover-item',
);
if (firstItem) {
firstItem.classList.add('focus');
}
}
}
break;
case 'Up':
case 'ArrowUp':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let prevItem = focusItem.previousElementSibling;
while (prevItem && prevItem.hidden) {
prevItem = prevItem.previousElementSibling;
}
if (prevItem) {
prevItem.classList.add('focus');
const siblings = Array.from(
prevItem.parentElement.children,
).filter((el) => el !== prevItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const lastItem = document.querySelector(
'.search-popover-item:last-child',
);
if (lastItem) {
lastItem.classList.add('focus');
}
}
}
break;
case 'Enter':
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
e.preventDefault();
focusItem.click();
}
setSearchMenuOpen(false);
}
break;
}
}}
/>
<div class="search-popover" hidden={!searchMenuOpen || !query}>
{!!query &&
[
{
label: (
<>
Posts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=statuses`,
hidden: /^https?:/.test(query),
},
{
label: (
<>
Posts tagged with <mark>#{query.replace(/^#/, '')}</mark>
</>
),
to: `/${instance}/t/${query.replace(/^#/, '')}`,
hidden:
/^@/.test(query) || /^https?:/.test(query) || /\s/.test(query),
top: /^#/.test(query),
type: 'link',
},
{
label: (
<>
Look up <mark>{query}</mark>
</>
),
to: `/${query}`,
hidden: !/^https?:/.test(query),
top: /^https?:/.test(query),
type: 'link',
},
{
label: (
<>
Accounts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=accounts`,
},
]
.sort((a, b) => {
if (a.top && !b.top) return -1;
if (!a.top && b.top) return 1;
return 0;
})
.map(({ label, to, hidden, type }) => (
<Link to={to} class="search-popover-item" hidden={hidden}>
<Icon
icon={type === 'link' ? 'arrow-right' : 'search'}
class="more-insignificant"
/>
<span>{label}</span>{' '}
</Link>
))}
</div>
</form>
);
});

View file

@ -7,6 +7,7 @@
text-transform: uppercase;
color: var(--text-insignificant-color);
font-weight: normal;
padding-inline: 16px;
}
#settings-container section {
@ -128,3 +129,14 @@
gap: 4px;
align-items: flex-start;
}
#settings-container .section-postnote {
margin-bottom: 48px;
padding-inline: 16px;
color: var(--text-insignificant-color);
}
#settings-container .synced-icon {
color: var(--link-color);
vertical-align: middle;
}

View file

@ -5,11 +5,18 @@ import { useSnapshot } from 'valtio';
import logo from '../assets/logo.svg';
import Icon from '../components/icon';
import Link from '../components/link';
import RelativeTime from '../components/relative-time';
import targetLanguages from '../data/lingva-target-languages';
import { api } from '../utils/api';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import localeCode2Text from '../utils/localeCode2Text';
import {
initSubscription,
isPushSupported,
removeSubscription,
updateSubscription,
} from '../utils/push-notifications';
import states from '../utils/states';
import store from '../utils/store';
@ -27,6 +34,7 @@ function Settings({ onClose }) {
const currentTextSize = store.local.get('textSize') || DEFAULT_TEXT_SIZE;
const [prefs, setPrefs] = useState(store.account.get('preferences') || {});
const { masto, authenticated, instance } = api();
// Get preferences every time Settings is opened
// NOTE: Disabled for now because I don't expect this to change often. Also for some reason, the /api/v1/preferences endpoint is cached for a while and return old prefs if refresh immediately after changing them.
// useEffect(() => {
@ -162,50 +170,69 @@ function Settings({ onClose }) {
</li>
</ul>
</section>
<h3>Posting</h3>
<section>
<ul>
<li>
<div>
<label for="posting-privacy-field">Default visibility</label>
</div>
<div>
<select
id="posting-privacy-field"
value={prefs['posting:default:visibility'] || 'public'}
onChange={(e) => {
const { value } = e.target;
const { masto } = api();
(async () => {
try {
await masto.v1.accounts.updateCredentials({
source: {
privacy: value,
},
});
setPrefs({
...prefs,
'posting:default:visibility': value,
});
store.account.set('preferences', {
...prefs,
'posting:default:visibility': value,
});
} catch (e) {
alert('Failed to update posting privacy');
console.error(e);
}
})();
}}
{authenticated && (
<>
<h3>Posting</h3>
<section>
<ul>
<li>
<div>
<label for="posting-privacy-field">
Default visibility{' '}
<Icon icon="cloud" alt="Synced" class="synced-icon" />
</label>
</div>
<div>
<select
id="posting-privacy-field"
value={prefs['posting:default:visibility'] || 'public'}
onChange={(e) => {
const { value } = e.target;
(async () => {
try {
await masto.v1.accounts.updateCredentials({
source: {
privacy: value,
},
});
setPrefs({
...prefs,
'posting:default:visibility': value,
});
store.account.set('preferences', {
...prefs,
'posting:default:visibility': value,
});
} catch (e) {
alert('Failed to update posting privacy');
console.error(e);
}
})();
}}
>
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
<option value="private">Followers only</option>
</select>
</div>
</li>
</ul>
</section>
<p class="section-postnote">
<Icon icon="cloud" alt="Synced" class="synced-icon" />{' '}
<small>
Synced to your instance server's settings.{' '}
<a
href={`https://${instance}/`}
target="_blank"
rel="noopener noreferrer"
>
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
<option value="private">Followers only</option>
</select>
</div>
</li>
</ul>
</section>
Go to your instance ({instance}) for more settings.
</a>
</small>
</p>
</>
)}
<h3>Experiments</h3>
<section>
<ul>
@ -326,6 +353,7 @@ function Settings({ onClose }) {
<a
href="https://github.com/thedaviddelta/lingva-translate"
target="_blank"
rel="noopener noreferrer"
>
Lingva Translate
</a>
@ -377,20 +405,23 @@ function Settings({ onClose }) {
</small>
</div>
</li>
<li>
<button
type="button"
class="light"
onClick={() => {
states.showDrafts = true;
states.showSettings = false;
}}
>
Unsent drafts
</button>
</li>
{authenticated && (
<li>
<button
type="button"
class="light"
onClick={() => {
states.showDrafts = true;
states.showSettings = false;
}}
>
Unsent drafts
</button>
</li>
)}
</ul>
</section>
{authenticated && <PushNotificationsSection onClose={onClose} />}
<h3>About</h3>
<section>
<div
@ -419,6 +450,7 @@ function Settings({ onClose }) {
<a
href="https://hachyderm.io/@phanpy"
// target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'phanpy@hachyderm.io';
@ -427,13 +459,18 @@ function Settings({ onClose }) {
@phanpy
</a>
<br />
<a href="https://github.com/cheeaun/phanpy" target="_blank">
<a
href="https://github.com/cheeaun/phanpy"
target="_blank"
rel="noopener noreferrer"
>
Built
</a>{' '}
by{' '}
<a
href="https://mastodon.social/@cheeaun"
// target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social';
@ -447,6 +484,7 @@ function Settings({ onClose }) {
<a
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
target="_blank"
rel="noopener noreferrer"
>
Privacy Policy
</a>
@ -461,6 +499,7 @@ function Settings({ onClose }) {
<a
href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`}
target="_blank"
rel="noopener noreferrer"
>
<code>{__COMMIT_HASH__}</code>
</a>
@ -475,4 +514,244 @@ function Settings({ onClose }) {
);
}
function PushNotificationsSection({ onClose }) {
if (!isPushSupported()) return null;
const { instance } = api();
const [uiState, setUIState] = useState('default');
const pushFormRef = useRef();
const [allowNofitications, setAllowNotifications] = useState(false);
const [needRelogin, setNeedRelogin] = useState(false);
const previousPolicyRef = useRef();
useEffect(() => {
(async () => {
setUIState('loading');
try {
const { subscription, backendSubscription } = await initSubscription();
if (
backendSubscription?.policy &&
backendSubscription.policy !== 'none'
) {
setAllowNotifications(true);
const { alerts, policy } = backendSubscription;
previousPolicyRef.current = policy;
const { elements } = pushFormRef.current;
const policyEl = elements.namedItem(policy);
if (policyEl) policyEl.value = policy;
// alerts is {}, iterate it
Object.keys(alerts).forEach((alert) => {
const el = elements.namedItem(alert);
if (el?.type === 'checkbox') {
el.checked = true;
}
});
}
setUIState('default');
} catch (err) {
console.warn(err);
if (/outside.*authorized/i.test(err.message)) {
setNeedRelogin(true);
} else {
alert(err?.message || err);
}
setUIState('error');
}
})();
}, []);
const isLoading = uiState === 'loading';
return (
<form
ref={pushFormRef}
onChange={() => {
const values = Object.fromEntries(new FormData(pushFormRef.current));
const allowNofitications = !!values['policy-allow'];
const params = {
policy: values.policy,
data: {
alerts: {
mention: !!values.mention,
favourite: !!values.favourite,
reblog: !!values.reblog,
follow: !!values.follow,
follow_request: !!values.followRequest,
poll: !!values.poll,
update: !!values.update,
status: !!values.status,
},
},
};
let alertsCount = 0;
// Remove false values from data.alerts
// API defaults to false anyway
Object.keys(params.data.alerts).forEach((key) => {
if (!params.data.alerts[key]) {
delete params.data.alerts[key];
} else {
alertsCount++;
}
});
const policyChanged = previousPolicyRef.current !== params.policy;
console.log('PN Form', { values, allowNofitications, params });
if (allowNofitications && alertsCount > 0) {
if (policyChanged) {
console.debug('Policy changed.');
removeSubscription()
.then(() => {
updateSubscription(params);
})
.catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
});
} else {
updateSubscription(params).catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
});
}
} else {
removeSubscription().catch((err) => {
console.warn(err);
alert('Failed to remove subscription. Please try again.');
});
}
}}
>
<h3>Push Notifications (beta)</h3>
<section>
<ul>
<li>
<label>
<input
type="checkbox"
disabled={isLoading || needRelogin}
name="policy-allow"
checked={allowNofitications}
onChange={async (e) => {
const { checked } = e.target;
if (checked) {
// Request permission
const permission = await Notification.requestPermission();
if (permission === 'granted') {
setAllowNotifications(true);
} else {
setAllowNotifications(false);
if (permission === 'denied') {
alert(
'Push notifications are blocked. Please enable them in your browser settings.',
);
}
}
} else {
setAllowNotifications(false);
}
}}
/>{' '}
Allow from{' '}
<select
name="policy"
disabled={isLoading || needRelogin || !allowNofitications}
>
{[
{
value: 'all',
label: 'anyone',
},
{
value: 'followed',
label: 'people I follow',
},
{
value: 'follower',
label: 'followers',
},
].map((type) => (
<option value={type.value}>{type.label}</option>
))}
</select>
</label>
<div
class="shazam-container no-animation"
style={{
width: '100%',
}}
hidden={!allowNofitications}
>
<div class="shazam-container-inner">
<div class="sub-section">
<ul>
{[
{
value: 'mention',
label: 'Mentions',
},
{
value: 'favourite',
label: 'Favourites',
},
{
value: 'reblog',
label: 'Boosts',
},
{
value: 'follow',
label: 'Follows',
},
{
value: 'followRequest',
label: 'Follow requests',
},
{
value: 'poll',
label: 'Polls',
},
{
value: 'update',
label: 'Post edits',
},
{
value: 'status',
label: 'New posts',
},
].map((alert) => (
<li>
<label>
<input type="checkbox" name={alert.value} />{' '}
{alert.label}
</label>
</li>
))}
</ul>
</div>
</div>
</div>
{needRelogin && (
<div class="sub-section">
<p>
Push permission was not granted since your last login. You'll
need to{' '}
<Link to={`/login?instance=${instance}`} onClick={onClose}>
<b>log in</b> again to grant push permission
</Link>
.
</p>
</div>
)}
</li>
</ul>
</section>
<p class="section-postnote">
<small>
NOTE: Push notifications only work for <b>one account</b>.
</small>
</p>
</form>
);
}
export default Settings;

View file

@ -0,0 +1,9 @@
import { useParams } from 'react-router-dom';
import Status from './status';
export default function StatusRoute() {
const params = useParams();
const { id, instance } = params;
return <Status id={id} instance={instance} />;
}

View file

@ -31,6 +31,9 @@
.ancestors-indicator {
font-size: 70% !important;
}
.ancestors-indicator:not([hidden]) {
animation: slide-up 0.3s both ease-out 0.3s;
}
.ancestors-indicator[hidden] {
opacity: 0;
pointer-events: none;

View file

@ -48,9 +48,10 @@ const MAX_WEIGHT = 5;
let cachedRepliesToggle = {};
let cachedStatusesMap = {};
let scrollPositions = {};
function resetScrollPosition(id) {
delete cachedStatusesMap[id];
delete states.scrollPositions[id];
delete scrollPositions[id];
}
function StatusPage(params) {
@ -109,6 +110,23 @@ function StatusPage(params) {
? mediaStatus?.mediaAttachments
: heroStatus?.mediaAttachments;
const handleMediaClose = useCallback(() => {
if (
!window.matchMedia('(min-width: calc(40em + 350px))').matches &&
snapStates.prevLocation
) {
history.back();
} else {
if (showMediaOnly) {
location.hash = closeLink;
} else {
searchParams.delete('media');
searchParams.delete('mediaStatusID');
setSearchParams(searchParams);
}
}
}, [showMediaOnly, closeLink, snapStates.prevLocation]);
return (
<div class="deck-backdrop">
{showMedia ? (
@ -117,23 +135,9 @@ function StatusPage(params) {
mediaAttachments={mediaAttachments}
statusID={mediaStatusID || id}
instance={instance}
lang={heroStatus?.language}
index={mediaIndex - 1}
onClose={() => {
if (
!window.matchMedia('(min-width: calc(40em + 350px))').matches &&
snapStates.prevLocation
) {
history.back();
} else {
if (showMediaOnly) {
location.hash = closeLink;
} else {
searchParams.delete('media');
searchParams.delete('mediaStatusID');
setSearchParams(searchParams);
}
}
}}
onClose={handleMediaClose}
/>
) : (
<div class="media-modal-container loading">
@ -184,7 +188,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
if (!scrollableRef.current) return;
const { scrollTop } = scrollableRef.current;
if (uiState !== 'loading') {
states.scrollPositions[id] = scrollTop;
scrollPositions[id] = scrollTop;
}
}, 50);
scrollableRef.current?.addEventListener('scroll', onScroll, {
@ -391,7 +395,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
useEffect(() => {
if (!statuses.length) return;
console.debug('STATUSES', statuses);
const scrollPosition = snapStates.scrollPositions[id];
const scrollPosition = scrollPositions[id];
console.debug('scrollPosition', scrollPosition);
if (!!scrollPosition) {
console.debug('Case 1', {
@ -449,7 +453,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
useEffect(() => {
return () => {
// RESET
states.scrollPositions = {};
scrollPositions = {};
states.reloadStatusPage = 0;
cachedStatusesMap = {};
cachedRepliesToggle = {};
@ -633,6 +637,10 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
[id],
);
const handleStatusLinkClick = useCallback((e, status) => {
resetScrollPosition(status.id);
}, []);
return (
<div
tabIndex="-1"
@ -979,9 +987,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
size={thread || ancestor ? 'm' : 's'}
enableTranslate
onMediaClick={handleMediaClick}
onStatusLinkClick={() => {
resetScrollPosition(statusID);
}}
onStatusLinkClick={handleStatusLinkClick}
/>
{ancestor && isThread && repliesCount > 1 && (
<div class="replies-link">

View file

@ -1,7 +1,12 @@
import { createClient } from 'masto';
import store from './store';
import { getAccount, getCurrentAccount, saveAccount } from './store-utils';
import {
getAccount,
getAccountByAccessToken,
getCurrentAccount,
saveAccount,
} from './store-utils';
// Default *fallback* instance
const DEFAULT_INSTANCE = 'mastodon.social';
@ -18,6 +23,7 @@ const apis = {};
// Just in case if I need this one day.
// E.g. accountApis['mastodon.social']['ACCESS_TOKEN']
const accountApis = {};
window.__ACCOUNT_APIS__ = accountApis;
// Current account masto instance
let currentAccountApi;
@ -92,7 +98,7 @@ export async function initInstance(client, instance) {
}
// Get the account information and store it
export async function initAccount(client, instance, accessToken) {
export async function initAccount(client, instance, accessToken, vapidKey) {
const masto = client;
const mastoAccount = await masto.v1.accounts.verifyCredentials();
@ -102,6 +108,7 @@ export async function initAccount(client, instance, accessToken) {
info: mastoAccount,
instanceURL: instance.toLowerCase(),
accessToken,
vapidKey,
});
}
@ -136,6 +143,35 @@ export function api({ instance, accessToken, accountID, account } = {}) {
};
}
if (accessToken) {
// If only accessToken is provided, get the masto instance for that accessToken
console.log('X 1', accountApis);
for (const instance in accountApis) {
if (accountApis[instance][accessToken]) {
console.log('X 2', accountApis, instance, accessToken);
return {
masto: accountApis[instance][accessToken],
authenticated: true,
instance,
};
} else {
console.log('X 3', accountApis, instance, accessToken);
const account = getAccountByAccessToken(accessToken);
if (account) {
const accessToken = account.accessToken;
const instance = account.instanceURL.toLowerCase().trim();
return {
masto: initClient({ instance, accessToken }),
authenticated: true,
instance,
};
} else {
throw new Error(`Access token ${accessToken} not found`);
}
}
}
}
// If account is provided, get the masto instance for that account
if (account || accountID) {
account = account || getAccount(accountID);

View file

@ -1,11 +1,13 @@
const { VITE_CLIENT_NAME: CLIENT_NAME, VITE_WEBSITE: WEBSITE } = import.meta
.env;
const SCOPES = 'read write follow push';
export async function registerApplication({ instanceURL }) {
const registrationParams = new URLSearchParams({
client_name: CLIENT_NAME,
scopes: 'read write follow',
redirect_uris: location.origin + location.pathname,
scopes: SCOPES,
website: WEBSITE,
});
const registrationResponse = await fetch(
@ -26,7 +28,7 @@ export async function registerApplication({ instanceURL }) {
export async function getAuthorizationURL({ instanceURL, client_id }) {
const authorizationParams = new URLSearchParams({
client_id,
scope: 'read write follow',
scope: SCOPES,
redirect_uri: location.origin + location.pathname,
// redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
response_type: 'code',
@ -47,7 +49,7 @@ export async function getAccessToken({
redirect_uri: location.origin + location.pathname,
grant_type: 'authorization_code',
code,
scope: 'read write follow',
scope: SCOPES,
});
const tokenResponse = await fetch(`https://${instanceURL}/oauth/token`, {
method: 'POST',

View file

@ -8,7 +8,7 @@ function emojifyText(text, emojis = []) {
const { shortcode, staticUrl, url } = emoji;
text = text.replace(
new RegExp(`:${shortcode}:`, 'g'),
`<picture><source srcset="${staticUrl}" media="(prefers-reduced-motion: reduce)"></source><img class="shortcode-emoji emoji" src="${url}" alt=":${shortcode}:" width="12" height="12" loading="lazy" decoding="async" /></picture>`,
`<picture><source srcset="${staticUrl}" media="(prefers-reduced-motion: reduce)"></source><img class="shortcode-emoji emoji" src="${url}" alt=":${shortcode}:" width="16" height="16" loading="lazy" decoding="async" /></picture>`,
);
});
// console.log(text, emojis);

View file

@ -1,6 +1,7 @@
import emojifyText from './emojify-text';
const fauxDiv = document.createElement('div');
const whitelistLinkClasses = ['u-url', 'mention', 'hashtag'];
function enhanceContent(content, opts = {}) {
const { emojis, postEnhanceDOM = () => {} } = opts;
@ -10,15 +11,25 @@ function enhanceContent(content, opts = {}) {
const hasLink = /<a/i.test(enhancedContent);
const hasCodeBlock = enhancedContent.indexOf('```') !== -1;
// Add target="_blank" to all links with no target="_blank"
// E.g. `note` in `account`
if (hasLink) {
// Add target="_blank" to all links with no target="_blank"
// E.g. `note` in `account`
const noTargetBlankLinks = Array.from(
dom.querySelectorAll('a:not([target="_blank"])'),
);
noTargetBlankLinks.forEach((link) => {
link.setAttribute('target', '_blank');
});
// Remove all classes except `u-url`, `mention`, `hashtag`
const links = Array.from(dom.querySelectorAll('a[class]'));
links.forEach((link) => {
Array.from(link.classList).forEach((c) => {
if (!whitelistLinkClasses.includes(c)) {
link.classList.remove(c);
}
});
});
}
// Add 'has-url-text' to all links that contains a url
@ -33,15 +44,26 @@ function enhanceContent(content, opts = {}) {
// Spanify un-spanned mentions
if (hasLink) {
const notMentionLinks = Array.from(dom.querySelectorAll('a[href]'));
notMentionLinks.forEach((link) => {
const links = Array.from(dom.querySelectorAll('a[href]'));
const usernames = [];
links.forEach((link) => {
const text = link.innerText.trim();
const hasChildren = link.querySelector('*');
// If text looks like @username@domain, then it's a mention
if (/^@[^@]+(@[^@]+)?$/g.test(text)) {
// Only show @username
const username = text.split('@')[1];
if (!hasChildren) link.innerHTML = `@<span>${username}</span>`;
const [_, username, domain] = text.split('@');
if (!hasChildren) {
if (
!usernames.find(([u]) => u === username) ||
usernames.find(([u, d]) => u === username && d === domain)
) {
link.innerHTML = `@<span>${username}</span>`;
usernames.push([username, domain]);
} else {
link.innerHTML = `@<span>${username}@${domain}</span>`;
}
}
link.classList.add('mention');
}
// If text looks like #hashtag, then it's a hashtag
@ -120,7 +142,7 @@ function enhanceContent(content, opts = {}) {
p.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
});
const codeText = nextParagraphs.map((p) => p.innerHTML).join('\n\n');
pre.innerHTML = `<code>${codeText}</code>`;
pre.innerHTML = `<code tabindex="0">${codeText}</code>`;
block.replaceWith(pre);
nextParagraphs.forEach((p) => p.remove());
}

33
src/utils/focus-deck.jsx Normal file
View file

@ -0,0 +1,33 @@
const focusDeck = () => {
let timer = setTimeout(() => {
const columns = document.getElementById('columns');
if (columns) {
// Focus first column
// columns.querySelector('.deck-container')?.focus?.();
} else {
const modals = document.querySelectorAll('#modal-container > *');
if (modals?.length) {
// Focus last modal
const modal = modals[modals.length - 1]; // last one
const modalFocusElement =
modal.querySelector('[tabindex="-1"]') || modal;
if (modalFocusElement) {
modalFocusElement.focus();
return;
}
}
const backDrop = document.querySelector('.deck-backdrop');
if (backDrop) return;
// Focus last deck
const pages = document.querySelectorAll('.deck-container');
const page = pages[pages.length - 1]; // last one
if (page && page.tabIndex === -1) {
console.log('FOCUS', page);
page.focus();
}
}
}, 100);
return () => clearTimeout(timer);
};
export default focusDeck;

View file

@ -1,14 +1,24 @@
import states from './states';
function handleContentLinks(opts) {
const { mentions = [], instance, previewMode } = opts || {};
const { mentions = [], instance, previewMode, statusURL } = opts || {};
return (e) => {
let { target } = e;
target = target.closest('a');
if (!target) return;
// If cmd/ctrl/shift/alt key is pressed or middle-click, let the browser handle it
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.which === 2) {
return;
}
const prevText = target.previousSibling?.textContent;
const textBeforeLinkIsAt = prevText?.endsWith('@');
if (target.classList.contains('u-url') || textBeforeLinkIsAt) {
const textStartsWithAt = target.innerText.startsWith('@');
if (
(target.classList.contains('u-url') && textStartsWithAt) ||
(textBeforeLinkIsAt && !textStartsWithAt)
) {
const targetText = (
target.querySelector('span') || target
).innerText.trim();
@ -46,7 +56,11 @@ function handleContentLinks(opts) {
const hashURL = instance ? `#/${instance}/t/${tag}` : `#/t/${tag}`;
console.log({ hashURL });
location.hash = hashURL;
} else if (states.unfurledLinks[target.href]?.url) {
} else if (
states.unfurledLinks[target.href]?.url &&
statusURL !== target.href
) {
// If unfurled AND not self-referential
e.preventDefault();
e.stopPropagation();
states.prevLocation = {

View file

@ -2,7 +2,7 @@ export default function isMastodonLinkMaybe(url) {
const { pathname } = new URL(url);
return (
/^\/.*\/\d+$/i.test(pathname) ||
/^\/@[^/]+\/statuses\/\w+$/i.test(pathname) || // GoToSocial
/^\/@[^/]+\/(statuses|posts)\/\w+\/?$/i.test(pathname) || // GoToSocial, Takahe
/^\/notes\/[a-z0-9]+$/i.test(pathname) || // Misskey, Calckey
/^\/(notice|objects)\/[a-z0-9-]+$/i.test(pathname) // Pleroma
);

View file

@ -5,7 +5,7 @@ export default function openCompose(opts) {
const top = Math.max(0, (screenHeight - 450) / 2);
const width = Math.min(screenWidth, 600);
const height = Math.min(screenHeight, 450);
const winUID = opts.uid || Math.random();
const winUID = opts?.uid || Math.random();
const newWin = window.open(
url,
'compose' + winUID,

View file

@ -0,0 +1,233 @@
// Utils for push notifications
import { api } from './api';
import { getCurrentAccount } from './store-utils';
// Subscription is an object with the following structure:
// {
// data: {
// alerts: {
// admin: {
// report: boolean,
// signUp: boolean,
// },
// favourite: boolean,
// follow: boolean,
// mention: boolean,
// poll: boolean,
// reblog: boolean,
// status: boolean,
// update: boolean,
// }
// },
// policy: "all" | "followed" | "follower" | "none",
// subscription: {
// endpoint: string,
// keys: {
// auth: string,
// p256dh: string,
// },
// },
// }
// Back-end CRUD
// =============
function createBackendPushSubscription(subscription) {
const { masto } = api();
return masto.v1.webPushSubscriptions.create(subscription);
}
function fetchBackendPushSubscription() {
const { masto } = api();
return masto.v1.webPushSubscriptions.fetch();
}
function updateBackendPushSubscription(subscription) {
const { masto } = api();
return masto.v1.webPushSubscriptions.update(subscription);
}
function removeBackendPushSubscription() {
const { masto } = api();
return masto.v1.webPushSubscriptions.remove();
}
// Front-end
// =========
export function isPushSupported() {
return 'serviceWorker' in navigator && 'PushManager' in window;
}
export function getRegistration() {
// return navigator.serviceWorker.ready;
return navigator.serviceWorker.getRegistration();
}
async function getSubscription() {
const registration = await getRegistration();
const subscription = registration
? await registration.pushManager.getSubscription()
: undefined;
return { registration, subscription };
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = `${base64String}${padding}`
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Front-end <-> back-end
// ======================
export async function initSubscription() {
if (!isPushSupported()) return;
const { subscription } = await getSubscription();
let backendSubscription = null;
try {
backendSubscription = await fetchBackendPushSubscription();
} catch (err) {
if (/(not found|unknown)/i.test(err.message)) {
// No subscription found
} else {
// Other error
throw err;
}
}
console.log('INIT subscription', {
subscription,
backendSubscription,
});
// Check if the subscription changed
if (backendSubscription && subscription) {
const sameEndpoint = backendSubscription.endpoint === subscription.endpoint;
const { vapidKey } = getCurrentAccount();
const sameKey = backendSubscription.serverKey === vapidKey;
if (!sameEndpoint) {
throw new Error('Backend subscription endpoint changed');
}
if (sameKey) {
// Subscription didn't change
} else {
// Subscription changed
console.error('🔔 Subscription changed', {
sameEndpoint,
serverKey: backendSubscription.serverKey,
vapIdKey: vapidKey,
endpoint1: backendSubscription.endpoint,
endpoint2: subscription.endpoint,
sameKey,
key1: backendSubscription.serverKey,
key2: vapidKey,
});
throw new Error('Backend subscription key and vapid key changed');
// Only unsubscribe from backend, not from browser
// await removeBackendPushSubscription();
// // Now let's resubscribe
// // NOTE: I have no idea if this works
// return await updateSubscription({
// data: backendSubscription.data,
// policy: backendSubscription.policy,
// });
}
}
if (subscription && !backendSubscription) {
// check if account's vapidKey is same as subscription's applicationServerKey
const { vapidKey } = getCurrentAccount();
const { applicationServerKey } = subscription.options;
const vapidKeyStr = urlBase64ToUint8Array(vapidKey).toString();
const applicationServerKeyStr = new Uint8Array(
applicationServerKey,
).toString();
const sameKey = vapidKeyStr === applicationServerKeyStr;
if (sameKey) {
// Subscription didn't change
} else {
// Subscription changed
console.error('🔔 Subscription changed', {
vapidKeyStr,
applicationServerKeyStr,
sameKey,
});
// Unsubscribe since backend doesn't have a subscription
await subscription.unsubscribe();
throw new Error('Subscription key and vapid key changed');
}
}
// Check if backend subscription returns 404
// if (subscription && !backendSubscription) {
// // Re-subscribe to backend
// backendSubscription = await createBackendPushSubscription({
// subscription,
// data: {},
// policy: 'all',
// });
// }
return { subscription, backendSubscription };
}
export async function updateSubscription({ data, policy }) {
console.log('🔔 Updating subscription', { data, policy });
if (!isPushSupported()) return;
let { registration, subscription } = await getSubscription();
let backendSubscription = null;
if (subscription) {
try {
backendSubscription = await updateBackendPushSubscription({
data,
policy,
});
// TODO: save subscription in user settings
} catch (error) {
// Backend doesn't have a subscription for this user
// Create a new one
backendSubscription = await createBackendPushSubscription({
subscription,
data,
policy,
});
// TODO: save subscription in user settings
}
} else {
// User is not subscribed
const { vapidKey } = getCurrentAccount();
if (!vapidKey) throw new Error('No server key found');
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
});
backendSubscription = await createBackendPushSubscription({
subscription,
data,
policy,
});
// TODO: save subscription in user settings
}
return { subscription, backendSubscription };
}
export async function removeSubscription() {
if (!isPushSupported()) return;
const { subscription } = await getSubscription();
if (subscription) {
await removeBackendPushSubscription();
await subscription.unsubscribe();
}
}

View file

@ -1,31 +1,35 @@
import mem from 'mem';
const root = document.documentElement;
const style = getComputedStyle(root);
const defaultBoundingBoxPadding = 8;
function _safeBoundingBoxPadding(paddings = []) {
// paddings = [top, right, bottom, left]
let safeAreaInsets = [0, 0, 0, 0];
function getSafeAreaInsets() {
// Get safe area inset variables from root
const safeAreaInsetTop = style.getPropertyValue('--sai-top');
const safeAreaInsetRight = style.getPropertyValue('--sai-right');
const safeAreaInsetBottom = style.getPropertyValue('--sai-bottom');
const safeAreaInsetLeft = style.getPropertyValue('--sai-left');
const str = [
safeAreaInsetTop,
safeAreaInsetRight,
safeAreaInsetBottom,
safeAreaInsetLeft,
]
.map(
(v, i) =>
(parseInt(v, 10) || defaultBoundingBoxPadding) + (paddings[i] || 0),
)
safeAreaInsets = [
// top, right, bottom, left (clockwise)
Math.max(0, parseInt(safeAreaInsetTop, 10)),
Math.max(0, parseInt(safeAreaInsetRight, 10)),
Math.max(0, parseInt(safeAreaInsetBottom, 10)),
Math.max(0, parseInt(safeAreaInsetLeft, 10)),
];
}
requestAnimationFrame(getSafeAreaInsets);
function safeBoundingBoxPadding(paddings = []) {
const str = safeAreaInsets
.map((v, i) => (v || defaultBoundingBoxPadding) + (paddings[i] || 0))
.join(' ');
// console.log(str);
return str;
}
const safeBoundingBoxPadding = mem(_safeBoundingBoxPadding, {
maxAge: 10000, // 10 seconds
});
// Update safe area insets when orientation or resize
if (CSS.supports('top: env(safe-area-inset-top)')) {
window.addEventListener('resize', getSafeAreaInsets, { passive: true });
}
export default safeBoundingBoxPadding;

View file

@ -1,5 +1,7 @@
import Toastify from 'toastify-js';
window._showToast = showToast;
function showToast(props) {
if (typeof props === 'string') {
props = { text: props };

View file

@ -24,11 +24,16 @@ const states = proxy({
notificationsLastFetchTime: null,
accounts: {},
reloadStatusPage: 0,
reloadGenericAccounts: {
id: null,
counter: 0,
},
spoilers: {},
scrollPositions: {},
unfurledLinks: {},
statusQuotes: {},
accounts: {},
routeNotification: null,
// Modals
showCompose: false,
showSettings: false,
@ -37,6 +42,9 @@ const states = proxy({
showDrafts: false,
showMediaModal: false,
showShortcutsSettings: false,
showKeyboardShortcutsHelp: false,
showGenericAccounts: false,
showMediaAlt: false,
// Shortcuts
shortcuts: store.account.get('shortcuts') ?? [],
// Settings
@ -136,6 +144,9 @@ export function hideAllModals() {
states.showDrafts = false;
states.showMediaModal = false;
states.showShortcutsSettings = false;
states.showKeyboardShortcutsHelp = false;
states.showGenericAccounts = false;
states.showMediaAlt = false;
}
export function statusKey(id, instance) {

View file

@ -5,6 +5,11 @@ export function getAccount(id) {
return accounts.find((a) => a.info.id === id) || accounts[0];
}
export function getAccountByAccessToken(accessToken) {
const accounts = store.local.getJSON('accounts') || [];
return accounts.find((a) => a.accessToken === accessToken);
}
export function getCurrentAccount() {
const currentAccount = store.session.get('currentAccount');
const account = getAccount(currentAccount);
@ -27,6 +32,7 @@ export function saveAccount(account) {
acc.info = account.info;
acc.instanceURL = account.instanceURL;
acc.accessToken = account.accessToken;
acc.vapidKey = account.vapidKey;
} else {
accounts.push(account);
}

View file

@ -30,7 +30,8 @@ export default function useTitle(title, path) {
}
useLayoutEffect(() => {
const unsub = subscribeKey(states, 'currentLocation', setTitle);
setTitle();
return subscribeKey(states, 'currentLocation', setTitle);
return unsub;
}, [title, path]);
}

17
src/utils/useTruncated.js Normal file
View file

@ -0,0 +1,17 @@
import { useRef } from 'preact/hooks';
import useResizeObserver from 'use-resize-observer';
export default function useTruncated({ className = 'truncated' } = {}) {
const ref = useRef();
useResizeObserver({
ref,
box: 'border-box',
onResize: ({ height }) => {
if (ref.current) {
const { scrollHeight } = ref.current;
ref.current.classList.toggle(className, scrollHeight > height);
}
},
});
return ref;
}