mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-11 05:09:36 +02:00
Improve notifications timeline rendering performance
and introduced a few bugs to fix later
This commit is contained in:
parent
fc6d36b593
commit
a7021217dc
5
.changeset/tiny-planets-cheat.md
Normal file
5
.changeset/tiny-planets-cheat.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Improve notifications timeline rendering performance
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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))}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
23
src/hooks/timeline/use-cache-entry-height.ts
Normal file
23
src/hooks/timeline/use-cache-entry-height.ts
Normal 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]);
|
||||
}
|
19
src/hooks/timeline/use-min-number.ts
Normal file
19
src/hooks/timeline/use-min-number.ts
Normal 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;
|
||||
}
|
37
src/hooks/timeline/use-number-cache.ts
Normal file
37
src/hooks/timeline/use-number-cache.ts
Normal 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 };
|
||||
}
|
15
src/hooks/timeline/use-timeline-cache-key.ts
Normal file
15
src/hooks/timeline/use-timeline-cache-key.ts
Normal 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;
|
||||
}
|
31
src/hooks/timeline/use-timeline-dates.ts
Normal file
31
src/hooks/timeline/use-timeline-dates.ts
Normal 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 };
|
||||
}
|
42
src/hooks/timeline/use-timeline-view-dates-buffer.ts
Normal file
42
src/hooks/timeline/use-timeline-view-dates-buffer.ts
Normal 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 };
|
||||
}
|
74
src/hooks/timeline/use-timeline-view-dates.ts
Normal file
74
src/hooks/timeline/use-timeline-view-dates.ts
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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>;
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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" />
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
68
src/views/notifications/use-vim-navigation.ts
Normal file
68
src/views/notifications/use-vim-navigation.ts
Normal 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);
|
||||
}
|
@ -30,7 +30,7 @@ export default function UserNotesTab() {
|
||||
[showReplies.isOpen, showReposts.isOpen, timelineEventFilter],
|
||||
);
|
||||
const timeline = useTimelineLoader(
|
||||
truncatedId(pubkey) + "-notes",
|
||||
pubkey + "-notes",
|
||||
readRelays,
|
||||
{
|
||||
authors: [pubkey],
|
||||
|
Loading…
x
Reference in New Issue
Block a user