Rewrite whole scroll logic for Status page

Handle 3 cases, all written down in comments.

Crossing my fingers 🤞🤞🤞
This commit is contained in:
Lim Chee Aun 2022-12-21 18:02:13 +08:00
parent 237ceae356
commit 3b6f0f277e
5 changed files with 166 additions and 94 deletions

11
package-lock.json generated
View file

@ -13,6 +13,7 @@
"fast-blurhash": "~1.1.2", "fast-blurhash": "~1.1.2",
"history": "~5.3.0", "history": "~5.3.0",
"iconify-icon": "~1.0.2", "iconify-icon": "~1.0.2",
"just-debounce-it": "^3.2.0",
"masto": "~4.10.1", "masto": "~4.10.1",
"mem": "~9.0.2", "mem": "~9.0.2",
"preact": "~10.11.3", "preact": "~10.11.3",
@ -4046,6 +4047,11 @@
"node": ">=0.10.0" "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": { "node_modules/kolorist": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.6.0.tgz", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.6.0.tgz",
@ -8562,6 +8568,11 @@
"integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
"dev": true "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": { "kolorist": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.6.0.tgz", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.6.0.tgz",

View file

@ -15,6 +15,7 @@
"fast-blurhash": "~1.1.2", "fast-blurhash": "~1.1.2",
"history": "~5.3.0", "history": "~5.3.0",
"iconify-icon": "~1.0.2", "iconify-icon": "~1.0.2",
"just-debounce-it": "^3.2.0",
"masto": "~4.10.1", "masto": "~4.10.1",
"mem": "~9.0.2", "mem": "~9.0.2",
"preact": "~10.11.3", "preact": "~10.11.3",

View file

@ -137,6 +137,7 @@ a.mention span {
transparent transparent
); );
background-repeat: no-repeat; background-repeat: no-repeat;
transition: opacity 0.3s ease-in-out;
} }
.timeline.contextual > li:first-child { .timeline.contextual > li:first-child {
background-position: 0 16px; background-position: 0 16px;
@ -274,13 +275,13 @@ a.mention span {
left: calc(50px + 16px + 16px); left: calc(50px + 16px + 16px);
} }
.timeline.contextual.loading > li:not(.hero) { .timeline.contextual.loading > li:not(.hero) {
opacity: 0.2; opacity: 0.5;
pointer-events: none; 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; content: none !important;
} } */
.timeline-deck.compact .status { .timeline-deck.compact .status {
max-height: max(25vh, 160px); max-height: max(25vh, 160px);

View file

@ -1,3 +1,4 @@
import debounce from 'just-debounce-it';
import { Link } from 'preact-router/match'; import { Link } from 'preact-router/match';
import { import {
useEffect, useEffect,
@ -18,117 +19,163 @@ import useTitle from '../utils/useTitle';
function StatusPage({ id }) { function StatusPage({ id }) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const cachedStatuses = store.session.getJSON('statuses-' + id); const [statuses, setStatuses] = useState([]);
const [statuses, setStatuses] = useState(cachedStatuses || [{ id }]);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const userInitiated = useRef(true); // Initial open is user-initiated
const heroStatusRef = useRef(); const heroStatusRef = useRef();
const scrollableRef = useRef();
useEffect(() => { 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); const containsStatus = statuses.find((s) => s.id === id);
if (!containsStatus) { if (!containsStatus) {
// Case 1: On first load, or when navigating to a status that's not cached at all
setStatuses([{ id }]); setStatuses([{ id }]);
} else { } else {
const cachedStatuses = store.session.getJSON('statuses-' + id); const cachedStatuses = store.session.getJSON('statuses-' + id);
if (cachedStatuses) { 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 () => { (async () => {
setUIState('loading'); const hasStatus = snapStates.statuses.has(id);
let heroStatus = snapStates.statuses.get(id);
const hasStatus = snapStates.statuses.has(id); try {
let heroStatus = snapStates.statuses.get(id); heroStatus = await masto.statuses.fetch(id);
try { states.statuses.set(id, heroStatus);
heroStatus = await masto.statuses.fetch(id); } catch (e) {
states.statuses.set(id, heroStatus); // Silent fail if status is cached
} catch (e) { if (!hasStatus) {
// Silent fail if status is cached setUIState('error');
if (!hasStatus) { alert('Error fetching status');
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);
}
} }
}); return;
}
console.log({ ancestors, descendants, nestedDescendants }); try {
const context = await masto.statuses.fetchContext(id);
const { ancestors, descendants } = context;
const allStatuses = [ ancestors.forEach((status) => {
...ancestors.map((s) => ({ states.statuses.set(status.id, status);
id: s.id, });
ancestor: true, const nestedDescendants = [];
accountID: s.account.id, descendants.forEach((status) => {
})), states.statuses.set(status.id, status);
{ id, accountID: heroStatus.account.id }, if (status.inReplyToAccountId === status.account.id) {
...nestedDescendants.map((s) => ({ // If replying to self, it's part of the thread, level 1
id: s.id, nestedDescendants.push(status);
accountID: s.account.id, } else if (status.inReplyToId === heroStatus.id) {
descendant: true, // If replying to the hero status, it's a reply, level 1
thread: s.account.id === heroStatus.account.id, nestedDescendants.push(status);
replies: s.__replies?.map((r) => r.id), } else {
})), // If replying to someone else, it's a reply to a reply, level 2
]; const parent = descendants.find((s) => s.id === status.inReplyToId);
console.log({ allStatuses }); if (parent) {
setStatuses(allStatuses); if (!parent.__replies) {
store.session.setJSON('statuses-' + id, allStatuses); parent.__replies = [];
} catch (e) { }
console.error(e); parent.__replies.push(status);
setUIState('error'); } 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]); }, [id, snapStates.reloadStatusPage]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (heroStatusRef.current && statuses.length > 1) { if (!statuses.length) return;
heroStatusRef.current.scrollIntoView({ const isLoading = uiState === 'loading';
behavior: 'smooth', if (userInitiated.current) {
block: 'start', 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(() => { if (!isLoading) {
const hasAncestor = statuses.some((s) => s.ancestor); // Reset user initiated flag after statuses are loaded
if (hasAncestor) { userInitiated.current = false;
heroStatusRef.current?.scrollIntoView({
// behavior: 'smooth',
block: 'start',
});
} }
}, [statuses]); }, [statuses, uiState]);
const heroStatus = snapStates.statuses.get(id); const heroStatus = snapStates.statuses.get(id);
const heroDisplayName = useMemo(() => { const heroDisplayName = useMemo(() => {
@ -175,11 +222,13 @@ function StatusPage({ id }) {
}, [statuses.length, limit]); }, [statuses.length, limit]);
const hasManyStatuses = statuses.length > 40; const hasManyStatuses = statuses.length > 40;
const hasDescendants = statuses.some((s) => s.descendant);
return ( return (
<div class="deck-backdrop"> <div class="deck-backdrop">
<Link href={closeLink}></Link> <Link href={closeLink}></Link>
<div <div
ref={scrollableRef}
class={`status-deck deck contained ${ class={`status-deck deck contained ${
statuses.length > 1 ? 'padded-bottom' : '' statuses.length > 1 ? 'padded-bottom' : ''
}`} }`}
@ -228,6 +277,9 @@ function StatusPage({ id }) {
status-link status-link
" "
href={`#/s/${statusID}`} href={`#/s/${statusID}`}
onClick={() => {
userInitiated.current = true;
}}
> >
<Status <Status
statusID={statusID} statusID={statusID}
@ -247,7 +299,13 @@ function StatusPage({ id }) {
<ul> <ul>
{replies.map((replyID) => ( {replies.map((replyID) => (
<li key={replyID}> <li key={replyID}>
<Link class="status-link" href={`#/s/${replyID}`}> <Link
class="status-link"
href={`#/s/${replyID}`}
onClick={() => {
userInitiated.current = true;
}}
>
<Status statusID={replyID} withinContext size="s" /> <Status statusID={replyID} withinContext size="s" />
</Link> </Link>
</li> </li>
@ -258,7 +316,7 @@ function StatusPage({ id }) {
{uiState === 'loading' && {uiState === 'loading' &&
isHero && isHero &&
!!heroStatus?.repliesCount && !!heroStatus?.repliesCount &&
statuses.length === 1 && ( !hasDescendants && (
<div class="status-loading"> <div class="status-loading">
<Loader /> <Loader />
</div> </div>

View file

@ -13,6 +13,7 @@ export default proxy({
accounts: new Map(), accounts: new Map(),
reloadStatusPage: 0, reloadStatusPage: 0,
spoilers: proxyMap([]), spoilers: proxyMap([]),
scrollPositions: new Map(),
// Modals // Modals
showCompose: false, showCompose: false,
showSettings: false, showSettings: false,