cache timelines

This commit is contained in:
hzrd149 2023-06-30 14:30:53 -05:00
parent 038d342ad1
commit 7a339ae03d
13 changed files with 102 additions and 123 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
cache timelines

View File

@ -37,18 +37,14 @@ import UserAboutTab from "./views/user/about";
// code split search view because QrScanner library is 400kB
const SearchView = React.lazy(() => import("./views/search"));
const RootPage = () => {
console.log(useLocation());
return (
<Page>
<ScrollRestoration />
<Suspense fallback={<Spinner />}>
<Outlet />
</Suspense>
</Page>
);
};
const RootPage = () => (
<Page>
<ScrollRestoration />
<Suspense fallback={<Spinner />}>
<Outlet />
</Suspense>
</Page>
);
const router = createHashRouter([
{

View File

@ -77,8 +77,8 @@ class RelayTimelineLoader {
export class TimelineLoader {
cursor = dayjs().unix();
query: NostrQuery;
relays: string[];
query?: NostrQuery;
relays: string[] = [];
events = new PersistentSubject<NostrEvent[]>([]);
timeline = new PersistentSubject<NostrEvent[]>([]);
@ -92,14 +92,9 @@ export class TimelineLoader {
private relayTimelineLoaders = new Map<string, RelayTimelineLoader>();
constructor(relays: string[], query: NostrQuery, name?: string) {
this.query = query;
this.relays = relays;
this.subscription = new NostrMultiSubscription(relays, { ...query, limit: BLOCK_SIZE / 2 }, name);
constructor(name?: string) {
this.subscription = new NostrMultiSubscription([], undefined, name);
this.subscription.onEvent.subscribe(this.handleEvent, this);
this.createLoaders();
}
private seenEvents = new Set<string>();
@ -115,6 +110,8 @@ export class TimelineLoader {
}
private createLoaders() {
if (!this.query) return;
for (const relay of this.relays) {
if (!this.relayTimelineLoaders.has(relay)) {
const loader = new RelayTimelineLoader(relay, this.query, this.subscription.name);
@ -137,6 +134,8 @@ export class TimelineLoader {
}
setRelays(relays: string[]) {
if (this.relays.sort().join("|") === relays.sort().join("|")) return;
// remove loaders
this.removeLoaders((loader) => !relays.includes(loader.relay));
@ -147,6 +146,8 @@ export class TimelineLoader {
this.updateComplete();
}
setQuery(query: NostrQuery) {
if (JSON.stringify(this.query) === JSON.stringify(query)) return;
this.removeLoaders();
this.query = query;

View File

@ -1,8 +0,0 @@
import { truncatedId } from "../helpers/nostr-event";
import { TimelineLoader } from "./timeline-loader";
export default class UserTimeline extends TimelineLoader {
constructor(pubkey: string) {
super([], { authors: [pubkey], kinds: [1, 6] }, truncatedId(pubkey) + "-timeline");
}
}

View File

@ -2,7 +2,7 @@ import React, { useMemo } from "react";
import { Avatar, AvatarProps } from "@chakra-ui/react";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { useAsync } from "react-use";
import { getIdenticon } from "../services/identicon";
import { getIdenticon } from "../helpers/identicon";
import { safeUrl } from "../helpers/parse";
import appSettings from "../services/app-settings";
import useSubject from "../hooks/use-subject";

View File

@ -17,8 +17,9 @@ export function isRepost(event: NostrEvent | DraftNostrEvent) {
return event.kind === 6 || (match && match[0].length === event.content.length);
}
export function truncatedId(id: string, keep = 6) {
return id.substring(0, keep) + "..." + id.substring(id.length - keep);
export function truncatedId(str: string, keep = 6) {
if (str.length < keep * 2 + 3) return str;
return str.substring(0, keep) + "..." + str.substring(str.length - keep);
}
/**

View File

@ -1,48 +1,44 @@
import { useEffect, useRef } from "react";
import { useEffect, useMemo } from "react";
import { useUnmount } from "react-use";
import { TimelineLoader } from "../classes/timeline-loader";
import { NostrQuery } from "../types/nostr-query";
import { NostrEvent } from "../types/nostr-event";
import timelineCacheService from "../services/timeline-cache";
type Options = {
enabled?: boolean;
eventFilter?: (event: NostrEvent) => boolean;
cursor?: number;
name?: string;
};
export function useTimelineLoader(key: string, relays: string[], query: NostrQuery, opts?: Options) {
if (opts && !opts.name) opts.name = key;
const ref = useRef<TimelineLoader | null>(null);
const loader = (ref.current = ref.current || new TimelineLoader(relays, query, opts?.name));
const timeline = useMemo(() => timelineCacheService.createTimeline(key), [key]);
useEffect(() => {
loader.setQuery(query);
}, [JSON.stringify(query)]);
timeline.setQuery(query);
}, [timeline, JSON.stringify(query)]);
useEffect(() => {
loader.setRelays(relays);
}, [relays.join("|")]);
timeline.setRelays(relays);
}, [timeline, relays.join("|")]);
useEffect(() => {
loader.setFilter(opts?.eventFilter);
}, [opts?.eventFilter]);
timeline.setFilter(opts?.eventFilter);
}, [timeline, opts?.eventFilter]);
useEffect(() => {
if (opts?.cursor !== undefined) {
loader.setCursor(opts.cursor);
timeline.setCursor(opts.cursor);
}
}, [opts?.cursor]);
}, [timeline, opts?.cursor]);
const enabled = opts?.enabled ?? true;
useEffect(() => {
if (enabled) {
loader.setQuery(query);
loader.open();
} else loader.close();
}, [enabled]);
timeline.setQuery(query);
timeline.open();
} else timeline.close();
}, [timeline, enabled]);
useUnmount(() => {
loader.close();
timeline.close();
});
return loader;
return timeline;
}

View File

@ -0,0 +1,32 @@
import { TimelineLoader } from "../classes/timeline-loader";
const MAX_CACHE = 4;
class TimelineCacheService {
private timelines = new Map<string, TimelineLoader>();
private cacheQueue: string[] = [];
createTimeline(key: string) {
let timeline = this.timelines.get(key);
if (!timeline) {
timeline = new TimelineLoader(key);
this.timelines.set(key, timeline);
}
this.cacheQueue = this.cacheQueue.filter((p) => p !== key).concat(key);
while (this.cacheQueue.length > MAX_CACHE) {
this.cacheQueue.shift();
}
return timeline;
}
}
const timelineCacheService = new TimelineCacheService();
if (import.meta.env.DEV) {
//@ts-ignore
window.timelineCacheService = timelineCacheService;
}
export default timelineCacheService;

View File

@ -1,33 +0,0 @@
import UserTimeline from "../classes/user-timeline";
const MAX_CACHE = 4;
class UserTimelineService {
timelines = new Map<string, UserTimeline>();
cacheQueue: string[] = [];
getTimeline(pubkey: string) {
let timeline = this.timelines.get(pubkey);
if (!timeline) {
timeline = new UserTimeline(pubkey);
this.timelines.set(pubkey, timeline);
}
this.cacheQueue = this.cacheQueue.filter((p) => p !== pubkey).concat(pubkey);
while (this.cacheQueue.length > MAX_CACHE) {
this.cacheQueue.shift();
}
return timeline;
}
}
const userTimelineService = new UserTimelineService();
if (import.meta.env.DEV) {
//@ts-ignore
window.userTimelineService = userTimelineService;
}
export default userTimelineService;

View File

@ -33,9 +33,8 @@ export default function GlobalTab() {
},
[showReplies]
);
const timeline = useTimelineLoader(
[`global`, ...selectedRelay].join(","),
[`global`, selectedRelay].join(","),
selectedRelay ? [selectedRelay] : [],
{ kinds: [1] },
{ eventFilter }

View File

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import React, { useCallback, useMemo, useRef } from "react";
import { Box, Flex, Grid, IconButton } from "@chakra-ui/react";
import { useNavigate, useOutletContext } from "react-router-dom";
import { useMount, useUnmount } from "react-use";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { matchImageUrls } from "../../helpers/regexp";
import { useIsMobile } from "../../hooks/use-is-mobile";
@ -9,11 +8,12 @@ import { ImageGalleryLink, ImageGalleryProvider } from "../../components/image-g
import { ExternalLinkIcon } from "../../components/icons";
import { getSharableNoteId } from "../../helpers/nip19";
import useSubject from "../../hooks/use-subject";
import userTimelineService from "../../services/user-timeline";
import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { truncatedId } from "../../helpers/nostr-event";
type ImagePreview = { eventId: string; src: string; index: number };
const matchAllImages = new RegExp(matchImageUrls, "ig");
@ -49,22 +49,19 @@ const UserMediaTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext();
const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]);
const eventFilter = useCallback((e: NostrEvent) => e.kind === 1 && !!e.content.match(matchAllImages), []);
useEffect(() => {
timeline.setFilter(eventFilter);
}, [timeline, eventFilter]);
const timeline = useTimelineLoader(
truncatedId(pubkey) + "-notes",
contextRelays,
{
authors: [pubkey],
kinds: [1, 6],
},
{ eventFilter }
);
const events = useSubject(timeline.timeline);
useEffect(() => {
timeline.setRelays(contextRelays);
}, [timeline, contextRelays.join("|")]);
useMount(() => timeline.open());
useUnmount(() => timeline.close());
const images = useMemo(() => {
var images: { eventId: string; src: string; index: number }[] = [];

View File

@ -1,20 +1,15 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useRef } from "react";
import { Flex, FormControl, FormLabel, Switch, useDisclosure } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom";
import { Note } from "../../components/note";
import RepostNote from "../../components/repost-note";
import { isReply, isRepost } from "../../helpers/nostr-event";
import { isReply, isRepost, truncatedId } from "../../helpers/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import userTimelineService from "../../services/user-timeline";
import useSubject from "../../hooks/use-subject";
import { useMount, useUnmount } from "react-use";
import { RelayIconStack } from "../../components/relay-icon-stack";
import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { TimelineLoader } from "../../classes/timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/generric-note-timeline";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
const UserNotesTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
@ -23,9 +18,6 @@ const UserNotesTab = () => {
const { isOpen: showReplies, onToggle: toggleReplies } = useDisclosure();
const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure();
const scrollBox = useRef<HTMLDivElement | null>(null);
const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]);
const eventFilter = useCallback(
(event: NostrEvent) => {
if (!showReplies && isReply(event)) return false;
@ -34,16 +26,17 @@ const UserNotesTab = () => {
},
[showReplies, hideReposts]
);
useEffect(() => {
timeline.setFilter(eventFilter);
}, [timeline, eventFilter]);
useEffect(() => {
timeline.setRelays(readRelays);
}, [timeline, readRelays.join("|")]);
useMount(() => timeline.open());
useUnmount(() => timeline.close());
const timeline = useTimelineLoader(
truncatedId(pubkey) + "-notes",
readRelays,
{
authors: [pubkey],
kinds: [1, 6],
},
{ eventFilter }
);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (