mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-02-02 06:06:41 +01: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,
|
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,
|
||||||
|
|
|
@ -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
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