Restore scroll position when returning to the timeline

This commit is contained in:
hzrd149
2023-12-15 16:15:57 -06:00
parent be49839869
commit 3bae870ebc
3 changed files with 89 additions and 13 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Restore scroll position when returning to the timeline

View File

@@ -1,7 +1,8 @@
import { ReactNode, memo, useEffect, useState } from "react"; import { ReactNode, memo, useCallback, useEffect, useRef, useState } from "react";
import { Box, Button, Text } from "@chakra-ui/react"; import { Box, Button, Text } from "@chakra-ui/react";
import { Kind } from "nostr-tools"; import { Kind } from "nostr-tools";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useLocation } from "react-router-dom";
import useSubject from "../../../hooks/use-subject"; import useSubject from "../../../hooks/use-subject";
import TimelineLoader from "../../../classes/timeline-loader"; import TimelineLoader from "../../../classes/timeline-loader";
@@ -14,11 +15,18 @@ import { ErrorBoundary } from "../../error-boundary";
import { getEventUID, isReply } from "../../../helpers/nostr/events"; import { getEventUID, isReply } from "../../../helpers/nostr/events";
import ReplyNote from "./reply-note"; import ReplyNote from "./reply-note";
import RelayRecommendation from "./relay-recommendation"; import RelayRecommendation from "./relay-recommendation";
import { ExtendedIntersectionObserverEntry, useIntersectionObserver } from "../../../providers/intersection-observer"; import {
ExtendedIntersectionObserverEntry,
useIntersectionObserver,
useRegisterIntersectionEntity,
} from "../../../providers/intersection-observer";
import BadgeAwardCard from "../../../views/badges/components/badge-award-card"; import BadgeAwardCard from "../../../views/badges/components/badge-award-card";
import ArticleNote from "./article-note"; import ArticleNote from "./article-note";
function RenderEvent({ event }: { event: NostrEvent }) { function RenderEvent({ event, visible, minHeight }: { event: NostrEvent; visible: boolean; minHeight?: number }) {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(event));
let content: ReactNode | null = null; let content: ReactNode | null = null;
switch (event.kind) { switch (event.kind) {
case Kind.Text: case Kind.Text:
@@ -44,23 +52,56 @@ function RenderEvent({ event }: { event: NostrEvent }) {
break; break;
} }
return content && <ErrorBoundary>{content}</ErrorBoundary>; return (
<ErrorBoundary>
<Box minHeight={minHeight} ref={ref}>
{visible && content}
</Box>
</ErrorBoundary>
);
} }
const RenderEventMemo = memo(RenderEvent); const RenderEventMemo = memo(RenderEvent);
const PRELOAD_NOTES = 5; const NOTE_BUFFER = 5;
const timelineNoteMinHeightCache = new WeakMap<TimelineLoader, Record<string, Record<string, number>>>();
function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) { function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) {
const notesArray = useSubject(timeline.timeline); const notesArray = useSubject(timeline.timeline);
const [latest, setLatest] = useState(() => dayjs().unix()); const [latest, setLatest] = useState(() => dayjs().unix());
const { subject } = useIntersectionObserver(); const { subject } = useIntersectionObserver();
const [minDate, setMinDate] = useState(timeline.timeline.value[PRELOAD_NOTES]?.created_at ?? 0); const location = useLocation();
const setCachedNumber = useCallback(
(id: string, value: number) => {
let timelineData = timelineNoteMinHeightCache.get(timeline);
if (!timelineData) {
timelineData = {};
timelineNoteMinHeightCache.set(timeline, timelineData);
}
if (!timelineData[location.key]) timelineData[location.key] = {};
timelineData[location.key][id] = value;
},
[location.key, timeline],
);
const getCachedNumber = useCallback(
(id: string) => {
const timelineData = timelineNoteMinHeightCache.get(timeline);
if (!timelineData) return undefined;
return timelineData[location.key]?.[id] ?? undefined;
},
[location.key, timeline],
);
const [maxDate, setMaxDate] = useState(getCachedNumber("max") ?? -Infinity);
const [minDate, setMinDate] = useState(
getCachedNumber("min") ?? timeline.timeline.value[NOTE_BUFFER]?.created_at ?? 0,
);
// reset the latest and minDate when timeline changes // reset the latest and minDate when timeline changes
useEffect(() => { useEffect(() => {
setLatest(dayjs().unix()); setLatest(dayjs().unix());
setMinDate(timeline.timeline.value[PRELOAD_NOTES]?.created_at ?? 0); setMaxDate(getCachedNumber("max") ?? -Infinity);
}, [timeline, setMinDate, setLatest]); setMinDate(getCachedNumber("min") ?? timeline.timeline.value[NOTE_BUFFER]?.created_at ?? 0);
}, [timeline, setMinDate, setLatest, getCachedNumber]);
const newNotes: NostrEvent[] = []; const newNotes: NostrEvent[] = [];
const notes: NostrEvent[] = []; const notes: NostrEvent[] = [];
@@ -69,13 +110,29 @@ function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) {
else if (note.created_at > minDate) notes.push(note); else if (note.created_at > minDate) notes.push(note);
} }
const updateNoteMinHeight = useCallback(
(id: string, element: Element) => {
const rect = element.getBoundingClientRect();
const current = getCachedNumber(id);
setCachedNumber(id, Math.max(current ?? 0, rect.height));
},
[setCachedNumber, getCachedNumber],
);
// TODO: break this out into its own component or hook, this is pretty ugly
const [intersectionEntryCache] = useState(() => new Map<string, IntersectionObserverEntry>()); const [intersectionEntryCache] = useState(() => new Map<string, IntersectionObserverEntry>());
useEffect(() => { useEffect(() => {
const listener = (entities: ExtendedIntersectionObserverEntry[]) => { const listener = (entities: ExtendedIntersectionObserverEntry[]) => {
for (const entity of entities) entity.id && intersectionEntryCache.set(entity.id, entity.entry); for (const entity of entities) {
if (entity.id) {
intersectionEntryCache.set(entity.id, entity.entry);
updateNoteMinHeight(entity.id, entity.entry.target);
}
}
let min: number = Infinity; let min: number = Infinity;
let preload = PRELOAD_NOTES; let max: number = -Infinity;
let preload = NOTE_BUFFER;
let foundVisible = false; let foundVisible = false;
for (const event of timeline.timeline.value) { for (const event of timeline.timeline.value) {
if (event.created_at > latest) continue; if (event.created_at > latest) continue;
@@ -92,17 +149,25 @@ function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) {
} else { } else {
// found visible event // found visible event
foundVisible = true; foundVisible = true;
const bufferEvent =
timeline.timeline.value[Math.max(timeline.timeline.value.indexOf(event) - NOTE_BUFFER)] || event;
if (bufferEvent.created_at > max) max = bufferEvent.created_at;
} }
} }
setMinDate((v) => Math.min(v, min)); setMinDate((v) => Math.min(v, min));
setMaxDate(max);
setCachedNumber("max", max);
setCachedNumber("min", Math.min(getCachedNumber("min") ?? Infinity, min));
}; };
subject.subscribe(listener); subject.subscribe(listener);
return () => { return () => {
subject.unsubscribe(listener); subject.unsubscribe(listener);
}; };
}, [setMinDate, intersectionEntryCache, latest, timeline]); }, [setMinDate, intersectionEntryCache, updateNoteMinHeight, setCachedNumber, getCachedNumber, latest, timeline]);
return ( return (
<> <>
@@ -120,7 +185,12 @@ function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) {
</Box> </Box>
)} )}
{notes.map((note) => ( {notes.map((note) => (
<RenderEventMemo key={note.id} event={note} /> <RenderEventMemo
key={note.id}
event={note}
visible={note.created_at <= maxDate}
minHeight={getCachedNumber(getEventUID(note))}
/>
))} ))}
</> </>
); );

