diff --git a/package-lock.json b/package-lock.json index f4db9921..a9954791 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "masto": "~5.10.0", "mem": "~9.0.2", "p-retry": "~5.1.2", + "p-throttle": "~5.0.0", "preact": "~10.12.1", "react-hotkeys-hook": "~4.3.7", "react-intersection-observer": "~9.4.2", @@ -4985,6 +4986,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-throttle": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-5.0.0.tgz", + "integrity": "sha512-iXBFjW4kP/5Ivw7uC9EDnj+/xo3pNn4Rws3zgMGPwXnWTv1M3P0LVdZxLrqRUI5JK0Fp3Du0bt6lCaVrI3WF7g==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -10482,6 +10494,11 @@ "retry": "^0.13.1" } }, + "p-throttle": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-5.0.0.tgz", + "integrity": "sha512-iXBFjW4kP/5Ivw7uC9EDnj+/xo3pNn4Rws3zgMGPwXnWTv1M3P0LVdZxLrqRUI5JK0Fp3Du0bt6lCaVrI3WF7g==" + }, "param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", diff --git a/package.json b/package.json index a223c108..74963b2b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "masto": "~5.10.0", "mem": "~9.0.2", "p-retry": "~5.1.2", + "p-throttle": "~5.0.0", "preact": "~10.12.1", "react-hotkeys-hook": "~4.3.7", "react-intersection-observer": "~9.4.2", diff --git a/public/sw.js b/public/sw.js index 46fa48e8..ddf9f42d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -33,9 +33,13 @@ const imageRoute = new Route( ); registerRoute(imageRoute); -// 1-day cache for /api/v1/instance and /api/v1/custom_emojis +// 1-day cache for +// - /api/v1/instance +// - /api/v1/custom_emojis +// - /api/v1/preferences +// - /api/v1/lists/:id const apiExtendedRoute = new RegExpRoute( - /^https?:\/\/[^\/]+\/api\/v\d+\/(instance|custom_emojis)/, + /^https?:\/\/[^\/]+\/api\/v\d+\/(instance|custom_emojis|preferences|lists\/\d+)/, new StaleWhileRevalidate({ cacheName: 'api-extended', plugins: [ diff --git a/src/app.css b/src/app.css index 3f6f9e6c..ec252f73 100644 --- a/src/app.css +++ b/src/app.css @@ -745,14 +745,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { margin-top: 16px; transform: translate(-50%, 0); font-size: 90%; - background-color: var(--button-bg-color); - background-image: linear-gradient( - 160deg, - rgba(255, 255, 255, 0.5), - rgba(255, 255, 255, 0) 50% - ); - box-shadow: 0 3px 8px -1px var(--drop-shadow-color), - 0 10px 36px -4px var(--button-bg-blur-color); } .updates-button .icon { vertical-align: top; @@ -905,12 +897,16 @@ body:has(.status-deck) .media-post-link { transition: all 0.3s ease-in-out; } /* Don't do this if there's a modal sheet (.sheet) */ - :has(#modal-container .carousel):has(.status-deck):not(:has(.sheet)) + :has(#modal-container .carousel):has(.status-deck):not( + :has(.sheet, #compose-container) + ) .status-deck { width: 350px; min-width: 0; } - :has(#modal-container .carousel):has(.status-deck):not(:has(.sheet)) + :has(#modal-container .carousel):has(.status-deck):not( + :has(.sheet, #compose-container) + ) #modal-container > div { left: 0; @@ -1099,9 +1095,11 @@ body:has(.status-deck) .media-post-link { backdrop-filter: blur(8px) saturate(3); border: var(--hairline-width) solid var(--bg-color); box-shadow: 0 3px 8px -1px var(--drop-shadow-color); + text-shadow: 0 var(--hairline-width) var(--bg-color), 0 0 8px var(--bg-color); } .glass-menu .szh-menu__item--hover { background-color: var(--button-bg-blur-color); + text-shadow: none; } /* DONUT METER */ @@ -1159,20 +1157,26 @@ meter.donut:is(.danger, .explode):after { color: var(--red-color); } -/* TOAST */ +/* SHINY PILL */ -:root .toastify { +.shiny-pill { + color: var(--button-text-color); + text-shadow: 0 -1px var(--drop-shadow-color); background-color: var(--button-bg-color); background-image: linear-gradient( 160deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0) 50% ); - color: var(--button-text-color); - border-radius: 10em; - padding: 8px 16px; box-shadow: 0 3px 8px -1px var(--drop-shadow-color), - 0 10px 36px -4px var(--button-bg-blur-color); + 0 10px 36px -4px var(--button-bg-blur-color), + inset var(--hairline-width) var(--hairline-width) rgba(255, 255, 255, 0.5); +} + +/* TOAST */ + +:root .toastify { + padding: 8px 16px; } .toastify-bottom { margin-bottom: env(safe-area-inset-bottom); @@ -1487,6 +1491,9 @@ ul.link-list li a .icon { transition: transform 0.4s var(--timing-function); --back-transition: transform 0.4s ease-out; } + .timeline:not(.flat) > li:not(:has(.status-carousel)) { + transform: translate3d(0, 0, 0); + } .timeline:not(.flat) > li:has(.status-link.is-active) { transition: var(--back-transition); transform: translate3d(-2.5vw, 0, 0); diff --git a/src/app.jsx b/src/app.jsx index 1a3b6606..113b74ae 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -318,13 +318,14 @@ function App() { null } onClose={(results) => { - const { newStatus } = results || {}; + const { newStatus, instance } = results || {}; states.showCompose = false; window.__COMPOSE__ = null; if (newStatus) { states.reloadStatusPage++; setTimeout(() => { const toast = Toastify({ + className: 'shiny-pill', text: 'Status posted. Check it out.', duration: 10_000, // 10 seconds gravity: 'bottom', @@ -333,7 +334,11 @@ function App() { onClick: () => { toast.hideToast(); states.prevLocation = location; - navigate(`/s/${newStatus.id}`); + navigate( + instance + ? `/${instance}/s/${newStatus.id}` + : `/s/${newStatus.id}`, + ); }, }); toast.showToast(); diff --git a/src/components/account.jsx b/src/components/account.jsx index ea6ab6e1..73ed46be 100644 --- a/src/components/account.jsx +++ b/src/components/account.jsx @@ -1,6 +1,7 @@ import './account.css'; import { useEffect, useRef, useState } from 'preact/hooks'; +import { useHotkeys } from 'react-hotkeys-hook'; import { api } from '../utils/api'; import emojifyText from '../utils/emojify-text'; @@ -82,8 +83,11 @@ function Account({ account, instance: propInstance, onClose }) { username, } = info || {}; + const escRef = useHotkeys('esc', onClose, [onClose]); + return (
diff --git a/src/components/compose.jsx b/src/components/compose.jsx index 70393ef4..dd1b2dd9 100644 --- a/src/components/compose.jsx +++ b/src/components/compose.jsx @@ -113,7 +113,8 @@ function Compose({ const currentAccount = getCurrentAccount(); const currentAccountInfo = currentAccount.info; - const { configuration } = getCurrentInstance(); + const instance = getCurrentInstance(); + const { configuration } = instance; console.log('⚙️ Configuration', configuration); const { @@ -141,20 +142,6 @@ function Compose({ const prefs = store.account.get('preferences') || {}; - const customEmojis = useRef(); - useEffect(() => { - (async () => { - try { - const emojis = await masto.v1.customEmojis.list(); - console.log({ emojis }); - customEmojis.current = emojis; - } catch (e) { - // silent fail - console.error(e); - } - })(); - }, []); - const oninputTextarea = () => { if (!textareaRef.current) return; textareaRef.current.dispatchEvent(new Event('input')); @@ -799,6 +786,7 @@ function Compose({ // Close onClose({ newStatus, + instance, }); } catch (e) { console.error(e); @@ -1057,6 +1045,20 @@ const Textarea = forwardRef((props, ref) => { const snapStates = useSnapshot(states); const charCount = snapStates.composerCharacterCount; + const customEmojis = useRef(); + useEffect(() => { + (async () => { + try { + const emojis = await masto.v1.customEmojis.list(); + console.log({ emojis }); + customEmojis.current = emojis; + } catch (e) { + // silent fail + console.error(e); + } + })(); + }, []); + const textExpanderRef = useRef(); const textExpanderTextRef = useRef(''); useEffect(() => { diff --git a/src/components/media.jsx b/src/components/media.jsx index 64d3ac4f..659d5902 100644 --- a/src/components/media.jsx +++ b/src/components/media.jsx @@ -16,7 +16,7 @@ audio = Audio track function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) { const { blurhash, description, meta, previewUrl, remoteUrl, url, type } = media; - const { original, small, focus } = meta || {}; + const { original = {}, small, focus } = meta || {}; const width = showOriginal ? original?.width : small?.width; const height = showOriginal ? original?.height : small?.height; diff --git a/src/components/shortcuts-settings.jsx b/src/components/shortcuts-settings.jsx index da4af345..d933ce27 100644 --- a/src/components/shortcuts-settings.jsx +++ b/src/components/shortcuts-settings.jsx @@ -1,5 +1,6 @@ import './shortcuts-settings.css'; +import mem from 'mem'; import { useEffect, useState } from 'preact/hooks'; import { useSnapshot } from 'valtio'; @@ -90,10 +91,15 @@ export const SHORTCUTS_META = { icon: 'notification', }, list: { - title: async ({ id }) => { - const list = await api().masto.v1.lists.fetch(id); - return list.title; - }, + title: mem( + async ({ id }) => { + const list = await api().masto.v1.lists.fetch(id); + return list.title; + }, + { + cacheKey: ([{ id }]) => id, + }, + ), path: ({ id }) => `/l/${id}`, icon: 'list', }, @@ -109,10 +115,15 @@ export const SHORTCUTS_META = { icon: 'search', }, 'account-statuses': { - title: async ({ id }) => { - const account = await api().masto.v1.accounts.fetch(id); - return account.username || account.acct || account.displayName; - }, + title: mem( + async ({ id }) => { + const account = await api().masto.v1.accounts.fetch(id); + return account.username || account.acct || account.displayName; + }, + { + cacheKey: ([{ id }]) => id, + }, + ), path: ({ id }) => `/a/${id}`, icon: 'user', }, diff --git a/src/components/shortcuts.jsx b/src/components/shortcuts.jsx index 280f75cc..19c8d338 100644 --- a/src/components/shortcuts.jsx +++ b/src/components/shortcuts.jsx @@ -1,7 +1,7 @@ import './shortcuts.css'; import { Menu, MenuItem } from '@szhsin/react-menu'; -import { useRef } from 'preact/hooks'; +import { useMemo, useRef } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import { useNavigate } from 'react-router-dom'; import { useSnapshot } from 'valtio'; @@ -23,29 +23,33 @@ function Shortcuts() { const menuRef = useRef(); - const formattedShortcuts = shortcuts - .map((pin, i) => { - const { type, ...data } = pin; - if (!SHORTCUTS_META[type]) return null; - let { path, title, icon } = SHORTCUTS_META[type]; + const formattedShortcuts = useMemo( + () => + shortcuts + .map((pin, i) => { + const { type, ...data } = pin; + if (!SHORTCUTS_META[type]) return null; + let { path, title, icon } = SHORTCUTS_META[type]; - if (typeof path === 'function') { - path = path(data, i); - } - if (typeof title === 'function') { - title = title(data); - } - if (typeof icon === 'function') { - icon = icon(data); - } + if (typeof path === 'function') { + path = path(data, i); + } + if (typeof title === 'function') { + title = title(data); + } + if (typeof icon === 'function') { + icon = icon(data); + } - return { - path, - title, - icon, - }; - }) - .filter(Boolean); + return { + path, + title, + icon, + }; + }) + .filter(Boolean), + [shortcuts], + ); const navigate = useNavigate(); useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => { diff --git a/src/components/status.jsx b/src/components/status.jsx index f5525f37..4328ce67 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -2,6 +2,7 @@ import './status.css'; import { Menu, MenuItem } from '@szhsin/react-menu'; import mem from 'mem'; +import pThrottle from 'p-throttle'; import { memo } from 'preact/compat'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import 'swiped-events'; @@ -26,6 +27,11 @@ import Link from './link'; import Media from './media'; import RelativeTime from './relative-time'; +const throttle = pThrottle({ + limit: 1, + interval: 1000, +}); + function fetchAccount(id, masto) { try { return masto.v1.accounts.fetch(id); @@ -374,14 +380,26 @@ function Status({ __html: enhanceContent(content, { emojis, postEnhanceDOM: (dom) => { + // Remove target="_blank" from links dom .querySelectorAll('a.u-url[target="_blank"]') .forEach((a) => { - // Remove target="_blank" from links if (!/http/i.test(a.innerText.trim())) { a.removeAttribute('target'); } }); + // Unfurl Mastodon links + dom + .querySelectorAll( + 'a[href]:not(.u-url):not(.mention):not(.hashtag)', + ) + .forEach((a) => { + if (isMastodonLinkMaybe(a.href)) { + unfurlMastodonLink(currentInstance, a.href).then(() => { + a.removeAttribute('target'); + }); + } + }); }, }), }} @@ -463,7 +481,9 @@ function Status({ !sensitive && !spoilerText && !poll && - !mediaAttachments.length && } + !mediaAttachments.length && ( + + )}
{size === 'l' && ( <> @@ -702,7 +722,7 @@ function Status({ ); } -function Card({ card }) { +function Card({ card, instance }) { const { blurhash, title, @@ -729,12 +749,38 @@ function Card({ card }) { const isLandscape = width / height >= 1.2; const size = isLandscape ? 'large' : ''; + const [cardStatusURL, setCardStatusURL] = useState(null); + // const [cardStatusID, setCardStatusID] = useState(null); + useEffect(() => { + if (hasText && image && isMastodonLinkMaybe(url)) { + unfurlMastodonLink(instance, url).then((result) => { + if (!result) return; + const { id, url } = result; + setCardStatusURL('#' + url); + + // NOTE: This is for quote post + // (async () => { + // const { masto } = api({ instance }); + // const status = await masto.v1.statuses.fetch(id); + // saveStatus(status, instance); + // setCardStatusID(id); + // })(); + }); + } + }, [hasText, image]); + + // if (cardStatusID) { + // return ( + // + // ); + // } + if (hasText && image) { const domain = new URL(url).hostname.replace(/^www\./, ''); return ( @@ -1129,4 +1175,57 @@ export function formatDuration(time) { } } +function isMastodonLinkMaybe(url) { + return /^https:\/\/.*\/\d+$/i.test(url); +} + +const denylistDomains = /(twitter|github)\.com/i; +const failedUnfurls = {}; + +function _unfurlMastodonLink(instance, url) { + if (denylistDomains.test(url)) { + return; + } + if (failedUnfurls[url]) { + return; + } + const instanceRegex = new RegExp(instance + '/'); + if (instanceRegex.test(states.unfurledLinks[url]?.url)) { + return Promise.resolve(states.unfurledLinks[url]); + } + console.debug('🦦 Unfurling URL', url); + const { masto } = api({ instance }); + return masto.v2 + .search({ + q: url, + type: 'statuses', + resolve: true, + limit: 1, + }) + .then((results) => { + if (results.statuses.length > 0) { + const status = results.statuses[0]; + const { id } = status; + const statusURL = `/${instance}/s/${id}`; + const result = { + id, + url: statusURL, + }; + console.debug('🦦 Unfurled URL', url, id, statusURL); + states.unfurledLinks[url] = result; + return result; + } else { + failedUnfurls[url] = true; + throw new Error('No results'); + } + }) + .catch((e) => { + failedUnfurls[url] = true; + console.warn(e); + // Silently fail + }); +} + +const unfurlMastodonLink = throttle(_unfurlMastodonLink); + export default memo(Status); diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx index 78cb035b..e0c4ccbc 100644 --- a/src/components/timeline.jsx +++ b/src/components/timeline.jsx @@ -275,7 +275,7 @@ function Timeline({ !hiddenUI && showNew && (