From e6d6adb480dbd2a708e4e8498516c3ebf5404d0f Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Thu, 23 Feb 2023 16:45:53 +0800 Subject: [PATCH] First small step to resolving mastodon links And open them inside Phanpy instead of like an external link --- package-lock.json | 17 +++++ package.json | 1 + src/components/status.jsx | 103 ++++++++++++++++++++++++++++-- src/utils/handle-content-links.js | 19 +++--- src/utils/states.js | 1 + 5 files changed, 125 insertions(+), 16 deletions(-) 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/src/components/status.jsx b/src/components/status.jsx index f5525f37..c7ab5465 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,51 @@ export function formatDuration(time) { } } +function isMastodonLinkMaybe(url) { + return /^https:\/\/.*\/\d+$/i.test(url); +} + +const denylistDomains = /(twitter|github)\.com/i; + +function _unfurlMastodonLink(instance, url) { + if (denylistDomains.test(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 { + throw new Error('No results'); + } + }) + .catch((e) => { + console.warn(e); + // Silently fail + }); +} + +const unfurlMastodonLink = throttle(_unfurlMastodonLink); + export default memo(Status); diff --git a/src/utils/handle-content-links.js b/src/utils/handle-content-links.js index a91e4ec3..14c742c6 100644 --- a/src/utils/handle-content-links.js +++ b/src/utils/handle-content-links.js @@ -4,13 +4,9 @@ function handleContentLinks(opts) { const { mentions = [], instance } = opts || {}; return (e) => { let { target } = e; - if (target.parentNode.tagName.toLowerCase() === 'a') { - target = target.parentNode; - } - if ( - target.tagName.toLowerCase() === 'a' && - target.classList.contains('u-url') - ) { + target = target.closest('a'); + if (!target) return; + if (target.classList.contains('u-url')) { const targetText = ( target.querySelector('span') || target ).innerText.trim(); @@ -39,16 +35,17 @@ function handleContentLinks(opts) { instance, }; } - } else if ( - target.tagName.toLowerCase() === 'a' && - target.classList.contains('hashtag') - ) { + } else if (target.classList.contains('hashtag')) { e.preventDefault(); e.stopPropagation(); const tag = target.innerText.replace(/^#/, '').trim(); const hashURL = instance ? `#/${instance}/t/${tag}` : `#/t/${tag}`; console.log({ hashURL }); location.hash = hashURL; + } else if (states.unfurledLinks[target.href]?.url) { + e.preventDefault(); + e.stopPropagation(); + location.hash = `#${states.unfurledLinks[target.href].url}`; } }; } diff --git a/src/utils/states.js b/src/utils/states.js index 432f7df2..f3ad16e2 100644 --- a/src/utils/states.js +++ b/src/utils/states.js @@ -24,6 +24,7 @@ const states = proxy({ reloadStatusPage: 0, spoilers: {}, scrollPositions: {}, + unfurledLinks: {}, // Modals showCompose: false, showSettings: false,