mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-02 08:58:36 +02:00
simple user timeline caching
This commit is contained in:
parent
0189507d62
commit
4506c82bd0
@ -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 /> },
|
||||
],
|
||||
},
|
||||
|
80
src/classes/user-timeline.ts
Normal file
80
src/classes/user-timeline.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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 {};
|
||||
}
|
||||
|
33
src/services/user-timeline.ts
Normal file
33
src/services/user-timeline.ts
Normal 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;
|
@ -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>
|
||||
);
|
||||
}
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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] : []);
|
||||
|
@ -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));
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user