From bd38122f1b422a5d86eb89f7f92c99156af8e4be Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Sat, 30 Dec 2023 18:13:56 +0800 Subject: [PATCH] Extract unfurling out of status component --- src/components/status.jsx | 188 ++++++-------------------------------- src/utils/states.js | 50 +++++++++- src/utils/unfurl-link.jsx | 136 +++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 164 deletions(-) create mode 100644 src/utils/unfurl-link.jsx diff --git a/src/components/status.jsx b/src/components/status.jsx index df50936..9954a12 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -9,7 +9,6 @@ import { MenuItem, } from '@szhsin/react-menu'; import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash'; -import pThrottle from 'p-throttle'; import { memo } from 'preact/compat'; import { useCallback, @@ -20,10 +19,8 @@ import { useState, } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; -import { InView } from 'react-intersection-observer'; import { useLongPress } from 'use-long-press'; import { useSnapshot } from 'valtio'; -import { snapshot } from 'valtio/vanilla'; import AccountBlock from '../components/account-block'; 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 statusPeek from '../utils/status-peek'; import store from '../utils/store'; +import unfurlMastodonLink from '../utils/unfurl-link'; import useTruncated from '../utils/useTruncated'; import visibilityIconsMap from '../utils/visibility-icons-map'; @@ -68,10 +66,6 @@ import TranslationBlock from './translation-block'; const SHOW_COMMENT_COUNT_LIMIT = 280; const INLINE_TRANSLATE_LIMIT = 140; -const throttle = pThrottle({ - limit: 1, - interval: 1000, -}); function fetchAccount(id, masto) { return masto.v1.accounts.$select(id).fetch(); @@ -1587,34 +1581,34 @@ function Status({ a.removeAttribute('target'); } }); - if (previewMode) return; + // if (previewMode) return; // Unfurl Mastodon links - Array.from( - dom.querySelectorAll( - 'a[href]:not(.u-url):not(.mention):not(.hashtag)', - ), - ) - .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; - a.removeAttribute('target'); - if (!sKey) return; - if (!Array.isArray(states.statusQuotes[sKey])) { - states.statusQuotes[sKey] = []; - } - if (!states.statusQuotes[sKey][i]) { - states.statusQuotes[sKey].splice(i, 0, result); - } - }, - ); - }); + // Array.from( + // dom.querySelectorAll( + // 'a[href]:not(.u-url):not(.mention):not(.hashtag)', + // ), + // ) + // .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; + // a.removeAttribute('target'); + // if (!sKey) return; + // if (!Array.isArray(states.statusQuotes[sKey])) { + // states.statusQuotes[sKey] = []; + // } + // if (!states.statusQuotes[sKey][i]) { + // 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) { if (!url) return; const urlObj = new URL(url); @@ -2404,8 +2274,6 @@ function nicePostURL(url) { ); } -const unfurlMastodonLink = throttle(_unfurlMastodonLink); - function FilteredStatus({ status, filterInfo, diff --git a/src/utils/states.js b/src/utils/states.js index ac018af..25ffb84 100644 --- a/src/utils/states.js +++ b/src/utils/states.js @@ -2,9 +2,11 @@ import { proxy, subscribe } from 'valtio'; import { subscribeKey } from 'valtio/utils'; import { api } from './api'; +import isMastodonLinkMaybe from './isMastodonLinkMaybe'; import pmem from './pmem'; import rateLimit from './ratelimit'; import store from './store'; +import unfurlMastodonLink from './unfurl-link'; const states = proxy({ appVersion: {}, @@ -168,10 +170,11 @@ export function saveStatus(status, instance, opts) { opts = instance; instance = null; } - const { override, skipThreading } = Object.assign( - { override: true, skipThreading: false }, - opts, - ); + const { + override = true, + skipThreading = false, + skipUnfurling = false, + } = opts || {}; if (!status) return; const oldStatus = getStatus(status.id, instance); 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) { @@ -240,6 +250,38 @@ function _threadifyStatus(status, propInstance) { } 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 = / { + 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) => { return masto.v1.statuses.$select(statusID).fetch(); }); diff --git a/src/utils/unfurl-link.jsx b/src/utils/unfurl-link.jsx new file mode 100644 index 0000000..6d59655 --- /dev/null +++ b/src/utils/unfurl-link.jsx @@ -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;