import './status.css'; import { Menu, MenuDivider, MenuHeader, MenuItem } from '@szhsin/react-menu'; import debounce from 'just-debounce-it'; import pRetry from 'p-retry'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import { InView } from 'react-intersection-observer'; import { matchPath, useParams, useSearchParams } from 'react-router-dom'; import { useDebouncedCallback } from 'use-debounce'; import { useSnapshot } from 'valtio'; import Avatar from '../components/avatar'; import Icon from '../components/icon'; import Link from '../components/link'; import Loader from '../components/loader'; import MediaModal from '../components/media-modal'; import NameText from '../components/name-text'; import RelativeTime from '../components/relative-time'; import Status from '../components/status'; import { api } from '../utils/api'; import htmlContentLength from '../utils/html-content-length'; import shortenNumber from '../utils/shorten-number'; import states, { getStatus, saveStatus, statusKey, threadifyStatus, } from '../utils/states'; import statusPeek from '../utils/status-peek'; import { getCurrentAccount } from '../utils/store-utils'; import useScroll from '../utils/useScroll'; import useTitle from '../utils/useTitle'; const LIMIT = 40; const THREAD_LIMIT = 20; let cachedRepliesToggle = {}; let cachedStatusesMap = {}; function resetScrollPosition(id) { delete cachedStatusesMap[id]; delete states.scrollPositions[id]; } function StatusPage() { const { id, ...params } = useParams(); const { masto, instance } = api({ instance: params.instance }); const [searchParams, setSearchParams] = useSearchParams(); const mediaParam = searchParams.get('media'); const mediaOnlyParam = searchParams.get('media-only'); const mediaIndex = parseInt(mediaParam || mediaOnlyParam, 10); let showMedia = mediaIndex > 0; const mediaStatusID = searchParams.get('mediaStatusID'); const mediaStatus = getStatus(mediaStatusID, instance); if (mediaStatusID && !mediaStatus) { showMedia = false; } const showMediaOnly = showMedia && !!mediaOnlyParam; const sKey = statusKey(id, instance); const [heroStatus, setHeroStatus] = useState(states.statuses[sKey]); const closeLink = useMemo(() => { const { prevLocation } = states; const pathname = (prevLocation?.pathname || '') + (prevLocation?.search || ''); const matchStatusPath = matchPath('/:instance/s/:id', pathname) || matchPath('/s/:id', pathname); if (!pathname || matchStatusPath) { return '/'; } return pathname; }, []); useEffect(() => { if (!heroStatus && showMedia) { (async () => { try { const status = await masto.v1.statuses.fetch(id); saveStatus(status, instance); setHeroStatus(status); } catch (err) { console.error(err); alert('Unable to load status.'); location.hash = closeLink; } })(); } }, [showMedia]); const mediaAttachments = mediaStatusID ? mediaStatus?.mediaAttachments : heroStatus?.mediaAttachments; return ( <div class="deck-backdrop"> {showMedia ? ( mediaAttachments?.length ? ( <MediaModal mediaAttachments={mediaAttachments} statusID={mediaStatusID || id} instance={instance} index={mediaIndex - 1} onClose={() => { if (showMediaOnly) { location.hash = closeLink; } else { searchParams.delete('media'); searchParams.delete('mediaStatusID'); setSearchParams(searchParams); } }} /> ) : ( <div class="media-modal-container loading"> <Loader abrupt /> </div> ) ) : ( <Link to={closeLink} /> )} {!showMediaOnly && <StatusThread closeLink={closeLink} />} </div> ); } function StatusThread({ closeLink = '/' }) { const { id, ...params } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); const mediaParam = searchParams.get('media'); const showMedia = parseInt(mediaParam, 10) > 0; const { masto, instance } = api({ instance: params.instance }); const { masto: currentMasto, instance: currentInstance, authenticated, } = api(); const sameInstance = instance === currentInstance; const snapStates = useSnapshot(states); const [statuses, setStatuses] = useState([]); const [uiState, setUIState] = useState('default'); const heroStatusRef = useRef(); const sKey = statusKey(id, instance); const scrollableRef = useRef(); useEffect(() => { scrollableRef.current?.focus(); }, []); useEffect(() => { const onScroll = debounce(() => { // console.log('onScroll'); if (!scrollableRef.current) return; const { scrollTop } = scrollableRef.current; if (uiState !== 'loading') { states.scrollPositions[id] = scrollTop; } }, 50); scrollableRef.current?.addEventListener('scroll', onScroll, { passive: true, }); onScroll(); return () => { onScroll.cancel(); scrollableRef.current?.removeEventListener('scroll', onScroll); }; }, [id, uiState !== 'loading']); const scrollOffsets = useRef(); const initContext = ({ reloadHero } = {}) => { console.debug('initContext', id); setUIState('loading'); let heroTimer; const cachedStatuses = cachedStatusesMap[id]; if (cachedStatuses) { // Case 1: It's cached, let's restore them to make it snappy const reallyCachedStatuses = cachedStatuses.filter( (s) => states.statuses[sKey], // Some are not cached in the global state, so we need to filter them out ); setStatuses(reallyCachedStatuses); } else { // const heroIndex = statuses.findIndex((s) => s.id === id); // if (heroIndex !== -1) { // // Case 2: It's in current statuses. Slice off all descendant statuses after the hero status to be safe // const slicedStatuses = statuses.slice(0, heroIndex + 1); // setStatuses(slicedStatuses); // } else { // Case 3: Not cached and not in statuses, let's start from scratch setStatuses([{ id }]); // } } (async () => { const heroFetch = () => pRetry(() => masto.v1.statuses.fetch(id), { retries: 4, }); const contextFetch = pRetry(() => masto.v1.statuses.fetchContext(id), { retries: 8, }); const hasStatus = !!snapStates.statuses[sKey]; let heroStatus = snapStates.statuses[sKey]; if (hasStatus && !reloadHero) { console.debug('Hero status is cached'); } else { try { heroStatus = await heroFetch(); saveStatus(heroStatus, instance); // Give time for context to appear await new Promise((resolve) => { setTimeout(resolve, 100); }); } catch (e) { console.error(e); setUIState('error'); return; } } try { const context = await contextFetch; const { ancestors, descendants } = context; ancestors.forEach((status) => { saveStatus(status, instance, { skipThreading: true, }); }); const ancestorsIsThread = ancestors.every( (s) => s.account.id === heroStatus.account.id, ); const nestedDescendants = []; descendants.forEach((status) => { saveStatus(status, instance, { skipThreading: true, }); if (status.inReplyToAccountId === status.account.id) { // If replying to self, it's part of the thread, level 1 nestedDescendants.push(status); } else if (status.inReplyToId === heroStatus.id) { // If replying to the hero status, it's a reply, level 1 nestedDescendants.push(status); } else { // If replying to someone else, it's a reply to a reply, level 2 const parent = descendants.find((s) => s.id === status.inReplyToId); if (parent) { if (!parent.__replies) { parent.__replies = []; } parent.__replies.push(status); } else { // If no parent, something is wrong console.warn('No parent found for', status); } } }); console.log({ ancestors, descendants, nestedDescendants }); function expandReplies(_replies) { return _replies?.map((_r) => ({ id: _r.id, account: _r.account, repliesCount: _r.repliesCount, content: _r.content, replies: expandReplies(_r.__replies), })); } const allStatuses = [ ...ancestors.map((s) => ({ id: s.id, ancestor: true, isThread: ancestorsIsThread, accountID: s.account.id, repliesCount: s.repliesCount, })), { id, accountID: heroStatus.account.id }, ...nestedDescendants.map((s) => ({ id: s.id, accountID: s.account.id, descendant: true, thread: s.account.id === heroStatus.account.id, replies: expandReplies(s.__replies), })), ]; setUIState('default'); scrollOffsets.current = { offsetTop: heroStatusRef.current?.offsetTop, scrollTop: scrollableRef.current?.scrollTop, }; console.log({ allStatuses }); setStatuses(allStatuses); cachedStatusesMap[id] = allStatuses; // Let's threadify this one // Note that all non-hero statuses will trigger saveStatus which will threadify them too // By right, at this point, all descendant statuses should be cached threadifyStatus(heroStatus, instance); } catch (e) { console.error(e); setUIState('error'); } })(); return () => { clearTimeout(heroTimer); }; }; useEffect(initContext, [id, masto]); useEffect(() => { if (!statuses.length) return; console.debug('STATUSES', statuses); const scrollPosition = states.scrollPositions[id]; console.debug('scrollPosition', scrollPosition); if (!!scrollPosition) { console.debug('Case 1', { id, scrollPosition, }); scrollableRef.current.scrollTop = scrollPosition; } else if (scrollOffsets.current) { const newScrollOffsets = { offsetTop: heroStatusRef.current?.offsetTop, scrollTop: scrollableRef.current?.scrollTop, }; const newScrollTop = newScrollOffsets.offsetTop - scrollOffsets.current.offsetTop + newScrollOffsets.scrollTop; console.debug('Case 2', { scrollOffsets: scrollOffsets.current, newScrollOffsets, newScrollTop, statuses: [...statuses], }); scrollableRef.current.scrollTop = newScrollTop; } else if (statuses.length === 1) { console.debug('Case 3', { id, }); scrollableRef.current.scrollTop = 0; } // RESET scrollOffsets.current = null; }, [statuses]); useEffect(() => { if (snapStates.reloadStatusPage <= 0) return; // Delete the cache for the context (async () => { try { const { instanceURL } = getCurrentAccount(); const contextURL = `https://${instanceURL}/api/v1/statuses/${id}/context`; console.log('Clear cache', contextURL); const apiCache = await caches.open('api'); await apiCache.delete(contextURL, { ignoreVary: true }); return initContext({ reloadHero: true, }); } catch (e) { console.error(e); } })(); }, [snapStates.reloadStatusPage]); useEffect(() => { return () => { // RESET states.scrollPositions = {}; states.reloadStatusPage = 0; cachedStatusesMap = {}; cachedRepliesToggle = {}; }; }, []); const heroStatus = snapStates.statuses[sKey] || snapStates.statuses[id]; const heroDisplayName = useMemo(() => { // Remove shortcodes from display name if (!heroStatus) return ''; const { account } = heroStatus; const div = document.createElement('div'); div.innerHTML = account.displayName; return div.innerText.trim(); }, [heroStatus]); const heroContentText = useMemo(() => { if (!heroStatus) return ''; let text = statusPeek(heroStatus); if (text.length > 64) { // "The title should ideally be less than 64 characters in length" // https://www.w3.org/Provider/Style/TITLE.html text = text.slice(0, 64) + '…'; } return text; }, [heroStatus]); useTitle( heroDisplayName && heroContentText ? `${heroDisplayName}: "${heroContentText}"` : 'Status', '/:instance?/s/:id', ); const postInstance = useMemo(() => { if (!heroStatus) return; const { url } = heroStatus; if (!url) return; return new URL(url).hostname; }, [heroStatus]); const postSameInstance = useMemo(() => { if (!postInstance) return; return postInstance === instance; }, [postInstance, instance]); const [limit, setLimit] = useState(LIMIT); const showMore = useMemo(() => { // return number of statuses to show return statuses.length - limit; }, [statuses.length, limit]); const hasManyStatuses = statuses.length > THREAD_LIMIT; const hasDescendants = statuses.some((s) => s.descendant); const ancestors = statuses.filter((s) => s.ancestor); const [heroInView, setHeroInView] = useState(true); const onView = useDebouncedCallback(setHeroInView, 100); const heroPointer = useMemo(() => { // get top offset of heroStatus if (!heroStatusRef.current || heroInView) return null; const { top } = heroStatusRef.current.getBoundingClientRect(); return top > 0 ? 'down' : 'up'; }, [heroInView]); useHotkeys( 'esc', () => { location.hash = closeLink; }, { // If media is open, esc to close media first // Else close the status page enabled: !showMedia, }, ); // For backspace, will always close both media and status page useHotkeys('backspace', () => { location.hash = closeLink; }); useHotkeys('j', () => { const activeStatus = document.activeElement.closest( '.status-link, .status-focus', ); const activeStatusRect = activeStatus?.getBoundingClientRect(); const allStatusLinks = Array.from( // Select all statuses except those inside collapsed details/summary // Hat-tip to @AmeliaBR@front-end.social // https://front-end.social/@AmeliaBR/109784776146144471 scrollableRef.current.querySelectorAll( '.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)', ), ); console.log({ allStatusLinks }); if ( activeStatus && activeStatusRect.top < scrollableRef.current.clientHeight && activeStatusRect.bottom > 0 ) { const activeStatusIndex = allStatusLinks.indexOf(activeStatus); let nextStatus = allStatusLinks[activeStatusIndex + 1]; if (nextStatus) { nextStatus.focus(); nextStatus.scrollIntoViewIfNeeded?.(); } } else { // If active status is not in viewport, get the topmost status-link in viewport const topmostStatusLink = allStatusLinks.find((statusLink) => { const statusLinkRect = statusLink.getBoundingClientRect(); return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real }); if (topmostStatusLink) { topmostStatusLink.focus(); topmostStatusLink.scrollIntoViewIfNeeded?.(); } } }); useHotkeys('k', () => { const activeStatus = document.activeElement.closest( '.status-link, .status-focus', ); const activeStatusRect = activeStatus?.getBoundingClientRect(); const allStatusLinks = Array.from( scrollableRef.current.querySelectorAll( '.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)', ), ); if ( activeStatus && activeStatusRect.top < scrollableRef.current.clientHeight && activeStatusRect.bottom > 0 ) { const activeStatusIndex = allStatusLinks.indexOf(activeStatus); let prevStatus = allStatusLinks[activeStatusIndex - 1]; if (prevStatus) { prevStatus.focus(); prevStatus.scrollIntoViewIfNeeded?.(); } } else { // If active status is not in viewport, get the topmost status-link in viewport const topmostStatusLink = allStatusLinks.find((statusLink) => { const statusLinkRect = statusLink.getBoundingClientRect(); return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real }); if (topmostStatusLink) { topmostStatusLink.focus(); topmostStatusLink.scrollIntoViewIfNeeded?.(); } } }); // NOTE: I'm not sure if 'x' is the best shortcut for this, might change it later // IDEA: x is for expand useHotkeys('x', () => { const activeStatus = document.activeElement.closest( '.status-link, .status-focus', ); if (activeStatus) { const details = activeStatus.nextElementSibling; if (details && details.tagName.toLowerCase() === 'details') { details.open = !details.open; } } }); const { nearReachStart } = useScroll({ scrollableRef, distanceFromStartPx: 16, }); const initialPageState = useRef(showMedia ? 'media+status' : 'status'); return ( <div tabIndex="-1" ref={scrollableRef} class={`status-deck deck contained ${ statuses.length > 1 ? 'padded-bottom' : '' } ${initialPageState.current === 'status' ? 'slide-in' : ''}`} > <header class={`${heroInView ? 'inview' : ''} ${ uiState === 'loading' ? 'loading' : '' }`} onDblClick={(e) => { // reload statuses states.reloadStatusPage++; }} > {/* <div> <Link class="button plain deck-close" href={closeLink}> <Icon icon="chevron-left" size="xl" /> </Link> </div> */} <div class="header-grid header-grid-2"> <h1> {!heroInView && heroStatus && uiState !== 'loading' ? ( <> <span class="hero-heading"> <NameText account={heroStatus.account} instance={instance} showAvatar short />{' '} <span class="insignificant"> •{' '} <RelativeTime datetime={heroStatus.createdAt} format="micro" /> </span> </span>{' '} <button type="button" class="ancestors-indicator light small" onClick={(e) => { e.preventDefault(); e.stopPropagation(); heroStatusRef.current.scrollIntoView({ behavior: 'smooth', block: 'start', }); }} > <Icon icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'} /> </button> </> ) : ( <> Status{' '} <button type="button" class="ancestors-indicator light small" onClick={(e) => { // Scroll to top e.preventDefault(); e.stopPropagation(); scrollableRef.current.scrollTo({ top: 0, behavior: 'smooth', }); }} hidden={!ancestors.length || nearReachStart} > <Icon icon="arrow-up" /> <Icon icon="comment" />{' '} <span class="insignificant"> {shortenNumber(ancestors.length)} </span> </button> </> )} </h1> <div class="header-side"> <Menu align="end" portal={{ // Need this, else the menu click will cause scroll jump target: scrollableRef.current, }} menuButton={ <button type="button" class="button plain4"> <Icon icon="more" alt="Actions" size="xl" /> </button> } > <MenuItem disabled={uiState === 'loading'} onClick={() => { states.reloadStatusPage++; }} > <Icon icon="refresh" /> <span>Refresh</span> </MenuItem> <MenuItem onClick={() => { // Click all buttons with class .spoiler but not .spoiling const buttons = Array.from( scrollableRef.current.querySelectorAll( 'button.spoiler:not(.spoiling)', ), ); buttons.forEach((button) => { button.click(); }); }} > <Icon icon="eye-open" /> <span>Show all sensitive content</span> </MenuItem> <MenuDivider /> <MenuHeader className="plain">Experimental</MenuHeader> <MenuItem disabled={postSameInstance} onClick={() => { const statusURL = getInstanceStatusURL(heroStatus.url); if (statusURL) { location.hash = statusURL; } else { alert('Unable to switch'); } }} > <Icon icon="transfer" /> <small class="menu-double-lines"> Switch to post's instance (<b>{postInstance}</b>) </small> </MenuItem> </Menu> <Link class="button plain deck-close" to={closeLink}> <Icon icon="x" size="xl" /> </Link> </div> </div> </header> {!!statuses.length && heroStatus ? ( <ul class={`timeline flat contextual grow ${ uiState === 'loading' ? 'loading' : '' }`} > {statuses.slice(0, limit).map((status) => { const { id: statusID, ancestor, isThread, descendant, thread, replies, repliesCount, } = status; const isHero = statusID === id; return ( <li key={statusID} ref={isHero ? heroStatusRef : null} class={`${ancestor ? 'ancestor' : ''} ${ descendant ? 'descendant' : '' } ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`} > {isHero ? ( <> <InView threshold={0.1} onChange={onView} class="status-focus" tabIndex={0} > <Status statusID={statusID} instance={instance} withinContext size="l" enableTranslate /> </InView> {uiState !== 'loading' && !authenticated ? ( <div class="post-status-banner"> <p> You're not logged in. Interactions (reply, boost, etc) are not possible. </p> <Link to="/login" class="button"> Log in </Link> </div> ) : ( !sameInstance && ( <div class="post-status-banner"> <p> This post is from another instance ( <b>{instance}</b>). Interactions (reply, boost, etc) are not possible. </p> <button type="button" disabled={uiState === 'loading'} onClick={() => { setUIState('loading'); (async () => { try { const results = await currentMasto.v2.search({ q: heroStatus.url, type: 'statuses', resolve: true, limit: 1, }); if (results.statuses.length) { const status = results.statuses[0]; location.hash = currentInstance ? `/${currentInstance}/s/${status.id}` : `/s/${status.id}`; } else { throw new Error('No results'); } } catch (e) { setUIState('default'); alert('Error: ' + e); console.error(e); } })(); }} > <Icon icon="transfer" /> Switch to my instance to enable interactions </button> </div> ) )} </> ) : ( <Link class="status-link" to={ instance ? `/${instance}/s/${statusID}` : `/s/${statusID}` } onClick={() => { resetScrollPosition(statusID); }} > <Status statusID={statusID} instance={instance} withinContext size={thread || ancestor ? 'm' : 's'} enableTranslate onMediaClick={(e, i) => { e.preventDefault(); e.stopPropagation(); setSearchParams({ media: i + 1, mediaStatusID: statusID, }); }} /> {ancestor && isThread && repliesCount > 1 && ( <div class="replies-link"> <Icon icon="comment" />{' '} <span title={repliesCount}> {shortenNumber(repliesCount)} </span> </div> )}{' '} {/* {replies?.length > LIMIT && ( <div class="replies-link"> <Icon icon="comment" />{' '} <span title={replies.length}> {shortenNumber(replies.length)} </span> </div> )} */} </Link> )} {descendant && replies?.length > 0 && ( <SubComments instance={instance} hasManyStatuses={hasManyStatuses} replies={replies} hasParentThread={thread} level={1} /> )} {uiState === 'loading' && isHero && !!heroStatus?.repliesCount && !hasDescendants && ( <div class="status-loading"> <Loader /> </div> )} {uiState === 'error' && isHero && !!heroStatus?.repliesCount && !hasDescendants && ( <div class="status-error"> Unable to load replies. <br /> <button type="button" class="plain" onClick={() => { states.reloadStatusPage++; }} > Try again </button> </div> )} </li> ); })} {showMore > 0 && ( <li> <button type="button" class="plain block" disabled={uiState === 'loading'} onClick={() => setLimit((l) => l + LIMIT)} style={{ marginBlockEnd: '6em' }} > Show more…{' '} <span class="tag"> {showMore > LIMIT ? `${LIMIT}+` : showMore} </span> </button> </li> )} </ul> ) : ( <> {uiState === 'loading' && ( <ul class="timeline flat contextual grow loading"> <li> <Status skeleton size="l" /> </li> </ul> )} {uiState === 'error' && ( <p class="ui-state"> Unable to load status <br /> <br /> <button type="button" onClick={() => { states.reloadStatusPage++; }} > Try again </button> </p> )} </> )} </div> ); } function SubComments({ hasManyStatuses, replies, instance, hasParentThread, level, }) { const [searchParams, setSearchParams] = useSearchParams(); // Set isBrief = true: // - if less than or 2 replies // - if replies have no sub-replies // - if total number of characters of content from replies is less than 500 let isBrief = false; if (replies.length <= 2) { const containsSubReplies = replies.some( (r) => r.repliesCount > 0 || r.replies?.length > 0, ); if (!containsSubReplies) { let totalLength = replies.reduce((acc, reply) => { const { content } = reply; const length = htmlContentLength(content); return acc + length; }, 0); isBrief = totalLength < 500; } } // Total comments count, including sub-replies const diveDeep = (replies) => { return replies.reduce((acc, reply) => { const { repliesCount, replies } = reply; const count = replies?.length || repliesCount; return acc + count + diveDeep(replies || []); }, 0); }; const totalComments = replies.length + diveDeep(replies); const sameCount = replies.length === totalComments; // Get the first 3 accounts, unique by id const accounts = replies .map((r) => r.account) .filter((a, i, arr) => arr.findIndex((b) => b.id === a.id) === i) .slice(0, 3); const open = (!hasParentThread || replies.length === 1) && (isBrief || !hasManyStatuses); const openBefore = cachedRepliesToggle[replies[0].id]; return ( <details class="replies" open={openBefore || open} onToggle={(e) => { const { open } = e.target; // use first reply as ID cachedRepliesToggle[replies[0].id] = open; }} style={{ '--comments-level': level, }} data-comments-level={level} data-comments-level-overflow={level > 4} > <summary class="replies-summary" hidden={open}> <span class="avatars"> {accounts.map((a) => ( <Avatar key={a.id} url={a.avatarStatic} title={`${a.displayName} @${a.username}`} squircle={a?.bot} /> ))} </span> <span> <span title={replies.length}>{shortenNumber(replies.length)}</span>{' '} repl {replies.length === 1 ? 'y' : 'ies'} </span> {!sameCount && totalComments > 1 && ( <> {' '} ·{' '} <span> <span title={totalComments}>{shortenNumber(totalComments)}</span>{' '} comment {totalComments === 1 ? '' : 's'} </span> </> )} </summary> <ul> {replies.map((r) => ( <li key={r.id}> <Link class="status-link" to={instance ? `/${instance}/s/${r.id}` : `/s/${r.id}`} onClick={() => { resetScrollPosition(r.id); }} > <Status statusID={r.id} instance={instance} withinContext size="s" enableTranslate onMediaClick={(e, i) => { e.preventDefault(); e.stopPropagation(); setSearchParams({ media: i + 1, mediaStatusID: r.id, }); }} /> {!r.replies?.length && r.repliesCount > 0 && ( <div class="replies-link"> <Icon icon="comment" />{' '} <span title={r.repliesCount}> {shortenNumber(r.repliesCount)} </span> </div> )} </Link> {r.replies?.length && ( <SubComments instance={instance} hasManyStatuses={hasManyStatuses} replies={r.replies} level={level + 1} /> )} </li> ))} </ul> </details> ); } const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/([^\/]+)\/?$/i; const statusNoteRegex = /\/notes\/([^\/]+)\/?$/i; function getInstanceStatusURL(url) { // Regex /:username/:id, where username = @username or @username@domain, id = anything const { hostname, pathname } = new URL(url); const [, username, domain, id] = pathname.match(statusRegex) || []; if (id) { return `/${hostname}/s/${id}`; } const [, noteId] = pathname.match(statusNoteRegex) || []; if (noteId) { return `/${hostname}/s/${noteId}`; } } export default StatusPage;