Improve notifications timeline rendering performance

and introduced a few bugs to fix later
This commit is contained in:
hzrd149
2024-09-05 15:02:06 -05:00
parent fc6d36b593
commit a7021217dc
21 changed files with 502 additions and 399 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Improve notifications timeline rendering performance

View File

@@ -65,8 +65,9 @@ export default class ChunkedRequest {
this.loading = true; this.loading = true;
if (!this.relay.connected) { if (!this.relay.connected) {
this.log("relay not connected, aborting"); this.log("requesting relay connection");
relayPoolService.requestConnect(this.relay); relayPoolService.requestConnect(this.relay);
this.loading = false;
return; return;
} }

View File

@@ -13,13 +13,16 @@ export default class PersistentSubscription {
process: Process; process: Process;
relay: Relay; relay: Relay;
filters: Filter[]; filters: Filter[];
closed = true; connecting = false;
params: Partial<SubscriptionParams>; params: Partial<SubscriptionParams>;
subscription: Subscription | null = null; subscription: Subscription | null = null;
get eosed() { get eosed() {
return !!this.subscription?.eosed; return !!this.subscription?.eosed;
} }
get closed() {
return !this.subscription || this.subscription.closed;
}
constructor(relay: AbstractRelay, params?: Partial<SubscriptionParams>) { constructor(relay: AbstractRelay, params?: Partial<SubscriptionParams>) {
this.id = nanoid(8); this.id = nanoid(8);
@@ -40,15 +43,21 @@ export default class PersistentSubscription {
/** attempts to update the subscription */ /** attempts to update the subscription */
async update() { async update() {
if (!this.filters || this.filters.length === 0) throw new Error("Missing filters"); if (!this.filters || this.filters.length === 0) throw new Error("Missing filters");
if (this.connecting) throw new Error("Cant update while connecting");
if (!(await relayPoolService.waitForOpen(this.relay))) throw new Error("Failed to connect to relay");
// check if its possible to subscribe to this relay // check if its possible to subscribe to this relay
if (!relayPoolService.canSubscribe(this.relay)) throw new Error("Cant subscribe to relay"); if (!relayPoolService.canSubscribe(this.relay)) throw new Error("Cant subscribe to relay");
this.closed = false;
this.process.active = true; this.process.active = true;
this.connecting = true;
if ((await relayPoolService.waitForOpen(this.relay)) === false) {
this.connecting = false;
this.process.active = false;
throw new Error("Failed to connect to relay");
}
this.connecting = false;
// recreate the subscription if its closed since nostr-tools cant reopen a sub // recreate the subscription if its closed since nostr-tools cant reopen a sub
if (!this.subscription || this.subscription.closed) { if (!this.subscription || this.subscription.closed) {
this.subscription = this.relay.subscribe(this.filters, { this.subscription = this.relay.subscribe(this.filters, {
@@ -60,7 +69,6 @@ export default class PersistentSubscription {
if (!this.closed) { if (!this.closed) {
relayPoolService.handleRelayNotice(this.relay, reason); relayPoolService.handleRelayNotice(this.relay, reason);
this.closed = true;
this.process.active = false; this.process.active = false;
} }
this.params.onclose?.(reason); this.params.onclose?.(reason);
@@ -74,9 +82,6 @@ export default class PersistentSubscription {
} else throw new Error("Subscription filters have not changed"); } else throw new Error("Subscription filters have not changed");
} }
close() { close() {
if (this.closed) return this;
this.closed = true;
if (this.subscription?.closed === false) this.subscription.close(); if (this.subscription?.closed === false) this.subscription.close();
this.process.active = false; this.process.active = false;

View File

@@ -1,158 +1,36 @@
import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { memo, useState } from "react";
import { Box, Button } from "@chakra-ui/react"; import { Box, Button } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { getEventUID } from "nostr-idb";
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";
import { NostrEvent } from "../../../types/nostr-event"; import useNumberCache from "../../../hooks/timeline/use-number-cache";
import { getEventUID } from "../../../helpers/nostr/event"; import useCacheEntryHeight from "../../../hooks/timeline/use-cache-entry-height";
import { import { useTimelineDates } from "../../../hooks/timeline/use-timeline-dates";
ExtendedIntersectionObserverEntry, import useTimelineLocationCacheKey from "../../../hooks/timeline/use-timeline-cache-key";
useIntersectionObserver,
} from "../../../providers/local/intersection-observer";
import TimelineItem from "./timeline-item"; import TimelineItem from "./timeline-item";
const INITIAL_NOTES = 10;
const NOTE_BUFFER = 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 events = useSubject(timeline.timeline); const events = useSubject(timeline.timeline);
const [latest, setLatest] = useState(() => dayjs().unix()); const [latest, setLatest] = useState(() => dayjs().unix());
const location = useLocation(); const cacheKey = useTimelineLocationCacheKey();
// only update the location key when the timeline changes const numberCache = useNumberCache(cacheKey);
// this fixes an issue with the key changes when the drawer opens const dates = useTimelineDates(timeline, numberCache, NOTE_BUFFER, INITIAL_NOTES);
const cachedLocationKey = useMemo(() => location.key, [timeline]);
const setCachedNumber = useCallback(
(id: string, value: number) => {
let timelineData = timelineNoteMinHeightCache.get(timeline);
if (!timelineData) {
timelineData = {};
timelineNoteMinHeightCache.set(timeline, timelineData);
}
if (!timelineData[cachedLocationKey]) timelineData[cachedLocationKey] = {};
timelineData[cachedLocationKey][id] = value;
},
[cachedLocationKey, timeline],
);
const getCachedNumber = useCallback(
(id: string) => {
const timelineData = timelineNoteMinHeightCache.get(timeline);
if (!timelineData) return undefined;
return timelineData[cachedLocationKey]?.[id] ?? undefined;
},
[cachedLocationKey, timeline],
);
const [pinDate, setPinDate] = useState(getCachedNumber("pin") ?? events[NOTE_BUFFER]?.created_at ?? Infinity); // measure and cache the hight of every entry
useCacheEntryHeight(numberCache.set);
const [maxDate, setMaxDate] = useState(getCachedNumber("max") ?? Infinity);
const [minDate, setMinDate] = useState(getCachedNumber("min") ?? events[NOTE_BUFFER]?.created_at ?? Infinity);
if (pinDate === Infinity && events.length > 0)
setPinDate(events[Math.min(NOTE_BUFFER, events.length - 1)]?.created_at);
if (minDate === Infinity && events.length > 0)
setMinDate(events[Math.min(NOTE_BUFFER, events.length - 1)]?.created_at);
// reset the latest and minDate when timeline changes
useEffect(() => {
setLatest(dayjs().unix());
setPinDate(getCachedNumber("pin") ?? timeline.timeline.value[NOTE_BUFFER]?.created_at ?? Infinity);
setMaxDate(getCachedNumber("max") ?? Infinity);
setMinDate(getCachedNumber("min") ?? timeline.timeline.value[NOTE_BUFFER]?.created_at ?? Infinity);
}, [timeline, setPinDate, setMinDate, setMaxDate, setLatest, getCachedNumber]);
const updateNoteMinHeight = useCallback(
(id: string, element: Element) => {
const rect = element.getBoundingClientRect();
const current = getCachedNumber(id);
if (rect.height !== current) 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 { subject: intersectionSubject } = useIntersectionObserver();
const [intersectionEntryCache] = useState(() => new Map<string, boolean>());
useEffect(() => {
const listener = (entities: ExtendedIntersectionObserverEntry[]) => {
for (const entity of entities) {
if (entity.id) {
intersectionEntryCache.set(entity.id, entity.entry.isIntersecting);
updateNoteMinHeight(entity.id, entity.entry.target);
}
}
let max: number = -Infinity;
let min: number = Infinity;
let minBuffer = NOTE_BUFFER;
let foundVisible = false;
for (const event of timeline.timeline.value) {
if (event.created_at > latest) continue;
const isIntersecting = intersectionEntryCache.get(getEventUID(event));
if (!isIntersecting) {
if (foundVisible) {
// found an event below the view
if (minBuffer-- < 0) break;
if (event.created_at < min) min = event.created_at;
} else {
// found an event above the view
continue;
}
} else {
// found visible event
foundVisible = true;
// find the event that is x indexes back
const bufferEvent = timeline.timeline.value[timeline.timeline.value.indexOf(event) - NOTE_BUFFER];
if (bufferEvent && bufferEvent.created_at > max) max = bufferEvent.created_at;
}
}
if (min !== Infinity) {
setCachedNumber("min", min);
setMinDate(min);
// only set the pin date if its less than before (the timeline only get longer)
setPinDate((v) => {
const value = Math.min(v, min);
setCachedNumber("pin", value);
return value;
});
}
if (max !== -Infinity) {
setMaxDate(max);
setCachedNumber("max", max);
} else if (foundVisible) {
setMaxDate(Infinity);
setCachedNumber("max", Infinity);
}
};
const sub = intersectionSubject.subscribe(listener);
return () => {
sub.unsubscribe();
};
}, [
setPinDate,
setMaxDate,
setMinDate,
intersectionSubject,
intersectionEntryCache,
updateNoteMinHeight,
setCachedNumber,
getCachedNumber,
latest,
]);
const newNotes: NostrEvent[] = []; const newNotes: NostrEvent[] = [];
const notes: NostrEvent[] = []; const notes: NostrEvent[] = [];
for (const note of events) { for (const note of events) {
if (note.created_at > latest) newNotes.push(note); if (note.created_at > latest) newNotes.push(note);
else if (note.created_at >= pinDate) notes.push(note); else if (note.created_at >= dates.cursor) notes.push(note);
} }
return ( return (
@@ -174,8 +52,8 @@ function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) {
<TimelineItem <TimelineItem
key={note.id} key={note.id}
event={note} event={note}
visible={note.created_at <= maxDate && note.created_at >= minDate} visible={note.created_at <= dates.max && note.created_at >= dates.min}
minHeight={getCachedNumber(getEventUID(note))} minHeight={numberCache.get(getEventUID(note))}
/> />
))} ))}
</> </>

View File

@@ -0,0 +1,23 @@
import { useEffect } from "react";
import { getEntryDetails, useIntersectionObserver } from "../../providers/local/intersection-observer";
export default function useCacheEntryHeight(set: (key: string, value: number) => void) {
const { subject: intersectionSubject } = useIntersectionObserver();
useEffect(() => {
const listener = (entities: IntersectionObserverEntry[]) => {
for (const entry of entities) {
const details = getEntryDetails(entry);
if (details?.id && entry.target instanceof HTMLElement) {
const rect = entry.target.getBoundingClientRect();
set(details.id, rect.height);
}
}
};
const sub = intersectionSubject.subscribe(listener);
return () => {
sub.unsubscribe();
};
}, [intersectionSubject, set]);
}

View File

@@ -0,0 +1,19 @@
import { useEffect, useRef } from "react";
export default function useMinNumber(reset?: any, ...values: (number | undefined)[]) {
const min = useRef<number>(Infinity);
// reset min when reset changes
useEffect(() => {
min.current = Infinity;
}, [reset]);
for (const v of values) {
if (v !== undefined) {
if (min.current === undefined) min.current = v;
else if (v < min.current) min.current = v;
}
}
return min.current;
}

View File

@@ -0,0 +1,37 @@
import { useCallback } from "react";
import { LRU } from "tiny-lru";
const cache = new LRU<Map<string, number>>(10);
export type NumberCache = {
key: string;
get: (key: string) => number | undefined;
set: (key: string, value: number) => void;
};
export default function useNumberCache(cacheKey: string): NumberCache {
const get = useCallback(
(key: string) => {
let map = cache.get(cacheKey);
if (!map) return undefined;
return map.get(key);
},
[cacheKey],
);
const set = useCallback(
(key: string, value: number) => {
let map = cache.get(cacheKey);
if (!map) {
map = new Map();
cache.set(cacheKey, map);
}
return map.set(key, value);
},
[cacheKey],
);
return { key: cacheKey, get, set };
}

View File

@@ -0,0 +1,15 @@
import { nanoid } from "nanoid";
import useRouteStateValue from "../use-route-state-value";
import { useEffect, useMemo } from "react";
/** gets or sets a unique cache key for the location */
export default function useTimelineLocationCacheKey() {
const fallback = useMemo(() => nanoid(), []);
const { value: cacheKey, setValue: setCacheKey } = useRouteStateValue("timeline-cache-key", "");
useEffect(() => {
if (!cacheKey) setCacheKey(fallback);
}, [cacheKey, fallback]);
return cacheKey || fallback;
}

View File

@@ -0,0 +1,31 @@
import { useEffect } from "react";
import TimelineLoader from "../../classes/timeline-loader";
import useMinNumber from "./use-min-number";
import { NumberCache } from "./use-number-cache";
import useTimelineViewDatesBuffer from "./use-timeline-view-dates-buffer";
export function useTimelineDates(
timeline: { id: string; created_at: number }[] | TimelineLoader,
cache: NumberCache,
buffer = 5,
initialRender = 10,
) {
const dates = useTimelineViewDatesBuffer(
cache.key,
{ min: cache.get("min"), max: cache.get("max") },
Array.isArray(timeline) ? timeline : timeline.timeline.value,
buffer,
initialRender,
);
const cursor = useMinNumber(cache.key, cache.get("cursor"), dates.min);
// cache dates
useEffect(() => {
if (dates.min) cache.set("min", dates.min);
if (dates.max) cache.set("max", dates.max);
cache.set("cursor", cursor);
}, [dates.max, dates.min, cursor, cache.set]);
return { ...dates, cursor: cursor };
}

View File

@@ -0,0 +1,42 @@
import useTimelineViewDates from "./use-timeline-view-dates";
export default function useTimelineViewDatesBuffer(
cacheKey: string,
init: { min?: number; max?: number },
timeline: { id: string; created_at: number }[],
buffer: number,
initialRender: number,
) {
// pass timeline in as reset
let { min, max } = useTimelineViewDates(init, cacheKey);
// if we don't know the max date in the view, set it to Infinity
if (!max || timeline.length <= buffer) {
max = Infinity;
} else {
for (let i = 0; i < timeline.length; i++) {
const event = timeline[i];
const bufferEvent = timeline[i + buffer];
if (bufferEvent && bufferEvent?.created_at <= max) {
max = event.created_at;
break;
}
}
}
let minBuffer = min ? buffer : buffer + initialRender;
for (let i = 0; i < timeline.length; i++) {
const event = timeline[i];
// if min date is not set, start at first event in timeline
if (!min) min = event.created_at;
// find minBuffer number of events below min date
else if (event.created_at < min) {
min = event.created_at;
if (minBuffer-- === 0) break;
}
}
return { min: min as number, max: max as number };
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from "react";
import { getEntryDetails, useIntersectionObserver } from "../../providers/local/intersection-observer";
/** tracks the min and max dates/entries that are visible in the view */
export default function useTimelineViewDates(init: { min?: number; max?: number }, reset?: string) {
const [dates, setDates] = useState<{ min?: number; max?: number }>(init);
const [cache] = useState(() => new Map<string, { entry: IntersectionObserverEntry; ts: number; id: string }>());
// when reset changes, forget the dates
const prev = useRef(reset);
useEffect(() => {
if (prev.current && prev.current !== reset) {
setDates({});
cache.clear();
}
prev.current = reset;
}, [setDates, cache, reset]);
const { subject: intersectionSubject } = useIntersectionObserver();
useEffect(() => {
const listener = (entities: IntersectionObserverEntry[]) => {
// update cache
for (const entry of entities) {
const details = getEntryDetails(entry);
if (details?.id && details?.ts) cache.set(details.id, { entry, ts: parseInt(details.ts), id: details.id });
}
// get a sorted list of all entries
const timeline = Array.from(cache.values()).sort((a, b) => b.ts - a.ts);
let max: number = -Infinity;
let min: number = Infinity;
let foundVisible = false;
for (let i = 0; i < timeline.length; i++) {
const { entry, ts, id } = timeline[i];
const isIntersecting = entry.isIntersecting;
if (!isIntersecting) {
// found an entry below the view
if (foundVisible) {
// move the min date
if (ts < min) min = ts;
break;
} else {
// found an event above the view
continue;
}
} else {
// found visible event
foundVisible = true;
if (ts > max) max = ts;
if (ts < min) min = ts;
}
}
setDates({
min: min !== Infinity ? min : undefined,
max: max !== -Infinity ? max : undefined,
});
};
const sub = intersectionSubject.subscribe(listener);
return () => {
sub.unsubscribe();
};
}, [setDates, intersectionSubject, cache]);
return dates;
}

View File

@@ -2,10 +2,10 @@ import { useRef } from "react";
import { getEventUID } from "nostr-idb"; import { getEventUID } from "nostr-idb";
import { NostrEvent } from "nostr-tools"; import { NostrEvent } from "nostr-tools";
import { useRegisterIntersectionEntity } from "../providers/local/intersection-observer"; import { useIntersectionEntityDetails } from "../providers/local/intersection-observer";
export default function useEventIntersectionRef<T extends HTMLElement = HTMLDivElement>(event?: NostrEvent) { export default function useEventIntersectionRef<T extends HTMLElement = HTMLDivElement>(event?: NostrEvent) {
const ref = useRef<T | null>(null); const ref = useRef<T | null>(null);
useRegisterIntersectionEntity(ref, event ? getEventUID(event) : undefined); useIntersectionEntityDetails(ref, event ? getEventUID(event) : undefined, event?.created_at);
return ref; return ref;
} }

View File

@@ -2,7 +2,7 @@ import { useInterval } from "react-use";
import { NostrEvent } from "nostr-tools"; import { NostrEvent } from "nostr-tools";
import TimelineLoader from "../classes/timeline-loader"; import TimelineLoader from "../classes/timeline-loader";
import { useIntersectionMapCallback } from "../providers/local/intersection-observer"; import { useCachedIntersectionMapCallback } from "../providers/local/intersection-observer";
export function useTimelineCurserIntersectionCallback(timeline: TimelineLoader) { export function useTimelineCurserIntersectionCallback(timeline: TimelineLoader) {
// if the cursor is set too far ahead and the last block did not overlap with the cursor // if the cursor is set too far ahead and the last block did not overlap with the cursor
@@ -11,12 +11,12 @@ export function useTimelineCurserIntersectionCallback(timeline: TimelineLoader)
timeline.triggerChunkLoad(); timeline.triggerChunkLoad();
}, 1000); }, 1000);
return useIntersectionMapCallback( return useCachedIntersectionMapCallback(
(map) => { (map) => {
// find oldest event that is visible // find oldest event that is visible
let oldestEvent: NostrEvent | undefined = undefined; let oldestEvent: NostrEvent | undefined = undefined;
for (const [id, intersection] of map) { for (const [id, entry] of map) {
if (!intersection.isIntersecting) continue; if (!entry.isIntersecting) continue;
const event = timeline.events.getEvent(id); const event = timeline.events.getEvent(id);
if (!event) continue; if (!event) continue;
if (!oldestEvent || event.created_at < oldestEvent.created_at) { if (!oldestEvent || event.created_at < oldestEvent.created_at) {

View File

@@ -13,50 +13,53 @@ import { useMount, useUnmount } from "react-use";
import Subject from "../../classes/subject"; import Subject from "../../classes/subject";
export type ExtendedIntersectionObserverEntry = { entry: IntersectionObserverEntry; id: string | undefined };
export type ExtendedIntersectionObserverCallback = (
entries: ExtendedIntersectionObserverEntry[],
observer: IntersectionObserver,
) => void;
const IntersectionObserverContext = createContext<{ const IntersectionObserverContext = createContext<{
observer?: IntersectionObserver; observer?: IntersectionObserver;
setElementId: (element: Element, id: any) => void;
// NOTE: hard codded string type // NOTE: hard codded string type
subject: Subject<ExtendedIntersectionObserverEntry[]>; subject: Subject<IntersectionObserverEntry[]>;
}>({ setElementId: () => {}, subject: new Subject() }); }>({ subject: new Subject() });
export function useIntersectionObserver() { export function useIntersectionObserver() {
return useContext(IntersectionObserverContext); return useContext(IntersectionObserverContext);
} }
export function useRegisterIntersectionEntity(ref: MutableRefObject<Element | null>, id?: string) { export function getEntryDetails(entry: IntersectionObserverEntry) {
const { observer, setElementId } = useIntersectionObserver(); if (entry.target instanceof HTMLElement) {
const { id, ts } = entry.target.dataset;
if (id && ts) return { id, ts };
}
}
export function useIntersectionEntityDetails(ref: MutableRefObject<HTMLElement | null>, id?: string, date?: number) {
const { observer } = useIntersectionObserver();
useEffect(() => { useEffect(() => {
if (observer && ref.current) { if (observer && ref.current) {
observer.observe(ref.current); observer.observe(ref.current);
if (id) {
setElementId(ref.current, id); if (id) ref.current.dataset.id = id;
if (import.meta.env.DEV) ref.current.setAttribute("data-event-id", id); if (date) ref.current.dataset.ts = String(date);
}
} }
}, [observer]); }, [observer]);
useUnmount(() => { useUnmount(() => {
if (observer && ref.current) observer.unobserve(ref.current); if (observer && ref.current) observer.unobserve(ref.current);
}); });
} }
/** @deprecated */ export function useCachedIntersectionMapCallback(
export function useIntersectionMapCallback(
callback: (map: Map<string, IntersectionObserverEntry>) => void, callback: (map: Map<string, IntersectionObserverEntry>) => void,
watch: DependencyList, watch: DependencyList,
) { ) {
const map = useMemo(() => new Map<string, IntersectionObserverEntry>(), []); const cache = useMemo(() => new Map<string, IntersectionObserverEntry>(), []);
return useCallback<ExtendedIntersectionObserverCallback>(
(entries) => { return useCallback<IntersectionObserverCallback>(
for (const { id, entry } of entries) id && map.set(id, entry); (entries, observer) => {
callback(map); for (const entry of entries) {
const details = getEntryDetails(entry);
if (details?.id) cache.set(details.id, entry);
}
callback(cache);
}, },
[callback, ...watch], [callback, ...watch],
); );
@@ -72,21 +75,16 @@ export default function IntersectionObserverProvider({
root?: MutableRefObject<HTMLElement | null>; root?: MutableRefObject<HTMLElement | null>;
rootMargin?: IntersectionObserverInit["rootMargin"]; rootMargin?: IntersectionObserverInit["rootMargin"];
threshold?: IntersectionObserverInit["threshold"]; threshold?: IntersectionObserverInit["threshold"];
callback: ExtendedIntersectionObserverCallback; callback: IntersectionObserverCallback;
}) { }) {
const elementIds = useMemo(() => new WeakMap<Element, string>(), []); const [subject] = useState(() => new Subject<IntersectionObserverEntry[]>([]));
const [subject] = useState(() => new Subject<ExtendedIntersectionObserverEntry[]>([]));
const handleIntersection = useCallback<IntersectionObserverCallback>( const handleIntersection = useCallback<IntersectionObserverCallback>(
(entries, observer) => { (entries, observer) => {
const extendedEntries = entries.map((entry) => { callback(entries, observer);
return { entry, id: elementIds.get(entry.target) }; subject.next(entries);
});
callback(extendedEntries, observer);
subject.next(extendedEntries);
}, },
[subject], [subject, callback],
); );
const [observer, setObserver] = useState<IntersectionObserver>( const [observer, setObserver] = useState<IntersectionObserver>(
@@ -103,20 +101,12 @@ export default function IntersectionObserverProvider({
if (observer) observer.disconnect(); if (observer) observer.disconnect();
}); });
const setElementId = useCallback(
(element: Element, id: string) => {
elementIds.set(element, id);
},
[elementIds],
);
const context = useMemo( const context = useMemo(
() => ({ () => ({
observer, observer,
setElementId,
subject, subject,
}), }),
[observer, setElementId, subject], [observer, subject],
); );
return <IntersectionObserverContext.Provider value={context}>{children}</IntersectionObserverContext.Provider>; return <IntersectionObserverContext.Provider value={context}>{children}</IntersectionObserverContext.Provider>;

View File

@@ -56,8 +56,6 @@ class ReadStatusService {
const subject = this.status.get(key); const subject = this.status.get(key);
const status = await trans.store.get(key); const status = await trans.store.get(key);
this.log(key, status);
if (status) { if (status) {
subject.next(status.read); subject.next(status.read);
if (status.ttl) this.setTTL(key, status.ttl); if (status.ttl) this.setTTL(key, status.ttl);

View File

@@ -40,9 +40,9 @@ export default function NotificationsCard({ ...props }: Omit<CardProps, "childre
</Heading> </Heading>
<KeyboardShortcut letter="i" requireMeta ml="auto" onPress={() => navigate("/notifications")} /> <KeyboardShortcut letter="i" requireMeta ml="auto" onPress={() => navigate("/notifications")} />
</CardHeader> </CardHeader>
<CardBody overflowX="hidden" overflowY="auto" pt="4" display="flex" gap="2" flexDirection="column"> <CardBody overflowX="hidden" overflowY="auto" pt="4" display="flex" flexDirection="column">
{limit.map((event) => ( {limit.map((event) => (
<NotificationItem event={event} key={event.id} onClick={handleClick} /> <NotificationItem event={event} key={event.id} onClick={handleClick} visible />
))} ))}
<Button as={RouterLink} to="/notifications" flexShrink={0} variant="link" size="lg" py="4"> <Button as={RouterLink} to="/notifications" flexShrink={0} variant="link" size="lg" py="4">
View More View More

View File

@@ -1,4 +1,4 @@
import { PropsWithChildren, ReactNode, forwardRef, memo, useCallback, useContext, useEffect } from "react"; import { PropsWithChildren, ReactNode, forwardRef, memo, useCallback, useContext, useEffect, useRef } from "react";
import { Box, Flex, Spacer, Text, useColorModeValue } from "@chakra-ui/react"; import { Box, Flex, Spacer, Text, useColorModeValue } from "@chakra-ui/react";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -32,10 +32,12 @@ const NotificationIconEntry = memo(
const focusSelf = useCallback(() => focus(id), [id, focus]); const focusSelf = useCallback(() => focus(id), [id, focus]);
// scroll element to stop when opened // scroll element to stop when opened
const headerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
if (expanded) { if (expanded) {
// @ts-expect-error setTimeout(() => {
ref.current?.scrollIntoView(); headerRef.current?.scrollIntoView();
}, 2);
} }
}, [expanded]); }, [expanded]);
@@ -45,11 +47,17 @@ const NotificationIconEntry = memo(
}, [read, expanded]); }, [read, expanded]);
return ( return (
<Flex direction="column" bg={expanded ? "whiteAlpha.100" : undefined} rounded="md"> <Flex
direction="column"
bg={expanded ? "whiteAlpha.100" : undefined}
rounded="md"
flexGrow={1}
overflow="hidden"
ref={ref}
>
<Flex <Flex
gap="2" gap="2"
alignItems="center" alignItems="center"
ref={ref}
cursor="pointer" cursor="pointer"
p="2" p="2"
tabIndex={0} tabIndex={0}
@@ -57,6 +65,8 @@ const NotificationIconEntry = memo(
onClick={onClick} onClick={onClick}
userSelect="none" userSelect="none"
bg={!read ? focusColor : undefined} bg={!read ? focusColor : undefined}
ref={headerRef}
overflow="hidden"
> >
<Box>{icon}</Box> <Box>{icon}</Box>
<UserAvatar pubkey={pubkey} size="sm" /> <UserAvatar pubkey={pubkey} size="sm" />

View File

@@ -1,5 +1,15 @@
import { ReactNode, forwardRef, memo, useCallback, useMemo } from "react"; import { ReactNode, forwardRef, memo, useCallback, useMemo } from "react";
import { AvatarGroup, ButtonGroup, Flex, IconButton, IconButtonProps, Text } from "@chakra-ui/react"; import {
AvatarGroup,
Box,
BoxProps,
ButtonGroup,
Flex,
FlexProps,
IconButton,
IconButtonProps,
Text,
} from "@chakra-ui/react";
import { kinds, nip18, nip25 } from "nostr-tools"; import { kinds, nip18, nip25 } from "nostr-tools";
import { DecodeResult } from "nostr-tools/nip19"; import { DecodeResult } from "nostr-tools/nip19";
@@ -224,7 +234,16 @@ const ZapNotification = forwardRef<HTMLDivElement, { event: NostrEvent; onClick?
}, },
); );
const NotificationItem = ({ event, onClick }: { event: CategorizedEvent; onClick?: (event: NostrEvent) => void }) => { const NotificationItem = ({
event,
visible,
onClick,
...props
}: Omit<FlexProps, "children" | "onClick"> & {
event: CategorizedEvent;
onClick?: (event: NostrEvent) => void;
visible: boolean;
}) => {
const ref = useEventIntersectionRef(event); const ref = useEventIntersectionRef(event);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
@@ -232,32 +251,37 @@ const NotificationItem = ({ event, onClick }: { event: CategorizedEvent; onClick
}, [onClick, event]); }, [onClick, event]);
let content: ReactNode | null = null; let content: ReactNode | null = null;
switch (event[typeSymbol]) { if (visible) {
case NotificationType.Reply: switch (event[typeSymbol]) {
content = <ReplyNotification event={event} onClick={onClick && handleClick} ref={ref} />; case NotificationType.Reply:
break; content = <ReplyNotification event={event} onClick={onClick && handleClick} />;
case NotificationType.Mention: break;
content = <MentionNotification event={event} onClick={onClick && handleClick} ref={ref} />; case NotificationType.Mention:
break; content = <MentionNotification event={event} onClick={onClick && handleClick} />;
case NotificationType.Reaction: break;
content = <ReactionNotification event={event} onClick={onClick && handleClick} ref={ref} />; case NotificationType.Reaction:
break; content = <ReactionNotification event={event} onClick={onClick && handleClick} />;
case NotificationType.Repost: break;
content = <RepostNotification event={event} onClick={onClick && handleClick} ref={ref} />; case NotificationType.Repost:
break; content = <RepostNotification event={event} onClick={onClick && handleClick} />;
case NotificationType.Zap: break;
content = <ZapNotification event={event} onClick={onClick && handleClick} ref={ref} />; case NotificationType.Zap:
break; content = <ZapNotification event={event} onClick={onClick && handleClick} />;
default: break;
content = <EmbeddedUnknown event={event} />; default:
break; content = <EmbeddedUnknown event={event} />;
break;
}
} }
return ( return (
content && ( <Flex ref={ref} overflow="hidden" flexShrink={0} {...props}>
<ErrorBoundary> {content && (
<TrustProvider event={event}>{content}</TrustProvider> <ErrorBoundary>
</ErrorBoundary> <TrustProvider event={event}>{content}</TrustProvider>
) </ErrorBoundary>
)}
</Flex>
); );
}; };

View File

@@ -1,7 +1,8 @@
import { memo, ReactNode, useCallback, useContext, useMemo } from "react"; import { memo, ReactNode, useCallback, useMemo } from "react";
import { Button, ButtonGroup, Divider, Flex, Text } from "@chakra-ui/react"; import { Button, ButtonGroup, Divider, Flex, Text } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import { getEventUID } from "nostr-idb";
import RequireCurrentAccount from "../../providers/route/require-current-account"; import RequireCurrentAccount from "../../providers/route/require-current-account";
import IntersectionObserverProvider from "../../providers/local/intersection-observer"; import IntersectionObserverProvider from "../../providers/local/intersection-observer";
@@ -14,14 +15,16 @@ import VerticalPageLayout from "../../components/vertical-page-layout";
import NotificationItem from "./components/notification-item"; import NotificationItem from "./components/notification-item";
import NotificationTypeToggles from "./notification-type-toggles"; import NotificationTypeToggles from "./notification-type-toggles";
import useLocalStorageDisclosure from "../../hooks/use-localstorage-disclosure"; import useLocalStorageDisclosure from "../../hooks/use-localstorage-disclosure";
import { NotificationType, typeSymbol } from "../../classes/notifications"; import { CategorizedEvent, NotificationType, typeSymbol } from "../../classes/notifications";
import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status"; import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status";
import FocusedContext from "./focused-context"; import FocusedContext from "./focused-context";
import useRouteStateValue from "../../hooks/use-route-state-value";
import readStatusService from "../../services/read-status"; import readStatusService from "../../services/read-status";
import useKeyPressNav from "../../hooks/use-key-press-nav"; import useTimelineLocationCacheKey from "../../hooks/timeline/use-timeline-cache-key";
import useNumberCache from "../../hooks/timeline/use-number-cache";
// const DATE_FORMAT = "YYYY-MM-DD"; import { useTimelineDates } from "../../hooks/timeline/use-timeline-dates";
import useCacheEntryHeight from "../../hooks/timeline/use-cache-entry-height";
import useVimNavigation from "./use-vim-navigation";
import { PersistentSubject } from "../../classes/subject";
function TimeMarker({ date, ids }: { date: Dayjs; ids: string[] }) { function TimeMarker({ date, ids }: { date: Dayjs; ids: string[] }) {
const readAll = useCallback(() => { const readAll = useCallback(() => {
@@ -42,14 +45,12 @@ function TimeMarker({ date, ids }: { date: Dayjs; ids: string[] }) {
const NotificationsTimeline = memo( const NotificationsTimeline = memo(
({ ({
// day,
showReplies, showReplies,
showMentions, showMentions,
showZaps, showZaps,
showReposts, showReposts,
showReactions, showReactions,
}: { }: {
// day: string;
showReplies: boolean; showReplies: boolean;
showMentions: boolean; showMentions: boolean;
showZaps: boolean; showZaps: boolean;
@@ -58,107 +59,45 @@ const NotificationsTimeline = memo(
}) => { }) => {
const { notifications } = useNotifications(); const { notifications } = useNotifications();
const { people } = usePeopleListContext(); const { people } = usePeopleListContext();
const { id: focused, focus: setFocus } = useContext(FocusedContext);
const peoplePubkeys = useMemo(() => people?.map((p) => p.pubkey), [people]); const peoplePubkeys = useMemo(() => people?.map((p) => p.pubkey), [people]);
// const minTimestamp = dayjs(day, DATE_FORMAT).startOf("day").unix();
// const maxTimestamp = dayjs(day, DATE_FORMAT).endOf("day").unix();
const events = useSubject(notifications?.timeline) ?? []; const events = useSubject(notifications?.timeline) ?? [];
const filteredEvents = useMemo( const cacheKey = useTimelineLocationCacheKey();
() => const numberCache = useNumberCache(cacheKey);
events.filter((e) => {
// if (e.created_at < minTimestamp || e.created_at > maxTimestamp) return false;
if (e[typeSymbol] === NotificationType.Zap) { const minItems = Math.round(window.innerHeight / 48);
if (!showZaps) return false; const dates = useTimelineDates(events, numberCache, minItems / 2, minItems);
if (peoplePubkeys && !peoplePubkeys.includes(e.pubkey)) return false;
}
if (!showReplies && e[typeSymbol] === NotificationType.Reply) return false; // measure and cache the hight of every entry
if (!showMentions && e[typeSymbol] === NotificationType.Mention) return false; useCacheEntryHeight(numberCache.set);
if (!showReactions && e[typeSymbol] === NotificationType.Reaction) return false;
if (!showReposts && e[typeSymbol] === NotificationType.Repost) return false;
if (!showZaps && e[typeSymbol] === NotificationType.Zap) return false;
return true; const filtered: CategorizedEvent[] = [];
}), for (const event of events) {
[ if (event.created_at < dates.cursor && filtered.length > minItems) continue;
events,
peoplePubkeys, const type = event[typeSymbol];
showReplies, if (type === NotificationType.Zap) {
showMentions, if (!showZaps) continue;
showReactions, if (peoplePubkeys && !peoplePubkeys.includes(event.pubkey)) continue;
showReposts, }
showZaps,
// minTimestamp, if (!showReplies && type === NotificationType.Reply) continue;
// maxTimestamp, if (!showMentions && type === NotificationType.Mention) continue;
], if (!showReactions && type === NotificationType.Reaction) continue;
); if (!showReposts && type === NotificationType.Repost) continue;
if (!showZaps && type === NotificationType.Zap) continue;
filtered.push(event);
}
// VIM controls // VIM controls
const navigatePrev = () => { useVimNavigation(filtered);
const focusedEvent = filteredEvents.find((e) => e.id === focused);
if (focusedEvent) { if (filtered.length === 0)
const i = filteredEvents.indexOf(focusedEvent);
if (i >= 1) {
const prev = filteredEvents[i - 1];
if (prev) setFocus(prev.id);
}
}
};
const navigatePrevUnread = () => {
const focusedEvent = filteredEvents.find((e) => e.id === focused);
const idx = focusedEvent ? filteredEvents.indexOf(focusedEvent) : 0;
for (let i = idx; i >= 0; i--) {
if (readStatusService.getStatus(filteredEvents[i].id).value === false) {
setFocus(filteredEvents[i].id);
break;
}
}
};
const navigateNext = () => {
const focusedEvent = filteredEvents.find((e) => e.id === focused);
const i = focusedEvent ? filteredEvents.indexOf(focusedEvent) : -1;
if (i < filteredEvents.length - 2) {
const next = filteredEvents[i + 1];
if (next) setFocus(next.id);
}
};
const navigateNextUnread = () => {
const focusedEvent = filteredEvents.find((e) => e.id === focused);
const idx = focusedEvent ? filteredEvents.indexOf(focusedEvent) : 0;
for (let i = idx; i < filteredEvents.length; i++) {
if (readStatusService.getStatus(filteredEvents[i].id).value === false) {
setFocus(filteredEvents[i].id);
break;
}
}
};
const navigateTop = () => setFocus(filteredEvents[0]?.id ?? "");
const navigateEnd = () => setFocus(filteredEvents[filteredEvents.length - 1]?.id ?? "");
useKeyPressNav("ArrowUp", navigatePrev);
useKeyPressNav("ArrowDown", navigateNext);
useKeyPressNav("ArrowLeft", navigatePrevUnread);
useKeyPressNav("ArrowRight", navigateNextUnread);
useKeyPressNav("k", navigatePrev);
useKeyPressNav("h", navigatePrevUnread);
useKeyPressNav("j", navigateNext);
useKeyPressNav("l", navigateNextUnread);
useKeyPressNav("H", navigateTop);
useKeyPressNav("Home", navigateTop);
useKeyPressNav("L", navigateEnd);
useKeyPressNav("End", navigateEnd);
if (filteredEvents.length === 0)
return ( return (
<Flex alignItems="center" justifyContent="center" minH="25vh" fontWeight="bold" fontSize="4xl"> <Flex alignItems="center" justifyContent="center" minH="25vh" fontWeight="bold" fontSize="4xl">
Nothing... Loading...
</Flex> </Flex>
); );
@@ -166,7 +105,7 @@ const NotificationsTimeline = memo(
let prev = dayjs(); let prev = dayjs();
let ids: string[] = []; let ids: string[] = [];
for (const event of filteredEvents) { for (const event of filtered) {
// insert markers at every day // insert markers at every day
if (prev.diff(dayjs.unix(event.created_at), "d") > 0) { if (prev.diff(dayjs.unix(event.created_at), "d") > 0) {
prev = dayjs.unix(event.created_at); prev = dayjs.unix(event.created_at);
@@ -176,17 +115,29 @@ const NotificationsTimeline = memo(
} }
ids.push(event.id); ids.push(event.id);
items.push(<NotificationItem key={event.id} event={event} />); items.push(
<NotificationItem
key={event.id}
event={event}
visible={event.created_at <= dates.max && event.created_at >= dates.min}
minHeight={numberCache.get(getEventUID(event)) + "px"}
/>,
);
} }
return <>{items}</>; return <>{items}</>;
}, },
); );
const cachedFocus = new PersistentSubject("");
function NotificationsPage() { function NotificationsPage() {
const { timeline } = useNotifications(); const { timeline } = useNotifications();
const { value: focused, setValue: setFocused } = useRouteStateValue("focused", ""); // const { value: focused, setValue: setFocused } = useRouteStateValue("focused", "");
// const [focused, setFocused] = useState("");
const focused = useSubject(cachedFocus);
const setFocused = useCallback((id: string) => cachedFocus.next(id), [cachedFocus]);
const focusContext = useMemo(() => ({ id: focused, focus: setFocused }), [focused, setFocused]); const focusContext = useMemo(() => ({ id: focused, focus: setFocused }), [focused, setFocused]);
const showReplies = useLocalStorageDisclosure("notifications-show-replies", true); const showReplies = useLocalStorageDisclosure("notifications-show-replies", true);
@@ -195,67 +146,11 @@ function NotificationsPage() {
const showReposts = useLocalStorageDisclosure("notifications-show-reposts", true); const showReposts = useLocalStorageDisclosure("notifications-show-reposts", true);
const showReactions = useLocalStorageDisclosure("notifications-show-reactions", true); const showReactions = useLocalStorageDisclosure("notifications-show-reactions", true);
// const today = dayjs().format(DATE_FORMAT);
// const { value: day, setValue: setDay } = useRouteSearchValue(
// "date",
// timeline.timeline.value[0] ? dayjs.unix(timeline.timeline.value[0].created_at).format(DATE_FORMAT) : today,
// );
// const nextDay = () => {
// setDay((date) => {
// const endOfDay = dayjs(date ?? today, DATE_FORMAT)
// .endOf("day")
// .unix();
// // find the next event
// for (let i = timeline.timeline.value.length - 1; i > 0; i--) {
// const e = timeline.timeline.value[i];
// if (e.created_at > endOfDay) return dayjs.unix(e.created_at).format(DATE_FORMAT);
// }
// return dayjs(date ?? today, DATE_FORMAT)
// .add(1, "day")
// .format(DATE_FORMAT);
// });
// };
// const previousDay = () => {
// setDay((date) => {
// const startOfDay = dayjs(date ?? today, DATE_FORMAT).unix();
// // find the next event
// for (const e of timeline.timeline.value) {
// if (e.created_at < startOfDay) return dayjs.unix(e.created_at).format(DATE_FORMAT);
// }
// return dayjs(date ?? today, DATE_FORMAT)
// .subtract(1, "day")
// .format(DATE_FORMAT);
// });
// };
const callback = useTimelineCurserIntersectionCallback(timeline); const callback = useTimelineCurserIntersectionCallback(timeline);
return ( return (
<VerticalPageLayout> <VerticalPageLayout>
<Flex direction={{ base: "column", lg: "row-reverse" }} gap="2" justifyContent="space-between"> <Flex direction={{ base: "column", lg: "row-reverse" }} gap="2" justifyContent="space-between">
{/* <Flex gap="2" justifyContent="space-between">
<IconButton aria-label="Previous" icon={<ChevronLeftIcon boxSize={6} />} onClick={previousDay} />
<Input
maxW="xs"
minW="64"
type="date"
value={day}
onChange={(e) => e.target.value && setDay(e.target.value)}
max={today}
/>
<IconButton
aria-label="Next"
icon={<ChevronRightIcon boxSize={6} />}
onClick={nextDay}
isDisabled={day === today}
/>
</Flex> */}
<Flex gap="2" wrap="wrap" flex={1} alignItems="center"> <Flex gap="2" wrap="wrap" flex={1} alignItems="center">
<NotificationTypeToggles <NotificationTypeToggles
showReplies={showReplies} showReplies={showReplies}
@@ -275,9 +170,8 @@ function NotificationsPage() {
<IntersectionObserverProvider callback={callback}> <IntersectionObserverProvider callback={callback}>
<FocusedContext.Provider value={focusContext}> <FocusedContext.Provider value={focusContext}>
<Flex direction="column"> <Flex direction="column" overflow="hidden">
<NotificationsTimeline <NotificationsTimeline
// day={day}
showReplies={showReplies.isOpen} showReplies={showReplies.isOpen}
showMentions={showMentions.isOpen} showMentions={showMentions.isOpen}
showZaps={showZaps.isOpen} showZaps={showZaps.isOpen}
@@ -289,17 +183,6 @@ function NotificationsPage() {
</IntersectionObserverProvider> </IntersectionObserverProvider>
<TimelineActionAndStatus timeline={timeline} /> <TimelineActionAndStatus timeline={timeline} />
{/* <ButtonGroup mx="auto" mt="4">
<Button leftIcon={<ChevronLeftIcon boxSize={6} />} onClick={previousDay}>
Previous
</Button>
{day !== today && (
<Button rightIcon={<ChevronRightIcon boxSize={6} />} onClick={nextDay}>
Next
</Button>
)}
</ButtonGroup> */}
</VerticalPageLayout> </VerticalPageLayout>
); );
} }

View File

@@ -0,0 +1,68 @@
import { NostrEvent } from "nostr-tools";
import { useContext } from "react";
import FocusedContext from "./focused-context";
import readStatusService from "../../services/read-status";
import useKeyPressNav from "../../hooks/use-key-press-nav";
export default function useVimNavigation(events: NostrEvent[]) {
const { id: focused, focus: setFocus } = useContext(FocusedContext);
// VIM controls
const navigatePrev = () => {
const focusedEvent = events.find((e) => e.id === focused);
if (focusedEvent) {
const i = events.indexOf(focusedEvent);
if (i >= 1) {
const prev = events[i - 1];
if (prev) setFocus(prev.id);
}
}
};
const navigatePrevUnread = () => {
const focusedEvent = events.find((e) => e.id === focused);
const idx = focusedEvent ? events.indexOf(focusedEvent) : 0;
for (let i = idx; i >= 0; i--) {
if (readStatusService.getStatus(events[i].id).value === false) {
setFocus(events[i].id);
break;
}
}
};
const navigateNext = () => {
const focusedEvent = events.find((e) => e.id === focused);
const i = focusedEvent ? events.indexOf(focusedEvent) : -1;
if (i < events.length - 2) {
const next = events[i + 1];
if (next) setFocus(next.id);
}
};
const navigateNextUnread = () => {
const focusedEvent = events.find((e) => e.id === focused);
const idx = focusedEvent ? events.indexOf(focusedEvent) : 0;
for (let i = idx; i < events.length; i++) {
if (readStatusService.getStatus(events[i].id).value === false) {
setFocus(events[i].id);
break;
}
}
};
const navigateTop = () => setFocus(events[0]?.id ?? "");
const navigateEnd = () => setFocus(events[events.length - 1]?.id ?? "");
useKeyPressNav("ArrowUp", navigatePrev);
useKeyPressNav("ArrowDown", navigateNext);
useKeyPressNav("ArrowLeft", navigatePrevUnread);
useKeyPressNav("ArrowRight", navigateNextUnread);
useKeyPressNav("k", navigatePrev);
useKeyPressNav("h", navigatePrevUnread);
useKeyPressNav("j", navigateNext);
useKeyPressNav("l", navigateNextUnread);
useKeyPressNav("H", navigateTop);
useKeyPressNav("Home", navigateTop);
useKeyPressNav("L", navigateEnd);
useKeyPressNav("End", navigateEnd);
}

View File

@@ -30,7 +30,7 @@ export default function UserNotesTab() {
[showReplies.isOpen, showReposts.isOpen, timelineEventFilter], [showReplies.isOpen, showReposts.isOpen, timelineEventFilter],
); );
const timeline = useTimelineLoader( const timeline = useTimelineLoader(
truncatedId(pubkey) + "-notes", pubkey + "-notes",
readRelays, readRelays,
{ {
authors: [pubkey], authors: [pubkey],