simple user timeline caching

This commit is contained in:
hzrd149 2023-06-04 21:59:57 -04:00
parent 0189507d62
commit 4506c82bd0
15 changed files with 173 additions and 102 deletions

View File

@ -1,9 +1,8 @@
import React, { Suspense, useEffect } from "react";
import { createBrowserRouter, Navigate, Outlet, RouterProvider, useLocation } from "react-router-dom";
import { Button, Flex, Spinner, Text, useColorMode } from "@chakra-ui/react";
import { ErrorBoundary, ErrorFallback } from "./components/error-boundary";
import { ErrorBoundary } from "./components/error-boundary";
import { Page } from "./components/page";
import { normalizeToHex } from "./helpers/nip19";
import { deleteDatabase } from "./services/db";
import accountService from "./services/account";
import useSubject from "./hooks/use-subject";
@ -120,7 +119,6 @@ const router = createBrowserRouter([
{ path: "", element: <FollowingTab /> },
{ path: "following", element: <FollowingTab /> },
{ path: "discover", element: <DiscoverTab /> },
// { path: "popular", element: <PopularTab /> },
{ path: "global", element: <GlobalTab /> },
],
},

View File

@ -0,0 +1,80 @@
import moment from "moment";
import { NostrEvent } from "../types/nostr-event";
import { NostrQuery } from "../types/nostr-query";
import { NostrRequest } from "./nostr-request";
import { NostrMultiSubscription } from "./nostr-multi-subscription";
import { PersistentSubject } from "./subject";
import { utils } from "nostr-tools";
import { truncatedId } from "../helpers/nostr-event";
const PAGE_SIZE = moment.duration(1, "week").asSeconds();
export default class UserTimeline {
pubkey: string;
query: NostrQuery;
events = new PersistentSubject<NostrEvent[]>([]);
loading = new PersistentSubject(false);
page = new PersistentSubject(0);
private seenEvents = new Set<string>();
private subscription: NostrMultiSubscription;
constructor(pubkey: string) {
this.pubkey = pubkey;
this.query = { authors: [pubkey], kinds: [1, 6], limit: 20 };
this.subscription = new NostrMultiSubscription([], this.query, truncatedId(pubkey) + "-timeline");
this.subscription.onEvent.subscribe(this.handleEvent, this);
}
setRelays(relays: string[]) {
this.subscription.setRelays(relays);
}
private handleEvent(event: NostrEvent) {
if (!this.seenEvents.has(event.id)) {
this.seenEvents.add(event.id);
this.events.next(utils.insertEventIntoDescendingList(Array.from(this.events.value), event));
if (this.loading.value) this.loading.next(false);
}
}
private getPageDates(page: number) {
const start = this.events.value[0]?.created_at ?? moment().unix();
const until = start - page * PAGE_SIZE;
const since = until - PAGE_SIZE;
return {
until,
since,
};
}
loadMore() {
if (this.loading.value) return;
const query = { ...this.query, ...this.getPageDates(this.page.value) };
const request = new NostrRequest(this.subscription.relayUrls);
request.onEvent.subscribe(this.handleEvent, this);
request.onComplete.then(() => {
this.loading.next(false);
});
request.start(query);
this.loading.next(true);
this.page.next(this.page.value + 1);
}
forgetEvents() {
this.events.next([]);
this.seenEvents.clear();
this.subscription.forgetEvents();
}
open() {
this.subscription.open();
}
close() {
this.subscription.close();
}
}

View File

@ -5,7 +5,7 @@ import { NoteContents } from "./note-contents";
import { NostrEvent } from "../../types/nostr-event";
import { UserAvatarLink } from "../user-avatar-link";
import { UserLink } from "../user-link";
import { UserDnsIdentityIcon } from "../user-dns-identity";
import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
import { convertTimestampToDate } from "../../helpers/date";
import useSubject from "../../hooks/use-subject";

View File

@ -20,7 +20,7 @@ import { NoteMenu } from "./note-menu";
import { NoteRelays } from "./note-relays";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { UserLink } from "../user-link";
import { UserDnsIdentityIcon } from "../user-dns-identity";
import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
import { convertTimestampToDate } from "../../helpers/date";
import ReactionButton from "./buttons/reaction-button";
import NoteZapButton from "./note-zap-button";

View File

