1
0
Fork 0

Extract unfurling out of status component

This commit is contained in:
Lim Chee Aun 2023-12-30 18:13:56 +08:00
parent d7d838ebf8
commit bd38122f1b
3 changed files with 210 additions and 164 deletions

View file

@ -9,7 +9,6 @@ import {
MenuItem, MenuItem,
} from '@szhsin/react-menu'; } from '@szhsin/react-menu';
import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash'; import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash';
import pThrottle from 'p-throttle';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { import {
useCallback, useCallback,
@ -20,10 +19,8 @@ import {
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useLongPress } from 'use-long-press'; import { useLongPress } from 'use-long-press';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import { snapshot } from 'valtio/vanilla';
import AccountBlock from '../components/account-block'; import AccountBlock from '../components/account-block';
import EmojiText from '../components/emoji-text'; import EmojiText from '../components/emoji-text';
@ -54,6 +51,7 @@ import { speak, supportsTTS } from '../utils/speech';
import states, { getStatus, saveStatus, statusKey } from '../utils/states'; import states, { getStatus, saveStatus, statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek'; import statusPeek from '../utils/status-peek';
import store from '../utils/store'; import store from '../utils/store';
import unfurlMastodonLink from '../utils/unfurl-link';
import useTruncated from '../utils/useTruncated'; import useTruncated from '../utils/useTruncated';
import visibilityIconsMap from '../utils/visibility-icons-map'; import visibilityIconsMap from '../utils/visibility-icons-map';
@ -68,10 +66,6 @@ import TranslationBlock from './translation-block';
const SHOW_COMMENT_COUNT_LIMIT = 280; const SHOW_COMMENT_COUNT_LIMIT = 280;
const INLINE_TRANSLATE_LIMIT = 140; const INLINE_TRANSLATE_LIMIT = 140;
const throttle = pThrottle({
limit: 1,
interval: 1000,
});
function fetchAccount(id, masto) { function fetchAccount(id, masto) {
return masto.v1.accounts.$select(id).fetch(); return masto.v1.accounts.$select(id).fetch();
@ -1587,34 +1581,34 @@ function Status({
a.removeAttribute('target'); a.removeAttribute('target');
} }
}); });
if (previewMode) return; // if (previewMode) return;
// Unfurl Mastodon links // Unfurl Mastodon links
Array.from( // Array.from(
dom.querySelectorAll( // dom.querySelectorAll(
'a[href]:not(.u-url):not(.mention):not(.hashtag)', // 'a[href]:not(.u-url):not(.mention):not(.hashtag)',
), // ),
) // )
.filter((a) => { // .filter((a) => {
const url = a.href; // const url = a.href;
const isPostItself = // const isPostItself =
url === status.url || url === status.uri; // url === status.url || url === status.uri;
return !isPostItself && isMastodonLinkMaybe(url); // return !isPostItself && isMastodonLinkMaybe(url);
}) // })
.forEach((a, i) => { // .forEach((a, i) => {
unfurlMastodonLink(currentInstance, a.href).then( // unfurlMastodonLink(currentInstance, a.href).then(
(result) => { // (result) => {
if (!result) return; // if (!result) return;
a.removeAttribute('target'); // a.removeAttribute('target');
if (!sKey) return; // if (!sKey) return;
if (!Array.isArray(states.statusQuotes[sKey])) { // if (!Array.isArray(states.statusQuotes[sKey])) {
states.statusQuotes[sKey] = []; // states.statusQuotes[sKey] = [];
} // }
if (!states.statusQuotes[sKey][i]) { // if (!states.statusQuotes[sKey][i]) {
states.statusQuotes[sKey].splice(i, 0, result); // states.statusQuotes[sKey].splice(i, 0, result);
} // }
}, // },
); // );
}); // });
}, },
}), }),
}} }}
@ -2257,130 +2251,6 @@ export function formatDuration(time) {
} }
} }
const denylistDomains = /(twitter|github)\.com/i;
const failedUnfurls = {};
function _unfurlMastodonLink(instance, url) {
const snapStates = snapshot(states);
if (denylistDomains.test(url)) {
return;
}
if (failedUnfurls[url]) {
return;
}
const instanceRegex = new RegExp(instance + '/');
if (instanceRegex.test(snapStates.unfurledLinks[url]?.url)) {
return Promise.resolve(snapStates.unfurledLinks[url]);
}
console.debug('🦦 Unfurling URL', url);
let remoteInstanceFetch;
let theURL = url;
// https://elk.zone/domain.com/@stest/123 -> https://domain.com/@stest/123
if (/\/\/elk\.[^\/]+\/[^\/]+\.[^\/]+/i.test(theURL)) {
theURL = theURL.replace(/elk\.[^\/]+\//i, '');
}
// https://trunks.social/status/domain.com/@stest/123 -> https://domain.com/@stest/123
if (/\/\/trunks\.[^\/]+\/status\/[^\/]+\.[^\/]+/i.test(theURL)) {
theURL = theURL.replace(/trunks\.[^\/]+\/status\//i, '');
}
// https://phanpy.social/#/domain.com/s/123 -> https://domain.com/statuses/123
if (/\/#\/[^\/]+\.[^\/]+\/s\/.+/i.test(theURL)) {
const urlAfterHash = theURL.split('/#/')[1];
const finalURL = urlAfterHash.replace(/\/s\//i, '/@fakeUsername/');
theURL = `https://${finalURL}`;
}
let urlObj;
try {
urlObj = new URL(theURL);
} catch (e) {
return;
}
const domain = urlObj.hostname;
const path = urlObj.pathname;
// Regex /:username/:id, where username = @username or @username@domain, id = number
const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/(\d+)$/i;
const statusMatch = statusRegex.exec(path);
if (statusMatch) {
const id = statusMatch[3];
const { masto } = api({ instance: domain });
remoteInstanceFetch = masto.v1.statuses
.$select(id)
.fetch()
.then((status) => {
if (status?.id) {
return {
status,
instance: domain,
};
} else {
throw new Error('No results');
}
});
}
const { masto } = api({ instance });
const mastoSearchFetch = masto.v2.search
.fetch({
q: theURL,
type: 'statuses',
resolve: true,
limit: 1,
})
.then((results) => {
if (results.statuses.length > 0) {
const status = results.statuses[0];
return {
status,
instance,
};
} else {
throw new Error('No results');
}
});
function handleFulfill(result) {
const { status, instance } = result;
const { id } = status;
const selfURL = `/${instance}/s/${id}`;
console.debug('🦦 Unfurled URL', url, id, selfURL);
const data = {
id,
instance,
url: selfURL,
};
states.unfurledLinks[url] = data;
saveStatus(status, instance, {
skipThreading: true,
});
return data;
}
function handleCatch(e) {
failedUnfurls[url] = true;
}
if (remoteInstanceFetch) {
// return Promise.any([remoteInstanceFetch, mastoSearchFetch])
// .then(handleFulfill)
// .catch(handleCatch);
// If mastoSearchFetch is fulfilled within 3s, return it, else return remoteInstanceFetch
const finalPromise = Promise.race([
mastoSearchFetch,
new Promise((resolve, reject) => setTimeout(reject, 3000)),
]).catch(() => {
// If remoteInstanceFetch is fullfilled, return it, else return mastoSearchFetch
return remoteInstanceFetch.catch(() => mastoSearchFetch);
});
return finalPromise.then(handleFulfill).catch(handleCatch);
} else {
return mastoSearchFetch.then(handleFulfill).catch(handleCatch);
}
}
function nicePostURL(url) { function nicePostURL(url) {
if (!url) return; if (!url) return;
const urlObj = new URL(url); const urlObj = new URL(url);
@ -2404,8 +2274,6 @@ function nicePostURL(url) {
); );
} }
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
function FilteredStatus({ function FilteredStatus({
status, status,
filterInfo, filterInfo,

View file

@ -2,9 +2,11 @@ import { proxy, subscribe } from 'valtio';
import { subscribeKey } from 'valtio/utils'; import { subscribeKey } from 'valtio/utils';
import { api } from './api'; import { api } from './api';
import isMastodonLinkMaybe from './isMastodonLinkMaybe';
import pmem from './pmem'; import pmem from './pmem';
import rateLimit from './ratelimit'; import rateLimit from './ratelimit';
import store from './store'; import store from './store';
import unfurlMastodonLink from './unfurl-link';
const states = proxy({ const states = proxy({
appVersion: {}, appVersion: {},
@ -168,10 +170,11 @@ export function saveStatus(status, instance, opts) {
opts = instance; opts = instance;
instance = null; instance = null;
} }
const { override, skipThreading } = Object.assign( const {
{ override: true, skipThreading: false }, override = true,
opts, skipThreading = false,
); skipUnfurling = false,
} = opts || {};
if (!status) return; if (!status) return;
const oldStatus = getStatus(status.id, instance); const oldStatus = getStatus(status.id, instance);
if (!override && oldStatus) return; if (!override && oldStatus) return;
@ -197,6 +200,13 @@ export function saveStatus(status, instance, opts) {
} }
}); });
} }
// UNFURLER
if (!skipUnfurling) {
queueMicrotask(() => {
unfurlStatus(status, instance);
});
}
} }
function _threadifyStatus(status, propInstance) { function _threadifyStatus(status, propInstance) {
@ -240,6 +250,38 @@ function _threadifyStatus(status, propInstance) {
} }
export const threadifyStatus = rateLimit(_threadifyStatus, 100); export const threadifyStatus = rateLimit(_threadifyStatus, 100);
const fauxDiv = document.createElement('div');
export function unfurlStatus(status, instance) {
const { instance: currentInstance } = api();
const content = status.reblog?.content || status.content;
const hasLink = /<a/i.test(content);
if (hasLink) {
const sKey = statusKey(status?.reblog?.id || status?.id, instance);
fauxDiv.innerHTML = content;
const links = fauxDiv.querySelectorAll(
'a[href]:not(.u-url):not(.mention):not(.hashtag)',
);
[...links]
.filter((a) => {
const url = a.href;
const isPostItself = url === status.url || url === status.uri;
return !isPostItself && isMastodonLinkMaybe(url);
})
.forEach((a, i) => {
unfurlMastodonLink(currentInstance, a.href).then((result) => {
if (!result) return;
if (!sKey) return;
if (!Array.isArray(states.statusQuotes[sKey])) {
states.statusQuotes[sKey] = [];
}
if (!states.statusQuotes[sKey][i]) {
states.statusQuotes[sKey].splice(i, 0, result);
}
});
});
}
}
const fetchStatus = pmem((statusID, masto) => { const fetchStatus = pmem((statusID, masto) => {
return masto.v1.statuses.$select(statusID).fetch(); return masto.v1.statuses.$select(statusID).fetch();
}); });

136
src/utils/unfurl-link.jsx Normal file
View file

@ -0,0 +1,136 @@
import pThrottle from 'p-throttle';
import { snapshot } from 'valtio/vanilla';
import { api } from './api';
import states, { saveStatus } from './states';
export const throttle = pThrottle({
limit: 1,
interval: 1000,
});
const denylistDomains = /(twitter|github)\.com/i;
const failedUnfurls = {};
function _unfurlMastodonLink(instance, url) {
const snapStates = snapshot(states);
if (denylistDomains.test(url)) {
return;
}
if (failedUnfurls[url]) {
return;
}
const instanceRegex = new RegExp(instance + '/');
if (instanceRegex.test(snapStates.unfurledLinks[url]?.url)) {
return Promise.resolve(snapStates.unfurledLinks[url]);
}
console.debug('🦦 Unfurling URL', url);
let remoteInstanceFetch;
let theURL = url;
// https://elk.zone/domain.com/@stest/123 -> https://domain.com/@stest/123
if (/\/\/elk\.[^\/]+\/[^\/]+\.[^\/]+/i.test(theURL)) {
theURL = theURL.replace(/elk\.[^\/]+\//i, '');
}
// https://trunks.social/status/domain.com/@stest/123 -> https://domain.com/@stest/123
if (/\/\/trunks\.[^\/]+\/status\/[^\/]+\.[^\/]+/i.test(theURL)) {
theURL = theURL.replace(/trunks\.[^\/]+\/status\//i, '');
}
// https://phanpy.social/#/domain.com/s/123 -> https://domain.com/statuses/123
if (/\/#\/[^\/]+\.[^\/]+\/s\/.+/i.test(theURL)) {
const urlAfterHash = theURL.split('/#/')[1];
const finalURL = urlAfterHash.replace(/\/s\//i, '/@fakeUsername/');
theURL = `https://${finalURL}`;
}
let urlObj;
try {
urlObj = new URL(theURL);
} catch (e) {
return;
}
const domain = urlObj.hostname;
const path = urlObj.pathname;
// Regex /:username/:id, where username = @username or @username@domain, id = number
const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/(\d+)$/i;
const statusMatch = statusRegex.exec(path);
if (statusMatch) {
const id = statusMatch[3];
const { masto } = api({ instance: domain });
remoteInstanceFetch = masto.v1.statuses
.$select(id)
.fetch()
.then((status) => {
if (status?.id) {
return {
status,
instance: domain,
};
} else {
throw new Error('No results');
}
});
}
const { masto } = api({ instance });
const mastoSearchFetch = masto.v2.search
.fetch({
q: theURL,
type: 'statuses',
resolve: true,
limit: 1,
})
.then((results) => {
if (results.statuses.length > 0) {
const status = results.statuses[0];
return {
status,
instance,
};
} else {
throw new Error('No results');
}
});
function handleFulfill(result) {
const { status, instance } = result;
const { id } = status;
const selfURL = `/${instance}/s/${id}`;
console.debug('🦦 Unfurled URL', url, id, selfURL);
const data = {
id,
instance,
url: selfURL,
};
states.unfurledLinks[url] = data;
saveStatus(status, instance, {
skipThreading: true,
});
return data;
}
function handleCatch(e) {
failedUnfurls[url] = true;
}
if (remoteInstanceFetch) {
// return Promise.any([remoteInstanceFetch, mastoSearchFetch])
// .then(handleFulfill)
// .catch(handleCatch);
// If mastoSearchFetch is fulfilled within 3s, return it, else return remoteInstanceFetch
const finalPromise = Promise.race([
mastoSearchFetch,
new Promise((resolve, reject) => setTimeout(reject, 3000)),
]).catch(() => {
// If remoteInstanceFetch is fullfilled, return it, else return mastoSearchFetch
return remoteInstanceFetch.catch(() => mastoSearchFetch);
});
return finalPromise.then(handleFulfill).catch(handleCatch);
} else {
return mastoSearchFetch.then(handleFulfill).catch(handleCatch);
}
}
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
export default unfurlMastodonLink;