import './status.css'; import { Menu, 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, useNavigate, useParams } 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 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, { saveStatus, statusKey, threadifyStatus, } from '../utils/states'; import { getCurrentAccount } from '../utils/store-utils'; import useScroll from '../utils/useScroll'; import useTitle from '../utils/useTitle'; const LIMIT = 40; const THREAD_LIMIT = 20; let cachedStatusesMap = {}; function resetScrollPosition(id) { delete cachedStatusesMap[id]; delete states.scrollPositions[id]; } function StatusPage() { const { id, ...params } = useParams(); const { masto, instance, authenticated } = api({ instance: params.instance }); const navigate = useNavigate(); 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; } }, 100); scrollableRef.current.addEventListener('scroll', onScroll, { passive: true, }); onScroll(); return () => { onScroll.flush(); scrollableRef.current?.removeEventListener('scroll', onScroll); }; }, [id, uiState !== 'loading']); const scrollOffsets = useRef(); const initContext = () => { 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) { 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 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 }); const allStatuses = [ ...ancestors.map((s) => ({ id: s.id, ancestor: true, accountID: s.account.id, })), { 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: s.__replies?.map((r) => ({ id: r.id, account: r.account, repliesCount: r.repliesCount, content: r.content, replies: r.__replies?.map((r2) => ({ // Level 3 id: r2.id, account: r2.account, repliesCount: r2.repliesCount, content: r2.content, replies: r2.__replies?.map((r3) => ({ // Level 4 id: r3.id, account: r3.account, repliesCount: r3.repliesCount, content: r3.content, })), })), })), })), ]; 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(); } catch (e) { console.error(e); } })(); }, [snapStates.reloadStatusPage]); useEffect(() => { return () => { // RESET states.scrollPositions = {}; states.reloadStatusPage = 0; cachedStatusesMap = {}; }; }, []); 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 ''; const { spoilerText, content } = heroStatus; let text; if (spoilerText) { text = spoilerText; } else { const div = document.createElement('div'); div.innerHTML = content; text = div.innerText.trim(); } 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 closeLink = useMemo(() => { const { prevLocation } = snapStates; const pathname = prevLocation?.pathname + (prevLocation?.search || ''); if ( !pathname || matchPath('/:instance/s/:id', pathname) || matchPath('/s/:id', pathname) ) { return '/'; } return pathname; }, []); const onClose = () => { states.showMediaModal = false; }; 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', 'backspace'], () => { // location.hash = closeLink; onClose(); navigate(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({ scrollableElement: scrollableRef.current, distanceFromStart: 0.2, }); return (
Unable to load status