@ -7,7 +7,7 @@ import { ErrorFallback } from "../error-boundary";
import { Note } from ".";
import { NoteMenu } from "./note-menu";
import { UserAvatar } from "../user-avatar";
import { UserDnsIdentityIcon } from "../user-dns-identity";
import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
import { UserLink } from "../user-link";
import { unique } from "../../helpers/array";
import { TrustProvider } from "./trust";

View File

@ -1,7 +1,6 @@
import { NostrEvent } from "../types/nostr-event";
import { Bech32Prefix, normalizeToBech32 } from "./nip19";
import { truncatedId } from "./nostr-event";
import { safeJson } from "./parse";
export type Kind0ParsedContent = {
name?: string;
@ -18,7 +17,12 @@ export type Kind0ParsedContent = {
export function parseKind0Event(event: NostrEvent): Kind0ParsedContent {
if (event.kind !== 0) throw new Error("expected a kind 0 event");
try {
return JSON.parse(event.content) as Kind0ParsedContent;
const metadata = JSON.parse(event.content) as Kind0ParsedContent;
// ensure nip05 is a string
if (metadata.nip05 && typeof metadata.nip05 !== "string") metadata.nip05 = String(metadata.nip05);
return metadata;
} catch (e) {}
return {};
}

View File

@ -0,0 +1,33 @@
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

@ -1,55 +0,0 @@
import { useMemo } from "react";
import { Box, Button, Flex, Text } from "@chakra-ui/react";
import moment from "moment";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useThrottle } from "react-use";
import { Kind } from "nostr-tools";
import { parseZapNote } from "../../helpers/zaps";
import { NoteLink } from "../../components/note-link";
export default function PopularTab() {
useAppTitle("popular");
const relays = useReadRelayUrls();
const {
loading,
events: zaps,
loadMore,
} = useTimelineLoader(
"popular-zaps",
relays,
{ since: moment().subtract(1, "hour").unix(), kinds: [Kind.Zap] },
{ pageSize: moment.duration(1, "hour").asSeconds() }
);
const throttledZaps = useThrottle(zaps, 1000);
const groupedZaps = useMemo(() => {
const dir: Record<string, ReturnType<typeof parseZapNote>[]> = {};
for (const zap of throttledZaps) {
try {
const parsed = parseZapNote(zap);
if (!parsed.eventId) continue;
dir[parsed.eventId] = dir[parsed.eventId] || [];
dir[parsed.eventId].push(parsed);
} catch (e) {}
}
return dir;
}, [throttledZaps]);
return (
<Flex direction="column" gap="2">
<Button onClick={() => loadMore()} isLoading={loading}>
Load More
</Button>
{Array.from(Object.entries(groupedZaps)).map(([eventId, parsedZaps]) => (
<Box key={eventId}>
<Text>{parsedZaps.length}</Text>
<NoteLink noteId={eventId} />
</Box>
))}
</Flex>
);
}

View File

@ -16,7 +16,7 @@ import { useSearchParams, Link as RouterLink, useNavigate } from "react-router-d
import { useAsync } from "react-use";
import { LightningIcon, QrCodeIcon } from "../../components/icons";
import { UserAvatarLink } from "../../components/user-avatar-link";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import ZapModal from "../../components/zap-modal";
import { convertTimestampToDate } from "../../helpers/date";
import { truncatedId } from "../../helpers/nostr-event";

View File

@ -7,7 +7,7 @@ import { CopyIconButton } from "../../../components/copy-icon-button";
import { ChatIcon, ExternalLinkIcon, KeyIcon, SettingsIcon } from "../../../components/icons";
import { QrIconButton } from "./share-qr-button";
import { UserAvatar } from "../../../components/user-avatar";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
import { UserFollowButton } from "../../../components/user-follow-button";
import { UserTipButton } from "../../../components/user-tip-button";
import { Bech32Prefix, normalizeToBech32 } from "../../../helpers/nip19";

View File

@ -5,7 +5,7 @@ import { useUserMetadata } from "../../../hooks/use-user-metadata";
import { getUserDisplayName } from "../../../helpers/user-metadata";
import { UserAvatar } from "../../../components/user-avatar";
import { Bech32Prefix, normalizeToBech32 } from "../../../helpers/nip19";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
export const UserCard = ({ pubkey, relay }: { pubkey: string; relay?: string }) => {
const metadata = useUserMetadata(pubkey, relay ? [relay] : []);

View File

@ -93,7 +93,7 @@ const UserView = () => {
const activeTab = tabs.indexOf(tabs.find((t) => lastMatch.pathname.includes(t.path)) ?? tabs[0]);
const metadata = useUserMetadata(pubkey, [], true);
const metadata = useUserMetadata(pubkey, userTopRelays, true);
const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey);
useAppTitle(getUserDisplayName(metadata, npub ?? pubkey));

View File

@ -1,15 +1,15 @@
import { AspectRatio, Box, Button, Flex, Grid, IconButton, Image, Spinner } from "@chakra-ui/react";
import moment from "moment";
import { Box, Button, Flex, Grid, IconButton, Spinner } from "@chakra-ui/react";
import { Link as RouterLink, useOutletContext } from "react-router-dom";
import { truncatedId } from "../../helpers/nostr-event";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { matchImageUrls } from "../../helpers/regexp";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { ImageGalleryLink, ImageGalleryProvider } from "../../components/image-gallery";
import { ExternalLinkIcon } from "../../components/icons";
import { getSharableNoteId } from "../../helpers/nip19";
import { useMount, useUnmount } from "react-use";
import useSubject from "../../hooks/use-subject";
import userTimelineService from "../../services/user-timeline";
const matchAllImages = new RegExp(matchImageUrls, "ig");
@ -18,13 +18,17 @@ const UserMediaTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext();
// TODO: move this out of a hook so its not being re-created every time
const { events, loading, loadMore } = useTimelineLoader(
`${truncatedId(pubkey)}-media`,
contextRelays,
{ authors: [pubkey], kinds: [1] },
{ pageSize: moment.duration(1, "week").asSeconds(), startLimit: 40 }
);
const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]);
const events = useSubject(timeline.events);
const loading = useSubject(timeline.loading);
useEffect(() => {
timeline.setRelays(contextRelays);
}, [timeline, contextRelays.join("|")]);
useMount(() => timeline.open());
useUnmount(() => timeline.close());
const images = useMemo(() => {
var images: { eventId: string; src: string; index: number }[] = [];
@ -67,7 +71,11 @@ const UserMediaTab = () => {
))}
</Grid>
</ImageGalleryProvider>
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
) : (
<Button onClick={() => timeline.loadMore()}>Load More</Button>
)}
</Flex>
);
};

