mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-18 11:32:30 +02:00
Restore scroll position when returning to the timeline
This commit is contained in:
5
.changeset/cool-clouds-flash.md
Normal file
5
.changeset/cool-clouds-flash.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Restore scroll position when returning to the timeline
|
@@ -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<HTMLDivElement | null>(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 && <ErrorBoundary>{content}</ErrorBoundary>;
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Box minHeight={minHeight} ref={ref}>
|
||||
{visible && content}
|
||||
</Box>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
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 }) {
|
||||
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<string, IntersectionObserverEntry>());
|
||||
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 }) {
|
||||
</Box>
|
||||
)}
|
||||
{notes.map((note) => (
|
||||
<RenderEventMemo key={note.id} event={note} />
|
||||
<RenderEventMemo
|
||||
key={note.id}
|
||||
event={note}
|
||||
visible={note.created_at <= maxDate}
|
||||
minHeight={getCachedNumber(getEventUID(note))}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
@@ -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;
|
||||
|
||||
|
Reference in New Issue
Block a user