remove legacy thread loader

This commit is contained in:
hzrd149 2023-11-29 07:38:19 -06:00
parent 6cb7f0d219
commit d18e03afe2
17 changed files with 162 additions and 232 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Rebuild thread loading

View File

@ -14,7 +14,7 @@ import SettingsView from "./views/settings";
import NostrLinkView from "./views/link";
import ProfileView from "./views/profile";
import HashTagView from "./views/hashtag";
import NoteView from "./views/note";
import ThreadView from "./views/note";
import NotificationsView from "./views/notifications";
import DirectMessagesView from "./views/messages";
import DirectMessageChatView from "./views/messages/chat";
@ -216,7 +216,7 @@ const router = createHashRouter([
},
{
path: "/n/:id",
element: <NoteView />,
element: <ThreadView />,
},
{ path: "settings", element: <SettingsView /> },
{

View File

@ -1,111 +0,0 @@
import { getReferences } from "../helpers/nostr/events";
import { NostrEvent } from "../types/nostr-event";
import NostrRequest from "./nostr-request";
import NostrMultiSubscription from "./nostr-multi-subscription";
import { PersistentSubject } from "./subject";
import { createSimpleQueryMap } from "../helpers/nostr/filter";
/** @deprecated */
export default class ThreadLoader {
loading = new PersistentSubject(false);
focusId = new PersistentSubject<string>("");
rootId = new PersistentSubject<string>("");
events = new PersistentSubject<Record<string, NostrEvent>>({});
private relays: string[];
private subscription: NostrMultiSubscription;
constructor(relays: string[], eventId: string) {
this.relays = relays;
this.subscription = new NostrMultiSubscription();
this.subscription.onEvent.subscribe((event) => {
this.events.next({ ...this.events.value, [event.id]: event });
});
this.updateEventId(eventId);
}
loadEvent() {
this.loading.next(true);
const request = new NostrRequest(this.relays);
request.onEvent.subscribe((event) => {
this.events.next({ ...this.events.value, [event.id]: event });
this.checkAndUpdateRoot();
request.complete();
this.loading.next(false);
});
request.start({ ids: [this.focusId.value] });
}
private checkAndUpdateRoot() {
const event = this.events.value[this.focusId.value];
if (event) {
const refs = getReferences(event);
const rootId = refs.rootId || event.id;
// only update the root if its different
if (rootId !== this.rootId.value) {
this.rootId.next(rootId);
this.loadRoot();
this.updateSubscription();
}
}
}
loadRoot() {
if (this.rootId.value) {
const request = new NostrRequest(this.relays);
request.onEvent.subscribe((event) => {
this.events.next({ ...this.events.value, [event.id]: event });
request.complete();
});
request.start({ ids: [this.rootId.value] });
}
}
setRelays(relays: string[]) {
this.relays = relays;
this.subscription.setQueryMap(createSimpleQueryMap(this.relays, { "#e": [this.rootId.value], kinds: [1] }));
this.loadEvent();
}
private updateSubscription() {
if (this.rootId.value) {
this.subscription.setQueryMap(createSimpleQueryMap(this.relays, { "#e": [this.rootId.value], kinds: [1] }));
if (this.subscription.state !== NostrMultiSubscription.OPEN) {
this.subscription.open();
}
}
}
updateEventId(eventId: string) {
if (this.loading.value) {
console.warn("trying to set eventId while loading");
return;
}
this.focusId.next(eventId);
const event = this.events.value[eventId];
if (!event) {
this.loadEvent();
} else {
this.checkAndUpdateRoot();
}
}
open() {
if (!this.loading.value && this.focusId.value && this.events.value[this.focusId.value]) {
this.loadEvent();
}
this.updateSubscription();
}
close() {
this.subscription.close();
}
}

View File

@ -197,9 +197,6 @@ export default class TimelineLoader {
mapQueryMap(this.queryMap, (filter) => addQueryToFilter(filter, { limit: BLOCK_SIZE / 2 })),
);
// TODO: maybe smartly prune the events based on the new filter
this.forgetEvents();
this.triggerBlockLoads();
}

View File

@ -16,6 +16,7 @@ import { getSharableEventAddress } from "../../../helpers/nip19";
import { CompactNoteContent } from "../../compact-note-content";
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
import HoverLinkOverlay from "../../hover-link-overlay";
import singleEventService from "../../../services/single-event";
export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const { showSignatureVerification } = useSubject(appSettings);
@ -25,6 +26,7 @@ export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "child
const handleClick = useCallback<MouseEventHandler>(
(e) => {
e.preventDefault();
singleEventService.handleEvent(event);
navigate(to);
},
[navigate, to],

View File

@ -76,7 +76,7 @@ export default function BookmarkButton({ event, ...props }: { event: NostrEvent
return (
<>
<Menu closeOnSelect={false}>
<Menu isLazy closeOnSelect={false}>
<MenuButton
as={IconButton}
icon={inLists.length > 0 ? <BookmarkedIcon /> : <BookmarkIcon />}

View File

@ -43,13 +43,13 @@ import OpenInDrawerButton from "../open-in-drawer-button";
import { getSharableEventAddress } from "../../helpers/nip19";
import { useBreakpointValue } from "../../providers/breakpoint-provider";
import HoverLinkOverlay from "../hover-link-overlay";
import { nip19 } from "nostr-tools";
import NoteCommunityMetadata from "./note-community-metadata";
import useSingleEvent from "../../hooks/use-single-event";
import { CompactNoteContent } from "../compact-note-content";
import NoteProxyLink from "./components/note-proxy-link";
import { NoteDetailsButton } from "./components/note-details-button";
import EventInteractionDetailsModal from "../event-interactions-modal";
import singleEventService from "../../services/single-event";
export type NoteProps = Omit<CardProps, "children"> & {
event: NostrEvent;
@ -97,7 +97,13 @@ export const Note = React.memo(
data-event-id={event.id}
{...props}
>
{clickable && <HoverLinkOverlay as={RouterLink} to={`/n/${getSharableEventAddress(event)}`} />}
{clickable && (
<HoverLinkOverlay
as={RouterLink}
to={`/n/${getSharableEventAddress(event)}`}
onClick={() => singleEventService.handleEvent(event)}
/>
)}
<CardHeader p="2">
<Flex flex="1" gap="2" alignItems="center">
<UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} />
@ -106,7 +112,12 @@ export const Note = React.memo(
<Flex grow={1} />
{showSignatureVerification && <EventVerificationIcon event={event} />}
{!hideDrawerButton && (
<OpenInDrawerButton to={`/n/${getSharableEventAddress(event)}`} size="sm" variant="ghost" />
<OpenInDrawerButton
to={`/n/${getSharableEventAddress(event)}`}
size="sm"
variant="ghost"
onClick={() => singleEventService.handleEvent(event)}
/>
)}
<Link as={RouterLink} whiteSpace="nowrap" color="current" to={`/n/${getSharableEventAddress(event)}`}>
<Timestamp timestamp={event.created_at} />

View File

@ -103,8 +103,13 @@ export function getReferences(event: NostrEvent | DraftNostrEvent) {
const events = eTags.map((t) => t[1]);
const contentTagRefs = getContentTagRefs(event.content, event.tags);
let replyId = eTags.find((t) => t[3] === "reply")?.[1];
let rootId = eTags.find((t) => t[3] === "root")?.[1];
const replyTag = eTags.find((t) => t[3] === "reply");
const rootTag = eTags.find((t) => t[3] === "root");
let replyId = replyTag?.[1];
let replyRelay = replyTag?.[2];
let rootId = rootTag?.[1];
let rootRelay = rootTag?.[2];
if (!rootId || !replyId) {
// a direct reply dose not need a "reply" reference
@ -136,7 +141,9 @@ export function getReferences(event: NostrEvent | DraftNostrEvent) {
return {
events,
rootId,
rootRelay,
replyId,
replyRelay,
contentTagRefs,
};
}

View File

@ -32,7 +32,7 @@ export function getThreadMembers(item: ThreadItem, omit?: string) {
return Array.from(pubkeys);
}
export function linkEvents(events: NostrEvent[]) {
export function buildThread(events: NostrEvent[]) {
const idToChildren: Record<string, NostrEvent[]> = {};
const replies = new Map<string, ThreadItem>();

View File

@ -1,52 +0,0 @@
import { useEffect, useMemo, useRef } from "react";
import { useUnmount } from "react-use";
import ThreadLoader from "../classes/thread-loader";
import { linkEvents } from "../helpers/thread";
import { useReadRelayUrls } from "./use-client-relays";
import useSubject from "./use-subject";
import useRelaysChanged from "./use-relays-changed";
type Options = {
enabled?: boolean;
};
export function useThreadLoader(eventId: string, additionalRelays: string[] = [], opts?: Options) {
const relays = useReadRelayUrls(additionalRelays);
const ref = useRef<ThreadLoader | null>(null);
const loader = (ref.current = ref.current || new ThreadLoader(relays, eventId));
useEffect(() => {
if (eventId !== loader.focusId.value) loader.updateEventId(eventId);
}, [eventId]);
const enabled = opts?.enabled ?? true;
useEffect(() => {
if (enabled) loader.open();
else loader.close();
}, [enabled]);
useRelaysChanged(relays, () => {
loader.setRelays(relays);
});
useUnmount(() => {
loader.close();
});
const events = useSubject(loader.events) ?? {};
const loading = useSubject(loader.loading);
const rootId = useSubject(loader.rootId) ?? "";
const focusId = useSubject(loader.focusId) ?? "";
const thread = useMemo(() => linkEvents(Object.values(events)), [events]);
return {
loader,
events,
thread,
rootId,
focusId,
loading,
};
}

View File

@ -14,11 +14,16 @@ type Options = {
customSort?: (a: NostrEvent, b: NostrEvent) => number;
};
export default function useTimelineLoader(key: string, relays: string[], query: NostrRequestFilter, opts?: Options) {
export default function useTimelineLoader(
key: string,
relays: string[],
query: NostrRequestFilter | undefined,
opts?: Options,
) {
const timeline = useMemo(() => timelineCacheService.createTimeline(key), [key]);
useEffect(() => {
timeline.setQueryMap(createSimpleQueryMap(relays, query));
if (query) timeline.setQueryMap(createSimpleQueryMap(relays, query));
}, [timeline, JSON.stringify(query), relays.join("|")]);
useEffect(() => {
timeline.setEventFilter(opts?.eventFilter);
@ -32,7 +37,7 @@ export default function useTimelineLoader(key: string, relays: string[], query:
timeline.events.customSort = opts?.customSort;
}, [timeline, opts?.customSort]);
const enabled = opts?.enabled ?? true;
const enabled = opts?.enabled ?? !!query;
useEffect(() => {
if (enabled) {
timeline.open();

View File

@ -13,7 +13,7 @@ import {
import { Location, RouteObject, RouterProvider, To, createMemoryRouter, useNavigate } from "react-router-dom";
import { ErrorBoundary } from "../components/error-boundary";
import NoteView from "../views/note";
import ThreadView from "../views/note";
import { ChevronLeftIcon, ChevronRightIcon, ExternalLinkIcon } from "../components/icons";
import { PageProviders } from ".";
import { logger } from "../helpers/debug";
@ -67,7 +67,7 @@ function DrawerSubView({
const routes: RouteObject[] = [
{
path: "/n/:id",
element: <NoteView />,
element: <ThreadView />,
},
];

View File

@ -1,8 +1,8 @@
import { RouteObject } from "react-router-dom";
import NoteView from "./views/note";
import ThreadView from "./views/note";
export const threadRoute: RouteObject = {
path: "/n/:id",
element: <NoteView />,
element: <ThreadView />,
};

View File

@ -1,14 +1,17 @@
import TimelineLoader from "../classes/timeline-loader";
import { logger } from "../helpers/debug";
const MAX_CACHE = 20;
const MAX_CACHE = 30;
class TimelineCacheService {
private timelines = new Map<string, TimelineLoader>();
private cacheQueue: string[] = [];
private log = logger.extend("TimelineCacheService");
createTimeline(key: string) {
let timeline = this.timelines.get(key);
if (!timeline) {
this.log(`Creating ${key}`);
timeline = new TimelineLoader(key);
this.timelines.set(key, timeline);
}
@ -22,6 +25,7 @@ class TimelineCacheService {
if (!deleteKey) break;
const deadTimeline = this.timelines.get(deleteKey);
if (deadTimeline) {
this.log(`Destroying ${deadTimeline.name}`);
this.timelines.delete(deleteKey);
deadTimeline.cleanup();
}

View File

@ -1,4 +1,4 @@
import { memo, useState } from "react";
import { memo, useRef, useState } from "react";
import {
Alert,
AlertIcon,
@ -39,6 +39,7 @@ import NoteProxyLink from "../../../components/note/components/note-proxy-link";
import { NoteDetailsButton } from "../../../components/note/components/note-details-button";
import EventInteractionDetailsModal from "../../../components/event-interactions-modal";
import { getNeventCodeWithRelays } from "../../../helpers/nip19";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
const LEVEL_COLORS = ["green", "blue", "red", "purple", "yellow", "cyan", "pink"];
@ -141,6 +142,9 @@ export const ThreadPost = memo(({ post, initShowReplies, focusId, level = -1 }:
</Flex>
);
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, post.event.id);
return (
<>
<Flex
@ -151,6 +155,7 @@ export const ThreadPost = memo(({ post, initShowReplies, focusId, level = -1 }:
borderWidth=".1rem .1rem .1rem .35rem"
borderColor={focusId === post.event.id ? focusColor : undefined}
borderLeftColor={color + "." + colorValue}
ref={ref}
>
{header}
{expanded && renderContent()}

View File

@ -1,12 +1,21 @@
import { Button, Spinner } from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
import { useEffect, useMemo } from "react";
import { Button, Code, Heading, Spinner } from "@chakra-ui/react";
import { Kind, nip19 } from "nostr-tools";
import { useParams, Link as RouterLink } from "react-router-dom";
import Note from "../../components/note";
import { getSharableEventAddress, isHexKey } from "../../helpers/nip19";
import { useThreadLoader } from "../../hooks/use-thread-loader";
import { ThreadPost } from "./components/thread-post";
import VerticalPageLayout from "../../components/vertical-page-layout";
import useSingleEvent from "../../hooks/use-single-event";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { getReferences } from "../../helpers/nostr/events";
import useSubject from "../../hooks/use-subject";
import { ThreadItem, buildThread } from "../../helpers/thread";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import singleEventService from "../../services/single-event";
function useNotePointer() {
const { id } = useParams() as { id: string };
@ -23,56 +32,101 @@ function useNotePointer() {
}
}
export default function NoteView() {
const pointer = useNotePointer();
const { thread, events, rootId, focusId, loading } = useThreadLoader(pointer.id, pointer.relays, {
enabled: !!pointer.id,
});
if (loading) return <Spinner />;
let pageContent = <span>Missing Event</span>;
function ThreadPage({ thread, rootId, focusId }: { thread: Map<string, ThreadItem>; rootId: string; focusId: string }) {
const isRoot = rootId === focusId;
const focusedPost = thread.get(focusId);
const rootPost = thread.get(rootId);
if (isRoot && rootPost) {
pageContent = <ThreadPost post={rootPost} initShowReplies focusId={focusId} />;
return <ThreadPost post={rootPost} initShowReplies focusId={focusId} />;
}
const post = thread.get(focusId);
if (post) {
const parentPosts = [];
if (post.reply) {
let p = post;
while (p.reply) {
parentPosts.unshift(p.reply);
p = p.reply;
}
if (!focusedPost) return null;
const parentPosts = [];
if (focusedPost.reply) {
let p = focusedPost;
while (p.reply) {
parentPosts.unshift(p.reply);
p = p.reply;
}
pageContent = (
<>
{parentPosts.length > 1 && (
<Button
variant="outline"
size="lg"
h="4rem"
w="full"
as={RouterLink}
to={`/n/${getSharableEventAddress(parentPosts[0].event)}`}
>
View full thread ({parentPosts.length - 1})
</Button>
)}
{post.reply && (
<Note key={post.reply.event.id + "-rely"} event={post.reply.event} hideDrawerButton showReplyLine={false} />
)}
<ThreadPost key={post.event.id} post={post} initShowReplies focusId={focusId} />
</>
);
} else if (events[focusId]) {
pageContent = <Note event={events[focusId]} variant="filled" hideDrawerButton />;
}
return <VerticalPageLayout px={{ base: 0, md: "2" }}>{pageContent}</VerticalPageLayout>;
return (
<>
{parentPosts.length > 1 && (
<Button
variant="outline"
size="lg"
h="4rem"
w="full"
as={RouterLink}
to={`/n/${getSharableEventAddress(parentPosts[0].event)}`}
>
View full thread ({parentPosts.length - 1})
</Button>
)}
{focusedPost.reply && (
<Note
key={focusedPost.reply.event.id + "-rely"}
event={focusedPost.reply.event}
hideDrawerButton
showReplyLine={false}
/>
)}
<ThreadPost key={focusedPost.event.id} post={focusedPost} initShowReplies focusId={focusId} />
</>
);
}
export default function ThreadView() {
const pointer = useNotePointer();
const readRelays = useReadRelayUrls(pointer.relays);
// load the event in focus
const focused = useSingleEvent(pointer.id, pointer.relays);
const refs = focused && getReferences(focused);
const rootId = refs ? refs.rootId || pointer.id : undefined;
const timelineId = `${rootId}-replies`;
const timeline = useTimelineLoader(
timelineId,
readRelays,
rootId
? {
"#e": [rootId],
kinds: [Kind.Text],
}
: undefined,
);
const events = useSubject(timeline.timeline);
// mirror all events to single event cache
useEffect(() => {
for (const e of events) singleEventService.handleEvent(e);
}, [events]);
const rootEvent = useSingleEvent(rootId, refs?.rootRelay ? [refs.rootRelay] : []);
const thread = useMemo(() => {
return rootEvent ? buildThread([...events, rootEvent]) : buildThread(events);
}, [events, rootEvent]);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<VerticalPageLayout px={{ base: 0, md: "2" }}>
{!focused && (
<Heading mx="auto" my="4">
<Spinner /> Loading note
</Heading>
)}
{/* <Code as="pre">
{JSON.stringify({ pointer, rootId, focused: focused?.id, refs, timelineId, events: events.length }, null, 2)}
</Code> */}
<IntersectionObserverProvider callback={callback}>
{focused && rootId ? <ThreadPage thread={thread} rootId={rootId} focusId={focused.id} /> : <Spinner />}
</IntersectionObserverProvider>
</VerticalPageLayout>
);
}

View File

@ -86,7 +86,10 @@ function TorrentsPage() {
() => (tags.length > 0 ? { ...filter, kinds: [TORRENT_KIND], "#t": tags } : { ...filter, kinds: [TORRENT_KIND] }),
[tags.join(","), filter],
);
const timeline = useTimelineLoader(`${listId}-torrents`, relays, query, { eventFilter, enabled: !!filter });
const timeline = useTimelineLoader(`${listId || "global"}-torrents`, relays, query, {
eventFilter,
enabled: !!filter,
});
const torrents = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);