View File

@ -1,20 +1,13 @@
import {
Box,
Button,
Flex,
FormControl,
FormLabel,
Spinner,
Switch,
useDisclosure,
} from "@chakra-ui/react";
import moment from "moment";
import { Box, Button, Flex, FormControl, FormLabel, Spinner, Switch, useDisclosure } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom";
import { Note } from "../../components/note";
import RepostNote from "../../components/note/repost-note";
import { isReply, isRepost, truncatedId } from "../../helpers/nostr-event";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isReply, isRepost } from "../../helpers/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import userTimelineService from "../../services/user-timeline";
import { useEffect, useMemo } from "react";
import useSubject from "../../hooks/use-subject";
import { useMount, useUnmount } from "react-use";
const UserNotesTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
@ -23,13 +16,19 @@ const UserNotesTab = () => {
const { isOpen: showReplies, onToggle: toggleReplies } = useDisclosure();
const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure();
const { events, loading, loadMore } = useTimelineLoader(
`${truncatedId(pubkey)}-notes`,
contextRelays,
{ authors: [pubkey], kinds: [1, 6] },
{ pageSize: moment.duration(2, "day").asSeconds(), startLimit: 20 }
);
const timeline = events.filter((event) => {
const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]);
const events = useSubject(timeline.events);
const loading = useSubject(timeline.loading);
useEffect(() => {
timeline.setRelays(contextRelays);
}, [timeline, contextRelays.join("|")]);
useMount(() => timeline.open());
useUnmount(() => timeline.close());
const filteredEvents = events.filter((event) => {
if (!showReplies && isReply(event)) return false;
if (hideReposts && isRepost(event)) return false;
return true;
@ -48,14 +47,18 @@ const UserNotesTab = () => {
</FormLabel>
<Box flexGrow={1} />
</FormControl>
{timeline.map((event) =>
{filteredEvents.map((event) =>
event.kind === 6 ? (
<RepostNote key={event.id} event={event} maxHeight={1200} />
) : (
<Note key={event.id} event={event} maxHeight={1200} />
)
)}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
) : (
<Button onClick={() => timeline.loadMore()}>Load More</Button>
)}
</Flex>
);
};