From 3bae870ebc9eacec5321faf3de4a19b7df1e70d7 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Fri, 15 Dec 2023 16:15:57 -0600 Subject: [PATCH] Restore scroll position when returning to the timeline --- .changeset/cool-clouds-flash.md | 5 + .../generic-note-timeline/index.tsx | 94 ++++++++++++++++--- src/helpers/regexp.ts | 3 +- 3 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 .changeset/cool-clouds-flash.md diff --git a/.changeset/cool-clouds-flash.md b/.changeset/cool-clouds-flash.md new file mode 100644 index 000000000..80ff9eedb --- /dev/null +++ b/.changeset/cool-clouds-flash.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Restore scroll position when returning to the timeline diff --git a/src/components/timeline-page/generic-note-timeline/index.tsx b/src/components/timeline-page/generic-note-timeline/index.tsx index 50ef66390..73526cfca 100644 --- a/src/components/timeline-page/generic-note-timeline/index.tsx +++ b/src/components/timeline-page/generic-note-timeline/index.tsx @@ -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 { Kind } from "nostr-tools"; import dayjs from "dayjs"; +import { useLocation } from "react-router-dom"; import useSubject from "../../../hooks/use-subject"; import TimelineLoader from "../../../classes/timeline-loader"; @@ -14,11 +15,18 @@ import { ErrorBoundary } from "../../error-boundary"; import { getEventUID, isReply } from "../../../helpers/nostr/events"; import ReplyNote from "./reply-note"; 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 ArticleNote from "./article-note"; -function RenderEvent({ event }: { event: NostrEvent }) { +function RenderEvent({ event, visible, minHeight }: { event: NostrEvent; visible: boolean; minHeight?: number }) { + const ref = useRef(null); + useRegisterIntersectionEntity(ref, getEventUID(event)); + let content: ReactNode | null = null; switch (event.kind) { case Kind.Text: @@ -44,23 +52,56 @@ function RenderEvent({ event }: { event: NostrEvent }) { break; } - return content && {content}; + return ( + + + {visible && content} + + + ); } const RenderEventMemo = memo(RenderEvent); -const PRELOAD_NOTES = 5; +const NOTE_BUFFER = 5; +const timelineNoteMinHeightCache = new WeakMap>>(); + function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) { const notesArray = useSubject(timeline.timeline); const [latest, setLatest] = useState(() => dayjs().unix()); 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 useEffect(() => { setLatest(dayjs().unix()); - setMinDate(timeline.timeline.value[PRELOAD_NOTES]?.created_at ?? 0); - }, [timeline, setMinDate, setLatest]); + setMaxDate(getCachedNumber("max") ?? -Infinity); + setMinDate(getCachedNumber("min") ?? timeline.timeline.value[NOTE_BUFFER]?.created_at ?? 0); + }, [timeline, setMinDate, setLatest, getCachedNumber]); const newNotes: NostrEvent[] = []; const notes: NostrEvent[] = []; @@ -69,13 +110,29 @@ function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) { 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()); useEffect(() => { 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 preload = PRELOAD_NOTES; + let max: number = -Infinity; + let preload = NOTE_BUFFER; let foundVisible = false; for (const event of timeline.timeline.value) { if (event.created_at > latest) continue; @@ -92,17 +149,25 @@ function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) { } else { // found visible event 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)); + setMaxDate(max); + + setCachedNumber("max", max); + setCachedNumber("min", Math.min(getCachedNumber("min") ?? Infinity, min)); }; subject.subscribe(listener); return () => { subject.unsubscribe(listener); }; - }, [setMinDate, intersectionEntryCache, latest, timeline]); + }, [setMinDate, intersectionEntryCache, updateNoteMinHeight, setCachedNumber, getCachedNumber, latest, timeline]); return ( <> @@ -120,7 +185,12 @@ function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) { )} {notes.map((note) => ( - + ))} ); diff --git a/src/helpers/regexp.ts b/src/helpers/regexp.ts index 1b8480a87..6a4b9b933 100644 --- a/src/helpers/regexp.ts +++ b/src/helpers/regexp.ts @@ -1,7 +1,8 @@ export const getMatchNostrLink = () => /(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 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 getMatchCashu = () => /cashuA[A-z0-9]+/g;