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 // code split search view because QrScanner library is 400kB
const SearchView = React.lazy(() => import("./views/search")); const SearchView = React.lazy(() => import("./views/search"));
const RootPage = () => { const RootPage = () => (
console.log(useLocation()); <Page>
<ScrollRestoration />
return ( <Suspense fallback={<Spinner />}>
<Page> <Outlet />
<ScrollRestoration /> </Suspense>
<Suspense fallback={<Spinner />}> </Page>
<Outlet /> );
</Suspense>
</Page>
);
};
const router = createHashRouter([ const router = createHashRouter([
{ {

View File

@ -77,8 +77,8 @@ class RelayTimelineLoader {
export class TimelineLoader { export class TimelineLoader {
cursor = dayjs().unix(); cursor = dayjs().unix();
query: NostrQuery; query?: NostrQuery;
relays: string[]; relays: string[] = [];
events = new PersistentSubject<NostrEvent[]>([]); events = new PersistentSubject<NostrEvent[]>([]);
timeline = new PersistentSubject<NostrEvent[]>([]); timeline = new PersistentSubject<NostrEvent[]>([]);
@ -92,14 +92,9 @@ export class TimelineLoader {
private relayTimelineLoaders = new Map<string, RelayTimelineLoader>(); private relayTimelineLoaders = new Map<string, RelayTimelineLoader>();
constructor(relays: string[], query: NostrQuery, name?: string) { constructor(name?: string) {
this.query = query; this.subscription = new NostrMultiSubscription([], undefined, name);
this.relays = relays;
this.subscription = new NostrMultiSubscription(relays, { ...query, limit: BLOCK_SIZE / 2 }, name);
this.subscription.onEvent.subscribe(this.handleEvent, this); this.subscription.onEvent.subscribe(this.handleEvent, this);
this.createLoaders();
} }
private seenEvents = new Set<string>(); private seenEvents = new Set<string>();
@ -115,6 +110,8 @@ export class TimelineLoader {
} }
private createLoaders() { private createLoaders() {
if (!this.query) return;
for (const relay of this.relays) { for (const relay of this.relays) {
if (!this.relayTimelineLoaders.has(relay)) { if (!this.relayTimelineLoaders.has(relay)) {
const loader = new RelayTimelineLoader(relay, this.query, this.subscription.name); const loader = new RelayTimelineLoader(relay, this.query, this.subscription.name);
@ -137,6 +134,8 @@ export class TimelineLoader {
} }
setRelays(relays: string[]) { setRelays(relays: string[]) {
if (this.relays.sort().join("|") === relays.sort().join("|")) return;
// remove loaders // remove loaders
this.removeLoaders((loader) => !relays.includes(loader.relay)); this.removeLoaders((loader) => !relays.includes(loader.relay));
@ -147,6 +146,8 @@ export class TimelineLoader {
this.updateComplete(); this.updateComplete();
} }
setQuery(query: NostrQuery) { setQuery(query: NostrQuery) {
if (JSON.stringify(this.query) === JSON.stringify(query)) return;
this.removeLoaders(); this.removeLoaders();
this.query = query; 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 { Avatar, AvatarProps } from "@chakra-ui/react";
import { useUserMetadata } from "../hooks/use-user-metadata"; import { useUserMetadata } from "../hooks/use-user-metadata";
import { useAsync } from "react-use"; import { useAsync } from "react-use";
import { getIdenticon } from "../services/identicon"; import { getIdenticon } from "../helpers/identicon";
import { safeUrl } from "../helpers/parse"; import { safeUrl } from "../helpers/parse";
import appSettings from "../services/app-settings"; import appSettings from "../services/app-settings";
import useSubject from "../hooks/use-subject"; 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); return event.kind === 6 || (match && match[0].length === event.content.length);
} }
export function truncatedId(id: string, keep = 6) { export function truncatedId(str: string, keep = 6) {
return id.substring(0, keep) + "..." + id.substring(id.length - keep); 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 { useUnmount } from "react-use";
import { TimelineLoader } from "../classes/timeline-loader";
import { NostrQuery } from "../types/nostr-query"; import { NostrQuery } from "../types/nostr-query";
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import timelineCacheService from "../services/timeline-cache";
type Options = { type Options = {
enabled?: boolean; enabled?: boolean;
eventFilter?: (event: NostrEvent) => boolean; eventFilter?: (event: NostrEvent) => boolean;
cursor?: number; cursor?: number;
name?: string;
}; };
export function useTimelineLoader(key: string, relays: string[], query: NostrQuery, opts?: Options) { export function useTimelineLoader(key: string, relays: string[], query: NostrQuery, opts?: Options) {
if (opts && !opts.name) opts.name = key; const timeline = useMemo(() => timelineCacheService.createTimeline(key), [key]);
const ref = useRef<TimelineLoader | null>(null);
const loader = (ref.current = ref.current || new TimelineLoader(relays, query, opts?.name));
useEffect(() => { useEffect(() => {
loader.setQuery(query); timeline.setQuery(query);
}, [JSON.stringify(query)]); }, [timeline, JSON.stringify(query)]);
useEffect(() => { useEffect(() => {
loader.setRelays(relays); timeline.setRelays(relays);
}, [relays.join("|")]); }, [timeline, relays.join("|")]);
useEffect(() => { useEffect(() => {
loader.setFilter(opts?.eventFilter); timeline.setFilter(opts?.eventFilter);
}, [opts?.eventFilter]); }, [timeline, opts?.eventFilter]);
useEffect(() => { useEffect(() => {
if (opts?.cursor !== undefined) { if (opts?.cursor !== undefined) {
loader.setCursor(opts.cursor); timeline.setCursor(opts.cursor);
} }
}, [opts?.cursor]); }, [timeline, opts?.cursor]);
const enabled = opts?.enabled ?? true; const enabled = opts?.enabled ?? true;
useEffect(() => { useEffect(() => {
if (enabled) { if (enabled) {
loader.setQuery(query); timeline.setQuery(query);
loader.open(); timeline.open();
} else loader.close(); } else timeline.close();
}, [enabled]); }, [timeline, enabled]);
useUnmount(() => { 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] [showReplies]
); );
const timeline = useTimelineLoader( const timeline = useTimelineLoader(
[`global`, ...selectedRelay].join(","), [`global`, selectedRelay].join(","),
selectedRelay ? [selectedRelay] : [], selectedRelay ? [selectedRelay] : [],
{ kinds: [1] }, { kinds: [1] },
{ eventFilter } { 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 { Box, Flex, Grid, IconButton } from "@chakra-ui/react";
import { useNavigate, useOutletContext } from "react-router-dom"; import { useNavigate, useOutletContext } from "react-router-dom";
import { useMount, useUnmount } from "react-use";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { matchImageUrls } from "../../helpers/regexp"; import { matchImageUrls } from "../../helpers/regexp";
import { useIsMobile } from "../../hooks/use-is-mobile"; import { useIsMobile } from "../../hooks/use-is-mobile";
@ -9,11 +8,12 @@ import { ImageGalleryLink, ImageGalleryProvider } from "../../components/image-g
import { ExternalLinkIcon } from "../../components/icons"; import { ExternalLinkIcon } from "../../components/icons";
import { getSharableNoteId } from "../../helpers/nip19"; import { getSharableNoteId } from "../../helpers/nip19";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import userTimelineService from "../../services/user-timeline";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status"; import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer"; import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; 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 }; type ImagePreview = { eventId: string; src: string; index: number };
const matchAllImages = new RegExp(matchImageUrls, "ig"); const matchAllImages = new RegExp(matchImageUrls, "ig");
@ -49,22 +49,19 @@ const UserMediaTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string }; const { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext(); const contextRelays = useAdditionalRelayContext();
const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]);
const eventFilter = useCallback((e: NostrEvent) => e.kind === 1 && !!e.content.match(matchAllImages), []); const eventFilter = useCallback((e: NostrEvent) => e.kind === 1 && !!e.content.match(matchAllImages), []);
useEffect(() => { const timeline = useTimelineLoader(
timeline.setFilter(eventFilter); truncatedId(pubkey) + "-notes",
}, [timeline, eventFilter]); contextRelays,
{
authors: [pubkey],
kinds: [1, 6],
},
{ eventFilter }
);
const events = useSubject(timeline.timeline); const events = useSubject(timeline.timeline);
useEffect(() => {
timeline.setRelays(contextRelays);
}, [timeline, contextRelays.join("|")]);
useMount(() => timeline.open());
useUnmount(() => timeline.close());
const images = useMemo(() => { const images = useMemo(() => {
var images: { eventId: string; src: string; index: number }[] = []; 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 { Flex, FormControl, FormLabel, Switch, useDisclosure } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { Note } from "../../components/note"; import { isReply, isRepost, truncatedId } from "../../helpers/nostr-event";
import RepostNote from "../../components/repost-note";
import { isReply, isRepost } from "../../helpers/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; 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 { RelayIconStack } from "../../components/relay-icon-stack";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status"; import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer"; import IntersectionObserverProvider from "../../providers/intersection-observer";
import { TimelineLoader } from "../../classes/timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/generric-note-timeline"; import GenericNoteTimeline from "../../components/generric-note-timeline";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
const UserNotesTab = () => { const UserNotesTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string }; const { pubkey } = useOutletContext() as { pubkey: string };
@ -23,9 +18,6 @@ const UserNotesTab = () => {
const { isOpen: showReplies, onToggle: toggleReplies } = useDisclosure(); const { isOpen: showReplies, onToggle: toggleReplies } = useDisclosure();
const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure(); const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure();
const scrollBox = useRef<HTMLDivElement | null>(null);
const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]);
const eventFilter = useCallback( const eventFilter = useCallback(
(event: NostrEvent) => { (event: NostrEvent) => {
if (!showReplies && isReply(event)) return false; if (!showReplies && isReply(event)) return false;
@ -34,16 +26,17 @@ const UserNotesTab = () => {
}, },
[showReplies, hideReposts] [showReplies, hideReposts]
); );
useEffect(() => { const timeline = useTimelineLoader(
timeline.setFilter(eventFilter); truncatedId(pubkey) + "-notes",
}, [timeline, eventFilter]); readRelays,
useEffect(() => { {
timeline.setRelays(readRelays); authors: [pubkey],
}, [timeline, readRelays.join("|")]); kinds: [1, 6],
},
useMount(() => timeline.open()); { eventFilter }
useUnmount(() => timeline.close()); );
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline); const callback = useTimelineCurserIntersectionCallback(timeline);
return ( return (