Rewrite whole scroll logic for Status page
Handle 3 cases, all written down in comments. Crossing my fingers 🤞🤞🤞
This commit is contained in:
parent
237ceae356
commit
3b6f0f277e
5 changed files with 166 additions and 94 deletions
11
package-lock.json
generated
11
package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Add table
Reference in a new issue