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;
if (!this.relay.connected) {
this.log("relay not connected, aborting");
this.log("requesting relay connection");
relayPoolService.requestConnect(this.relay);
this.loading = false;
return;
}

View File

@ -13,13 +13,16 @@ export default class PersistentSubscription {
process: Process;
relay: Relay;
filters: Filter[];
closed = true;
connecting = false;
params: Partial<SubscriptionParams>;
subscription: Subscription | null = null;
get eosed() {
return !!this.subscription?.eosed;
}
get closed() {
return !this.subscription || this.subscription.closed;
}
constructor(relay: AbstractRelay, params?: Partial<SubscriptionParams>) {
this.id = nanoid(8);
@ -40,15 +43,21 @@ export default class PersistentSubscription {
/** attempts to update the subscription */
async update() {
if (!this.filters || this.filters.length === 0) throw new Error("Missing filters");
if (!(await relayPoolService.waitForOpen(this.relay))) throw new Error("Failed to connect to relay");
if (this.connecting) throw new Error("Cant update while connecting");
// check if its possible to subscribe to this relay
if (!relayPoolService.canSubscribe(this.relay)) throw new Error("Cant subscribe to relay");
this.closed = false;
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
if (!this.subscription || this.subscription.closed) {
this.subscription = this.relay.subscribe(this.filters, {
@ -60,7 +69,6 @@ export default class PersistentSubscription {
if (!this.closed) {
relayPoolService.handleRelayNotice(this.relay, reason);
this.closed = true;
this.process.active = false;
}
this.params.onclose?.(reason);
@ -74,9 +82,6 @@ export default class PersistentSubscription {
} else throw new Error("Subscription filters have not changed");
}
close() {
if (this.closed) return this;
this.closed = true;
if (this.subscription?.closed === false) this.subscription.close();
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 { NostrEvent } from "nostr-tools";
import { getEventUID } from "nostr-idb";
import dayjs from "dayjs";
import { useLocation } from "react-router-dom";
import useSubject from "../../../hooks/use-subject";
import TimelineLoader from "../../../classes/timeline-loader";
import { NostrEvent } from "../../../types/nostr-event";
import { getEventUID } from "../../../helpers/nostr/event";
import {
ExtendedIntersectionObserverEntry,
useIntersectionObserver,
} from "../../../providers/local/intersection-observer";
import useNumberCache from "../../../hooks/timeline/use-number-cache";
import useCacheEntryHeight from "../../../hooks/timeline/use-cache-entry-height";
import { useTimelineDates } from "../../../hooks/timeline/use-timeline-dates";
import useTimelineLocationCacheKey from "../../../hooks/timeline/use-timeline-cache-key";
import TimelineItem from "./timeline-item";
const INITIAL_NOTES = 10;
const NOTE_BUFFER = 5;
const timelineNoteMinHeightCache = new WeakMap<TimelineLoader, Record<string, Record<string, number>>>();
function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) {
const events = useSubject(timeline.timeline);
const [latest, setLatest] = useState(() => dayjs().unix());
const location = useLocation();
// only update the location key when the timeline changes
// this fixes an issue with the key changes when the drawer opens
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 cacheKey = useTimelineLocationCacheKey();
const numberCache = useNumberCache(cacheKey);
const dates = useTimelineDates(timeline, numberCache, NOTE_BUFFER, INITIAL_NOTES);
const [pinDate, setPinDate] = useState(getCachedNumber("pin") ?? events[NOTE_BUFFER]?.created_at ?? Infinity);
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,
]);
// measure and cache the hight of every entry
useCacheEntryHeight(numberCache.set);
const newNotes: NostrEvent[] = [];
const notes: NostrEvent[] = [];
for (const note of events) {
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 (
@ -174,8 +52,8 @@ function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) {
<TimelineItem
key={note.id}
event={note}
visible={note.created_at <= maxDate && note.created_at >= minDate}
minHeight={getCachedNumber(getEventUID(note))}
visible={note.created_at <= dates.max && note.created_at >= dates.min}
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 { 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) {
const ref = useRef<T | null>(null);
useRegisterIntersectionEntity(ref, event ? getEventUID(event) : undefined);
useIntersectionEntityDetails(ref, event ? getEventUID(event) : undefined, event?.created_at);
return ref;
}

View File

@ -2,7 +2,7 @@ import { useInterval } from "react-use";
import { NostrEvent } from "nostr-tools";
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) {
// 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();
}, 1000);
return useIntersectionMapCallback(
return useCachedIntersectionMapCallback(
(map) => {
// find oldest event that is visible
let oldestEvent: NostrEvent | undefined = undefined;
for (const [id, intersection] of map) {
if (!intersection.isIntersecting) continue;
for (const [id, entry] of map) {
if (!entry.isIntersecting) continue;
const event = timeline.events.getEvent(id);
if (!event) continue;
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";
export type ExtendedIntersectionObserverEntry = { entry: IntersectionObserverEntry; id: string | undefined };
export type ExtendedIntersectionObserverCallback = (
entries: ExtendedIntersectionObserverEntry[],
observer: IntersectionObserver,
) => void;
const IntersectionObserverContext = createContext<{
observer?: IntersectionObserver;
setElementId: (element: Element, id: any) => void;
// NOTE: hard codded string type
subject: Subject<ExtendedIntersectionObserverEntry[]>;
}>({ setElementId: () => {}, subject: new Subject() });
subject: Subject<IntersectionObserverEntry[]>;
}>({ subject: new Subject() });
export function useIntersectionObserver() {
return useContext(IntersectionObserverContext);
}
export function useRegisterIntersectionEntity(ref: MutableRefObject<Element | null>, id?: string) {
const { observer, setElementId } = useIntersectionObserver();
export function getEntryDetails(entry: IntersectionObserverEntry) {
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(() => {
if (observer && ref.current) {
observer.observe(ref.current);
if (id) {
setElementId(ref.current, id);
if (import.meta.env.DEV) ref.current.setAttribute("data-event-id", id);
}
if (id) ref.current.dataset.id = id;
if (date) ref.current.dataset.ts = String(date);
}
}, [observer]);
useUnmount(() => {
if (observer && ref.current) observer.unobserve(ref.current);
});
}
/** @deprecated */
export function useIntersectionMapCallback(
export function useCachedIntersectionMapCallback(
callback: (map: Map<string, IntersectionObserverEntry>) => void,
watch: DependencyList,
) {
const map = useMemo(() => new Map<string, IntersectionObserverEntry>(), []);
return useCallback<ExtendedIntersectionObserverCallback>(
(entries) => {
for (const { id, entry } of entries) id && map.set(id, entry);
callback(map);
const cache = useMemo(() => new Map<string, IntersectionObserverEntry>(), []);
return useCallback<IntersectionObserverCallback>(
(entries, observer) => {
for (const entry of entries) {
const details = getEntryDetails(entry);
if (details?.id) cache.set(details.id, entry);
}
callback(cache);
},
[callback, ...watch],
);
@ -72,21 +75,16 @@ export default function IntersectionObserverProvider({
root?: MutableRefObject<HTMLElement | null>;
rootMargin?: IntersectionObserverInit["rootMargin"];
threshold?: IntersectionObserverInit["threshold"];
callback: ExtendedIntersectionObserverCallback;
callback: IntersectionObserverCallback;
}) {
const elementIds = useMemo(() => new WeakMap<Element, string>(), []);
const [subject] = useState(() => new Subject<ExtendedIntersectionObserverEntry[]>([]));
const [subject] = useState(() => new Subject<IntersectionObserverEntry[]>([]));
const handleIntersection = useCallback<IntersectionObserverCallback>(
(entries, observer) => {
const extendedEntries = entries.map((entry) => {
return { entry, id: elementIds.get(entry.target) };
});
callback(extendedEntries, observer);
subject.next(extendedEntries);
callback(entries, observer);
subject.next(entries);
},
[subject],
[subject, callback],
);
const [observer, setObserver] = useState<IntersectionObserver>(
@ -103,20 +101,12 @@ export default function IntersectionObserverProvider({
if (observer) observer.disconnect();
});
const setElementId = useCallback(
(element: Element, id: string) => {
elementIds.set(element, id);
},
[elementIds],
);
const context = useMemo(
() => ({
observer,
setElementId,
subject,
}),
[observer, setElementId, subject],
[observer, subject],
);
return <IntersectionObserverContext.Provider value={context}>{children}</IntersectionObserverContext.Provider>;

View File

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

View File

@ -40,9 +40,9 @@ export default function NotificationsCard({ ...props }: Omit<CardProps, "childre
</Heading>
<KeyboardShortcut letter="i" requireMeta ml="auto" onPress={() => navigate("/notifications")} />
</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) => (
<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">
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 dayjs from "dayjs";
@ -32,10 +32,12 @@ const NotificationIconEntry = memo(
const focusSelf = useCallback(() => focus(id), [id, focus]);
// scroll element to stop when opened
const headerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (expanded) {
// @ts-expect-error
ref.current?.scrollIntoView();
setTimeout(() => {
headerRef.current?.scrollIntoView();
}, 2);
}
}, [expanded]);
@ -45,11 +47,17 @@ const NotificationIconEntry = memo(
}, [read, expanded]);
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
gap="2"
alignItems="center"
ref={ref}
cursor="pointer"
p="2"
tabIndex={0}
@ -57,6 +65,8 @@ const NotificationIconEntry = memo(
onClick={onClick}
userSelect="none"
bg={!read ? focusColor : undefined}
ref={headerRef}
overflow="hidden"
>
<Box>{icon}</Box>
<UserAvatar pubkey={pubkey} size="sm" />

View File

@ -1,5 +1,15 @@
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 { 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 handleClick = useCallback(() => {
@ -232,32 +251,37 @@ const NotificationItem = ({ event, onClick }: { event: CategorizedEvent; onClick
}, [onClick, event]);
let content: ReactNode | null = null;
switch (event[typeSymbol]) {
case NotificationType.Reply:
content = <ReplyNotification event={event} onClick={onClick && handleClick} ref={ref} />;
break;
case NotificationType.Mention:
content = <MentionNotification event={event} onClick={onClick && handleClick} ref={ref} />;
break;
case NotificationType.Reaction:
content = <ReactionNotification event={event} onClick={onClick && handleClick} ref={ref} />;
break;
case NotificationType.Repost:
content = <RepostNotification event={event} onClick={onClick && handleClick} ref={ref} />;
break;
case NotificationType.Zap:
content = <ZapNotification event={event} onClick={onClick && handleClick} ref={ref} />;
break;
default:
content = <EmbeddedUnknown event={event} />;
break;
if (visible) {
switch (event[typeSymbol]) {
case NotificationType.Reply:
content = <ReplyNotification event={event} onClick={onClick && handleClick} />;
break;
case NotificationType.Mention:
content = <MentionNotification event={event} onClick={onClick && handleClick} />;
break;
case NotificationType.Reaction:
content = <ReactionNotification event={event} onClick={onClick && handleClick} />;
break;
case NotificationType.Repost:
content = <RepostNotification event={event} onClick={onClick && handleClick} />;
break;
case NotificationType.Zap:
content = <ZapNotification event={event} onClick={onClick && handleClick} />;
break;
default:
content = <EmbeddedUnknown event={event} />;
break;
}
}
return (
content && (
<ErrorBoundary>
<TrustProvider event={event}>{content}</TrustProvider>
</ErrorBoundary>
)
<Flex ref={ref} overflow="hidden" flexShrink={0} {...props}>
{content && (
<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 { Link as RouterLink } from "react-router-dom";
import dayjs, { Dayjs } from "dayjs";
import { getEventUID } from "nostr-idb";
import RequireCurrentAccount from "../../providers/route/require-current-account";
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 NotificationTypeToggles from "./notification-type-toggles";
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 FocusedContext from "./focused-context";
import useRouteStateValue from "../../hooks/use-route-state-value";
import readStatusService from "../../services/read-status";
import useKeyPressNav from "../../hooks/use-key-press-nav";
// const DATE_FORMAT = "YYYY-MM-DD";
import useTimelineLocationCacheKey from "../../hooks/timeline/use-timeline-cache-key";
import useNumberCache from "../../hooks/timeline/use-number-cache";
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[] }) {
const readAll = useCallback(() => {
@ -42,14 +45,12 @@ function TimeMarker({ date, ids }: { date: Dayjs; ids: string[] }) {
const NotificationsTimeline = memo(
({
// day,
showReplies,
showMentions,
showZaps,
showReposts,
showReactions,
}: {
// day: string;
showReplies: boolean;
showMentions: boolean;
showZaps: boolean;
@ -58,107 +59,45 @@ const NotificationsTimeline = memo(
}) => {
const { notifications } = useNotifications();
const { people } = usePeopleListContext();
const { id: focused, focus: setFocus } = useContext(FocusedContext);
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 filteredEvents = useMemo(
() =>
events.filter((e) => {
// if (e.created_at < minTimestamp || e.created_at > maxTimestamp) return false;
const cacheKey = useTimelineLocationCacheKey();
const numberCache = useNumberCache(cacheKey);
if (e[typeSymbol] === NotificationType.Zap) {
if (!showZaps) return false;
if (peoplePubkeys && !peoplePubkeys.includes(e.pubkey)) return false;
}
const minItems = Math.round(window.innerHeight / 48);
const dates = useTimelineDates(events, numberCache, minItems / 2, minItems);
if (!showReplies && e[typeSymbol] === NotificationType.Reply) return false;
if (!showMentions && e[typeSymbol] === NotificationType.Mention) return false;
if (!showReactions && e[typeSymbol] === NotificationType.Reaction) return false;
if (!showReposts && e[typeSymbol] === NotificationType.Repost) return false;
if (!showZaps && e[typeSymbol] === NotificationType.Zap) return false;
// measure and cache the hight of every entry
useCacheEntryHeight(numberCache.set);
return true;
}),
[
events,
peoplePubkeys,
showReplies,
showMentions,
showReactions,
showReposts,
showZaps,
// minTimestamp,
// maxTimestamp,
],
);
const filtered: CategorizedEvent[] = [];
for (const event of events) {
if (event.created_at < dates.cursor && filtered.length > minItems) continue;
const type = event[typeSymbol];
if (type === NotificationType.Zap) {
if (!showZaps) continue;
if (peoplePubkeys && !peoplePubkeys.includes(event.pubkey)) continue;
}
if (!showReplies && type === NotificationType.Reply) continue;
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
const navigatePrev = () => {
const focusedEvent = filteredEvents.find((e) => e.id === focused);
useVimNavigation(filtered);
if (focusedEvent) {
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)
if (filtered.length === 0)
return (
<Flex alignItems="center" justifyContent="center" minH="25vh" fontWeight="bold" fontSize="4xl">
Nothing...
Loading...
</Flex>
);
@ -166,7 +105,7 @@ const NotificationsTimeline = memo(
let prev = dayjs();
let ids: string[] = [];
for (const event of filteredEvents) {
for (const event of filtered) {
// insert markers at every day
if (prev.diff(dayjs.unix(event.created_at), "d") > 0) {
prev = dayjs.unix(event.created_at);
@ -176,17 +115,29 @@ const NotificationsTimeline = memo(
}
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}</>;
},
);
const cachedFocus = new PersistentSubject("");
function NotificationsPage() {
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 showReplies = useLocalStorageDisclosure("notifications-show-replies", true);
@ -195,67 +146,11 @@ function NotificationsPage() {
const showReposts = useLocalStorageDisclosure("notifications-show-reposts", 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);
return (
<VerticalPageLayout>
<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">
<NotificationTypeToggles
showReplies={showReplies}
@ -275,9 +170,8 @@ function NotificationsPage() {
<IntersectionObserverProvider callback={callback}>
<FocusedContext.Provider value={focusContext}>
<Flex direction="column">
<Flex direction="column" overflow="hidden">
<NotificationsTimeline
// day={day}
showReplies={showReplies.isOpen}
showMentions={showMentions.isOpen}
showZaps={showZaps.isOpen}
@ -289,17 +183,6 @@ function NotificationsPage() {
</IntersectionObserverProvider>
<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>
);
}

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],
);
const timeline = useTimelineLoader(
truncatedId(pubkey) + "-notes",
pubkey + "-notes",
readRelays,
{
authors: [pubkey],