View File

@@ -1,7 +1,8 @@
export const getMatchNostrLink = () => export const getMatchNostrLink = () =>
/(nostr:|@)?((npub|note|nprofile|nevent|nrelay|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi; /(nostr:|@)?((npub|note|nprofile|nevent|nrelay|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi;
export const getMatchHashtag = () => /(^|[^\p{L}])#([\p{L}\p{N}\p{M}]+)/gu; export const getMatchHashtag = () => /(^|[^\p{L}])#([\p{L}\p{N}\p{M}]+)/gu;
export const getMatchLink = () => /https?:\/\/([a-zA-Z0-9\.\-]+\.[a-zA-Z]+)([\p{L}\p{N}\p{M}&\.-\/\?=#\-@%\+_,:!~]*)/gu; export const getMatchLink = () =>
/https?:\/\/([a-zA-Z0-9\.\-]+\.[a-zA-Z]+)([\p{L}\p{N}\p{M}&\.-\/\?=#\-@%\+_,:!~*]*)/gu;
export const getMatchEmoji = () => /:([a-zA-Z0-9_-]+):/gi; export const getMatchEmoji = () => /:([a-zA-Z0-9_-]+):/gi;
export const getMatchCashu = () => /cashuA[A-z0-9]+/g; export const getMatchCashu = () => /cashuA[A-z0-9]+/g;