mirror of
https://github.com/cheeaun/phanpy.git
synced 2024-11-24 10:15:37 +03:00
Extract unfurling out of status component
This commit is contained in:
parent
d7d838ebf8
commit
bd38122f1b
3 changed files with 210 additions and 164 deletions
|
@ -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,
|
||||
|
|
|
@ -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 = /<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) => {
|
||||
return masto.v1.statuses.$select(statusID).fetch();
|
||||
});
|
||||
|
|
136
src/utils/unfurl-link.jsx
Normal file
136
src/utils/unfurl-link.jsx
Normal 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;
|
Loading…
Reference in a new issue