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 && (