diff --git a/.changeset/three-cats-refuse.md b/.changeset/three-cats-refuse.md new file mode 100644 index 000000000..4ab5e07aa --- /dev/null +++ b/.changeset/three-cats-refuse.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Rebuild thread loading diff --git a/src/app.tsx b/src/app.tsx index 596674bc0..be35067e0 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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: , + element: , }, { path: "settings", element: }, { diff --git a/src/classes/thread-loader.ts b/src/classes/thread-loader.ts deleted file mode 100644 index 2cea5c8f9..000000000 --- a/src/classes/thread-loader.ts +++ /dev/null @@ -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(""); - rootId = new PersistentSubject(""); - events = new PersistentSubject>({}); - - 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(); - } -} diff --git a/src/classes/timeline-loader.ts b/src/classes/timeline-loader.ts index a9d6d3f46..f1ecab8ab 100644 --- a/src/classes/timeline-loader.ts +++ b/src/classes/timeline-loader.ts @@ -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(); } diff --git a/src/components/embed-event/event-types/embedded-note.tsx b/src/components/embed-event/event-types/embedded-note.tsx index f0a8dc472..6167c5baa 100644 --- a/src/components/embed-event/event-types/embedded-note.tsx +++ b/src/components/embed-event/event-types/embedded-note.tsx @@ -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 & { event: NostrEvent }) { const { showSignatureVerification } = useSubject(appSettings); @@ -25,6 +26,7 @@ export default function EmbeddedNote({ event, ...props }: Omit( (e) => { e.preventDefault(); + singleEventService.handleEvent(event); navigate(to); }, [navigate, to], diff --git a/src/components/note/components/bookmark-button.tsx b/src/components/note/components/bookmark-button.tsx index 302564096..a315396b9 100644 --- a/src/components/note/components/bookmark-button.tsx +++ b/src/components/note/components/bookmark-button.tsx @@ -76,7 +76,7 @@ export default function BookmarkButton({ event, ...props }: { event: NostrEvent return ( <> - + 0 ? : } diff --git a/src/components/note/index.tsx b/src/components/note/index.tsx index f9fe67868..dd2872269 100644 --- a/src/components/note/index.tsx +++ b/src/components/note/index.tsx @@ -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 & { event: NostrEvent; @@ -97,7 +97,13 @@ export const Note = React.memo( data-event-id={event.id} {...props} > - {clickable && } + {clickable && ( + singleEventService.handleEvent(event)} + /> + )} @@ -106,7 +112,12 @@ export const Note = React.memo( {showSignatureVerification && } {!hideDrawerButton && ( - + singleEventService.handleEvent(event)} + /> )} diff --git a/src/helpers/nostr/events.ts b/src/helpers/nostr/events.ts index 7fd076254..6834105f8 100644 --- a/src/helpers/nostr/events.ts +++ b/src/helpers/nostr/events.ts @@ -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, }; } diff --git a/src/helpers/thread.ts b/src/helpers/thread.ts index adf7239e4..888dcab7c 100644 --- a/src/helpers/thread.ts +++ b/src/helpers/thread.ts @@ -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 = {}; const replies = new Map(); diff --git a/src/hooks/use-thread-loader.ts b/src/hooks/use-thread-loader.ts deleted file mode 100644 index 6b9cc734d..000000000 --- a/src/hooks/use-thread-loader.ts +++ /dev/null @@ -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(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, - }; -} diff --git a/src/hooks/use-timeline-loader.ts b/src/hooks/use-timeline-loader.ts index 5b1370f5c..f7f2b8954 100644 --- a/src/hooks/use-timeline-loader.ts +++ b/src/hooks/use-timeline-loader.ts @@ -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(); diff --git a/src/providers/drawer-sub-view-provider.tsx b/src/providers/drawer-sub-view-provider.tsx index 534d736c2..b7f8b80cd 100644 --- a/src/providers/drawer-sub-view-provider.tsx +++ b/src/providers/drawer-sub-view-provider.tsx @@ -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: , + element: , }, ]; diff --git a/src/routes.tsx b/src/routes.tsx index e75861bb6..e6a4cb799 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -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: , + element: , }; diff --git a/src/services/timeline-cache.ts b/src/services/timeline-cache.ts index e5fe6e73b..d430c7e75 100644 --- a/src/services/timeline-cache.ts +++ b/src/services/timeline-cache.ts @@ -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(); 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(); } diff --git a/src/views/note/components/thread-post.tsx b/src/views/note/components/thread-post.tsx index 95fc35698..2d6bf31b2 100644 --- a/src/views/note/components/thread-post.tsx +++ b/src/views/note/components/thread-post.tsx @@ -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 }: ); + const ref = useRef(null); + useRegisterIntersectionEntity(ref, post.event.id); + return ( <> {header} {expanded && renderContent()} diff --git a/src/views/note/index.tsx b/src/views/note/index.tsx index 884d67fe8..6013c1ce7 100644 --- a/src/views/note/index.tsx +++ b/src/views/note/index.tsx @@ -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 ; - - let pageContent = Missing Event; - +function ThreadPage({ thread, rootId, focusId }: { thread: Map; rootId: string; focusId: string }) { const isRoot = rootId === focusId; + + const focusedPost = thread.get(focusId); const rootPost = thread.get(rootId); if (isRoot && rootPost) { - pageContent = ; + return ; } - 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 && ( - - )} - {post.reply && ( - - )} - - - ); - } else if (events[focusId]) { - pageContent = ; } - return {pageContent}; + return ( + <> + {parentPosts.length > 1 && ( + + )} + {focusedPost.reply && ( + + )} + + + ); +} + +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 ( + + {!focused && ( + + Loading note + + )} + {/* + {JSON.stringify({ pointer, rootId, focused: focused?.id, refs, timelineId, events: events.length }, null, 2)} + */} + + {focused && rootId ? : } + + + ); } diff --git a/src/views/torrents/index.tsx b/src/views/torrents/index.tsx index 2585674bc..44ff4f425 100644 --- a/src/views/torrents/index.tsx +++ b/src/views/torrents/index.tsx @@ -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);