diff --git a/package-lock.json b/package-lock.json index 975b0ed9..63db590f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "fast-blurhash": "~1.1.2", "history": "~5.3.0", "iconify-icon": "~1.0.2", + "just-debounce-it": "^3.2.0", "masto": "~4.10.1", "mem": "~9.0.2", "preact": "~10.11.3", @@ -4046,6 +4047,11 @@ "node": ">=0.10.0" } }, + "node_modules/just-debounce-it": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/just-debounce-it/-/just-debounce-it-3.2.0.tgz", + "integrity": "sha512-WXzwLL0745uNuedrCsCs3rpmfD6DBaf7uuVwaq98/8dafURfgQaBsSpjiPp5+CW6Vjltwy9cOGI6qE71b3T8iQ==" + }, "node_modules/kolorist": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.6.0.tgz", @@ -8562,6 +8568,11 @@ "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", "dev": true }, + "just-debounce-it": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/just-debounce-it/-/just-debounce-it-3.2.0.tgz", + "integrity": "sha512-WXzwLL0745uNuedrCsCs3rpmfD6DBaf7uuVwaq98/8dafURfgQaBsSpjiPp5+CW6Vjltwy9cOGI6qE71b3T8iQ==" + }, "kolorist": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.6.0.tgz", diff --git a/package.json b/package.json index f0d1bb19..191757ff 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "fast-blurhash": "~1.1.2", "history": "~5.3.0", "iconify-icon": "~1.0.2", + "just-debounce-it": "^3.2.0", "masto": "~4.10.1", "mem": "~9.0.2", "preact": "~10.11.3", diff --git a/src/app.css b/src/app.css index 4e7cd710..bb889de9 100644 --- a/src/app.css +++ b/src/app.css @@ -137,6 +137,7 @@ a.mention span { transparent ); background-repeat: no-repeat; + transition: opacity 0.3s ease-in-out; } .timeline.contextual > li:first-child { background-position: 0 16px; @@ -274,13 +275,13 @@ a.mention span { left: calc(50px + 16px + 16px); } .timeline.contextual.loading > li:not(.hero) { - opacity: 0.2; + opacity: 0.5; pointer-events: none; - background-image: none !important; + /* background-image: none !important; */ } -.timeline.contextual.loading > li:not(.hero):before { +/* .timeline.contextual.loading > li:not(.hero):before { content: none !important; -} +} */ .timeline-deck.compact .status { max-height: max(25vh, 160px); diff --git a/src/pages/status.jsx b/src/pages/status.jsx index 71026c19..7d6f4dd8 100644 --- a/src/pages/status.jsx +++ b/src/pages/status.jsx @@ -1,3 +1,4 @@ +import debounce from 'just-debounce-it'; import { Link } from 'preact-router/match'; import { useEffect, @@ -18,117 +19,163 @@ import useTitle from '../utils/useTitle'; function StatusPage({ id }) { const snapStates = useSnapshot(states); - const cachedStatuses = store.session.getJSON('statuses-' + id); - const [statuses, setStatuses] = useState(cachedStatuses || [{ id }]); + const [statuses, setStatuses] = useState([]); const [uiState, setUIState] = useState('default'); + const userInitiated = useRef(true); // Initial open is user-initiated const heroStatusRef = useRef(); + const scrollableRef = useRef(); useEffect(() => { + const onScroll = debounce(() => { + // console.log('onScroll'); + const { scrollTop } = scrollableRef.current; + states.scrollPositions.set(id, scrollTop); + }, 100); + scrollableRef.current.addEventListener('scroll', onScroll, { + passive: true, + }); + onScroll(); + return () => { + scrollableRef.current?.removeEventListener('scroll', onScroll); + }; + }, [id]); + + useEffect(() => { + setUIState('loading'); + const containsStatus = statuses.find((s) => s.id === id); if (!containsStatus) { + // Case 1: On first load, or when navigating to a status that's not cached at all setStatuses([{ id }]); } else { const cachedStatuses = store.session.getJSON('statuses-' + id); if (cachedStatuses) { - setStatuses(cachedStatuses); + // Case 2: Looks like we've cached this status before, let's restore them to make it snappy + const reallyCachedStatuses = cachedStatuses.filter( + (s) => snapStates.statuses.has(s.id), + // Some are not cached in the global state, so we need to filter them out + ); + setStatuses(reallyCachedStatuses); + } else { + // Case 3: Unknown state, could be a sub-comment. Let's slice off all descendant statuses after the hero status to be safe because they are custom-rendered with sub-comments etc + const heroIndex = statuses.findIndex((s) => s.id === id); + const slicedStatuses = statuses.slice(0, heroIndex + 1); + setStatuses(slicedStatuses); } } - }, [id]); - useEffect(async () => { - setUIState('loading'); - - const hasStatus = snapStates.statuses.has(id); - let heroStatus = snapStates.statuses.get(id); - try { - heroStatus = await masto.statuses.fetch(id); - states.statuses.set(id, heroStatus); - } catch (e) { - // Silent fail if status is cached - if (!hasStatus) { - setUIState('error'); - alert('Error fetching status'); - } - return; - } - - try { - const context = await masto.statuses.fetchContext(id); - const { ancestors, descendants } = context; - - ancestors.forEach((status) => { - states.statuses.set(status.id, status); - }); - const nestedDescendants = []; - descendants.forEach((status) => { - states.statuses.set(status.id, status); - 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, it's probably a reply to a reply to a reply, level 3 - console.warn('[LEVEL 3] No parent found for', status); - } + (async () => { + const hasStatus = snapStates.statuses.has(id); + let heroStatus = snapStates.statuses.get(id); + try { + heroStatus = await masto.statuses.fetch(id); + states.statuses.set(id, heroStatus); + } catch (e) { + // Silent fail if status is cached + if (!hasStatus) { + setUIState('error'); + alert('Error fetching status'); } - }); + return; + } - console.log({ ancestors, descendants, nestedDescendants }); + try { + const context = await masto.statuses.fetchContext(id); + const { ancestors, descendants } = context; - 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) => r.id), - })), - ]; - console.log({ allStatuses }); - setStatuses(allStatuses); - store.session.setJSON('statuses-' + id, allStatuses); - } catch (e) { - console.error(e); - setUIState('error'); - } + ancestors.forEach((status) => { + states.statuses.set(status.id, status); + }); + const nestedDescendants = []; + descendants.forEach((status) => { + states.statuses.set(status.id, status); + 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, it's probably a reply to a reply to a reply, level 3 + console.warn('[LEVEL 3] No parent found for', status); + } + } + }); - setUIState('default'); + 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) => r.id), + })), + ]; + + setUIState('default'); + console.log({ allStatuses }); + setStatuses(allStatuses); + store.session.setJSON('statuses-' + id, allStatuses); + } catch (e) { + console.error(e); + setUIState('error'); + } + })(); }, [id, snapStates.reloadStatusPage]); useLayoutEffect(() => { - if (heroStatusRef.current && statuses.length > 1) { - heroStatusRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); + if (!statuses.length) return; + const isLoading = uiState === 'loading'; + if (userInitiated.current) { + const hasAncestors = statuses.findIndex((s) => s.id === id) > 0; // Cannot use `ancestor` key because the hero state is dynamic + if (!isLoading && hasAncestors) { + // Case 1: User initiated, has ancestors, after statuses are loaded, SNAP to hero status + console.log('Case 1'); + heroStatusRef.current?.scrollIntoView(); + } else if (isLoading && statuses.length > 1) { + // Case 2: User initiated, while statuses are loading, SMOOTH-SCROLL to hero status + console.log('Case 2'); + heroStatusRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } + } else { + const scrollPosition = states.scrollPositions.get(id); + if (scrollPosition && scrollableRef.current) { + // Case 3: Not user initiated (e.g. back/forward button), restore to saved scroll position + console.log('Case 3'); + scrollableRef.current.scrollTop = scrollPosition; + } } - }, [id]); + console.log('No case', { + isLoading, + userInitiated: userInitiated.current, + statusesLength: statuses.length, + // scrollPosition, + }); - useLayoutEffect(() => { - const hasAncestor = statuses.some((s) => s.ancestor); - if (hasAncestor) { - heroStatusRef.current?.scrollIntoView({ - // behavior: 'smooth', - block: 'start', - }); + if (!isLoading) { + // Reset user initiated flag after statuses are loaded + userInitiated.current = false; } - }, [statuses]); + }, [statuses, uiState]); const heroStatus = snapStates.statuses.get(id); const heroDisplayName = useMemo(() => { @@ -175,11 +222,13 @@ function StatusPage({ id }) { }, [statuses.length, limit]); const hasManyStatuses = statuses.length > 40; + const hasDescendants = statuses.some((s) => s.descendant); return (
1 ? 'padded-bottom' : '' }`} @@ -228,6 +277,9 @@ function StatusPage({ id }) { status-link " href={`#/s/${statusID}`} + onClick={() => { + userInitiated.current = true; + }} > {replies.map((replyID) => (
  • - + { + userInitiated.current = true; + }} + >
  • @@ -258,7 +316,7 @@ function StatusPage({ id }) { {uiState === 'loading' && isHero && !!heroStatus?.repliesCount && - statuses.length === 1 && ( + !hasDescendants && (
    diff --git a/src/utils/states.js b/src/utils/states.js index 613e52ba..cc3f6b3a 100644 --- a/src/utils/states.js +++ b/src/utils/states.js @@ -13,6 +13,7 @@ export default proxy({ accounts: new Map(), reloadStatusPage: 0, spoilers: proxyMap([]), + scrollPositions: new Map(), // Modals showCompose: false, showSettings: false,