mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-18 19:41:48 +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 { 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))}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@@ -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;
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user