diff --git a/src/classes/nostr-request.ts b/src/classes/nostr-request.ts index 729dad12d..93cd3048a 100644 --- a/src/classes/nostr-request.ts +++ b/src/classes/nostr-request.ts @@ -3,7 +3,6 @@ import { NostrEvent } from "../types/nostr-event"; import { NostrQuery } from "../types/nostr-query"; import { Relay } from "../services/relays"; import relayPool from "../services/relays/relay-pool"; -import { IncomingEvent } from "../services/relays/relay"; let lastId = 0; @@ -73,9 +72,12 @@ export class NostrRequest { } setTimeout(() => { + console.log(`NostrRequest: ${this.id} timed out`); this.cancel(); }, this.timeout); + console.log(`NostrRequest: ${this.id} started`); + return this; } cancel() { @@ -92,6 +94,8 @@ export class NostrRequest { this.relays = new Set(); this.onEvent.complete(); + console.log(`NostrRequest: ${this.id} complete`); + return this; } } diff --git a/src/classes/timeline-loader.ts b/src/classes/timeline-loader.ts new file mode 100644 index 000000000..29a4a518a --- /dev/null +++ b/src/classes/timeline-loader.ts @@ -0,0 +1,91 @@ +import moment from "moment"; +import { BehaviorSubject } from "rxjs"; +import { NostrEvent } from "../types/nostr-event"; +import { NostrQuery } from "../types/nostr-query"; +import { NostrRequest } from "./nostr-request"; +import { NostrSubscription } from "./nostr-subscription"; + +export type NostrQueryWithStart = NostrQuery & { since: number }; + +type Options = { + name?: string; + pageSize: number; +}; +export type TimelineLoaderOptions = Partial; + +export class TimelineLoader { + relays: string[]; + query: NostrQueryWithStart; + events = new BehaviorSubject([]); + loading = new BehaviorSubject(false); + page = new BehaviorSubject(0); + private seenEvents = new Set(); + private subscription: NostrSubscription; + private opts: Options = { pageSize: moment.duration(1, "hour").asSeconds() }; + + constructor(relays: string[], query: NostrQueryWithStart, opts?: TimelineLoaderOptions) { + if (!query.since) throw new Error('Timeline requires "since" to be set in query'); + + this.relays = relays; + this.query = query; + Object.assign(this.opts, opts); + + this.subscription = new NostrSubscription(relays, query, opts?.name); + + this.subscription.onEvent.subscribe(this.handleEvent.bind(this)); + } + + setQuery(query: NostrQueryWithStart) { + if (!query.since) throw new Error('Timeline requires "since" to be set in query'); + + this.query = query; + this.subscription.update(query); + } + + private handleEvent(event: NostrEvent) { + if (!this.seenEvents.has(event.id)) { + this.events.next(this.events.value.concat(event).sort((a, b) => b.created_at - a.created_at)); + this.seenEvents.add(event.id); + if (this.loading.value) this.loading.next(false); + } + } + + private getPageDates(page: number) { + const start = this.query.since; + const until = start - page * this.opts.pageSize; + const since = until - this.opts.pageSize; + + return { + until, + since, + }; + } + + loadMore() { + if (this.loading.value) return; + + const query = { ...this.query, ...this.getPageDates(this.page.value) }; + const request = new NostrRequest(this.relays); + request.onEvent.subscribe({ + next: this.handleEvent.bind(this), + complete: () => { + this.loading.next(false); + }, + }); + request.start(query); + + this.loading.next(true); + this.page.next(this.page.value + 1); + } + + reset() { + this.events.next([]); + this.seenEvents.clear(); + } + open() { + this.subscription.open(); + } + close() { + this.subscription.close(); + } +} diff --git a/src/helpers/nostr-event.ts b/src/helpers/nostr-event.ts index a3b8d4351..57a6c47ff 100644 --- a/src/helpers/nostr-event.ts +++ b/src/helpers/nostr-event.ts @@ -1,7 +1,7 @@ import { isETag, isPTag, NostrEvent } from "../types/nostr-event"; export function isReply(event: NostrEvent) { - return !!event.tags.find(isETag); + return !!event.tags.find((tag) => isETag(tag) && tag[3] !== "mention"); } export function isPost(event: NostrEvent) { diff --git a/src/hooks/use-event-timeline-loader.ts b/src/hooks/use-event-timeline-loader.ts deleted file mode 100644 index 656d6ca16..000000000 --- a/src/hooks/use-event-timeline-loader.ts +++ /dev/null @@ -1,53 +0,0 @@ -import moment from "moment"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { NostrEvent } from "../types/nostr-event"; -import { NostrQuery } from "../types/nostr-query"; -import { useEventDir } from "./use-event-dir"; -import { useSubscription } from "./use-subscription"; - -type Options = { - filter?: (event: NostrEvent) => boolean; - name?: string; - enabled?: boolean; - initialSince?: number; - pageSize?: number; -}; - -export function useEventTimelineLoader(query: Omit, opts?: Options) { - const enabled = opts?.enabled ?? true; - const pageSize = opts?.pageSize ?? moment.duration(1, "day").asSeconds(); - const [until, setUntil] = useState(undefined); - const [since, setSince] = useState(opts?.initialSince ?? moment().subtract(1, "day").unix()); - - const sub = useSubscription({ ...query, since, until }, { name: opts?.name, enabled }); - - const eventDir = useEventDir(sub, opts?.filter); - - const reset = useCallback(() => { - setUntil(undefined); - setSince(opts?.initialSince ?? moment().subtract(1, "day").startOf("day").unix()); - eventDir.reset(); - }, [eventDir.reset, setUntil, setSince]); - - // clear events when pubkey changes - useEffect(() => reset(), [opts?.name, reset]); - - const timeline = useMemo( - () => Object.values(eventDir.events).sort((a, b) => b.created_at - a.created_at), - [eventDir.events] - ); - - const more = useCallback( - (days: number) => { - setUntil(since); - setSince(since + pageSize); - }, - [setSince, setUntil, since] - ); - - return { - timeline, - reset, - more, - }; -} diff --git a/src/hooks/use-timeline-loader.ts b/src/hooks/use-timeline-loader.ts new file mode 100644 index 000000000..facf72252 --- /dev/null +++ b/src/hooks/use-timeline-loader.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef } from "react"; +import { useUnmount } from "react-use"; +import { NostrQueryWithStart, TimelineLoader, TimelineLoaderOptions } from "../classes/timeline-loader"; +import settings from "../services/settings"; +import useSubject from "./use-subject"; + +type Options = TimelineLoaderOptions & { + enabled?: boolean; +}; + +export function useTimelineLoader(key: string, query: NostrQueryWithStart, opts?: Options) { + const relays = useSubject(settings.relays); + if (opts && !opts.name) opts.name = key; + + const ref = useRef(null); + ref.current = ref.current || new TimelineLoader(relays, query, opts); + + useEffect(() => { + ref.current?.reset(); + ref.current?.setQuery(query); + }, [key]); + + const enabled = opts?.enabled ?? true; + useEffect(() => { + if (ref.current) { + if (enabled) ref.current.open(); + else ref.current.close(); + } + }, [ref, enabled]); + + useUnmount(() => { + ref.current?.close(); + }); + + const events = useSubject(ref.current?.events); + const loading = useSubject(ref.current.loading); + + return { + loader: ref.current, + events, + loading, + }; +} diff --git a/src/views/home/discover-tab.tsx b/src/views/home/discover-tab.tsx index 4b2f85a60..d4a635e6e 100644 --- a/src/views/home/discover-tab.tsx +++ b/src/views/home/discover-tab.tsx @@ -1,15 +1,14 @@ import { useEffect, useState } from "react"; -import { Flex, Text } from "@chakra-ui/react"; +import { Button, Flex, Spinner } from "@chakra-ui/react"; import moment from "moment"; import { mergeAll, from } from "rxjs"; import { Post } from "../../components/post"; -import { useEventDir } from "../../hooks/use-event-dir"; import useSubject from "../../hooks/use-subject"; -import { useSubscription } from "../../hooks/use-subscription"; import { useUserContacts } from "../../hooks/use-user-contacts"; import identity from "../../services/identity"; -import settings from "../../services/settings"; import userContactsService from "../../services/user-contacts"; +import { useTimelineLoader } from "../../hooks/use-timeline-loader"; +import { isPost } from "../../helpers/nostr-event"; function useExtendedContacts(pubkey: string) { const [extendedContacts, setExtendedContacts] = useState([]); @@ -42,27 +41,24 @@ export const DiscoverTab = () => { const pubkey = useSubject(identity.pubkey); const contactsOfContacts = useExtendedContacts(pubkey); - - const [since, setSince] = useState(moment().subtract(1, "hour")); - const [after, setAfter] = useState(moment()); - - const sub = useSubscription( - { - authors: contactsOfContacts, - kinds: [1], - since: since.unix(), - }, - { name: "home-discover", enabled: contactsOfContacts.length > 0 } + const { loader, events, loading } = useTimelineLoader( + `discover-posts`, + { authors: contactsOfContacts, kinds: [1], since: moment().subtract(1, "hour").unix() }, + { pageSize: moment.duration(1, "hour").asSeconds(), enabled: contactsOfContacts.length > 0 } ); - const { events } = useEventDir(sub); - const timeline = Object.values(events).sort((a, b) => b.created_at - a.created_at); + const timeline = events.filter(isPost); return ( {timeline.map((event) => ( ))} + {loading ? ( + + ) : ( + + )} ); }; diff --git a/src/views/home/following-posts-tab.tsx b/src/views/home/following-posts-tab.tsx index 7806a5432..6e0f94426 100644 --- a/src/views/home/following-posts-tab.tsx +++ b/src/views/home/following-posts-tab.tsx @@ -1,9 +1,9 @@ -import { Flex } from "@chakra-ui/react"; +import { Button, Flex, Spinner } from "@chakra-ui/react"; import moment from "moment"; import { Post } from "../../components/post"; import { isPost } from "../../helpers/nostr-event"; -import { useEventTimelineLoader } from "../../hooks/use-event-timeline-loader"; import useSubject from "../../hooks/use-subject"; +import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useUserContacts } from "../../hooks/use-user-contacts"; import identity from "../../services/identity"; @@ -12,26 +12,24 @@ export const FollowingPostsTab = () => { const contacts = useUserContacts(pubkey); const following = contacts?.contacts || []; - - const { timeline } = useEventTimelineLoader( - { - authors: following, - kinds: [1], - }, - { - name: "following-posts", - enabled: following.length > 0, - filter: isPost, - initialSince: moment().subtract(1, "hour").unix(), - pageSize: moment.duration(1, "hour").asSeconds(), - } + const { loader, events, loading } = useTimelineLoader( + `following-posts`, + { authors: following, kinds: [1], since: moment().subtract(2, "hour").unix() }, + { pageSize: moment.duration(2, "hour").asSeconds(), enabled: following.length > 0 } ); + const timeline = events.filter(isPost); + return ( {timeline.map((event) => ( ))} + {loading ? ( + + ) : ( + + )} ); }; diff --git a/src/views/user/index.tsx b/src/views/user/index.tsx index 7307b0132..47e8d7962 100644 --- a/src/views/user/index.tsx +++ b/src/views/user/index.tsx @@ -13,7 +13,6 @@ import { Tabs, Text, Box, - Image, } from "@chakra-ui/react"; import { useParams } from "react-router-dom"; import { UserPostsTab } from "./posts"; @@ -27,7 +26,6 @@ import { normalizeToHex } from "../../helpers/nip-19"; import { Page } from "../../components/page"; import { UserProfileMenu } from "./user-profile-menu"; import { UserFollowersTab } from "./followers"; -import { useUserFollowers } from "../../hooks/use-user-followers"; import { UserRepliesTab } from "./replies"; export const UserPage = () => { @@ -62,7 +60,6 @@ export const UserView = ({ pubkey }: UserViewProps) => { const metadata = useUserMetadata(pubkey, [], true); const label = getUserDisplayName(metadata, pubkey); - const followers = useUserFollowers(pubkey); return ( @@ -81,7 +78,7 @@ export const UserView = ({ pubkey }: UserViewProps) => { Posts Replies - Followers ({followers?.length}) + Followers Following Relays diff --git a/src/views/user/posts.tsx b/src/views/user/posts.tsx index ce155c28b..8ce4f7cc5 100644 --- a/src/views/user/posts.tsx +++ b/src/views/user/posts.tsx @@ -1,22 +1,27 @@ import { Button, Flex, Spinner } from "@chakra-ui/react"; +import moment from "moment"; import { Post } from "../../components/post"; import { isPost } from "../../helpers/nostr-event"; -import { useEventTimelineLoader } from "../../hooks/use-event-timeline-loader"; +import { useTimelineLoader } from "../../hooks/use-timeline-loader"; export const UserPostsTab = ({ pubkey }: { pubkey: string }) => { - const { timeline, more } = useEventTimelineLoader( - { authors: [pubkey], kinds: [1] }, - { filter: isPost, name: "user posts" } + const { loader, events, loading } = useTimelineLoader( + `${pubkey} posts`, + { authors: [pubkey], kinds: [1], since: moment().subtract(1, "day").unix() }, + { pageSize: moment.duration(1, "day").asSeconds() } ); + const timeline = events.filter(isPost); return ( - {timeline.length > 0 ? ( - timeline.map((event) => ) - ) : ( + {timeline.map((event) => ( + + ))} + {loading ? ( + ) : ( + )} - ); }; diff --git a/src/views/user/replies.tsx b/src/views/user/replies.tsx index 72a9decbf..45dbaeeab 100644 --- a/src/views/user/replies.tsx +++ b/src/views/user/replies.tsx @@ -1,24 +1,27 @@ -import { Button, Flex, SkeletonText } from "@chakra-ui/react"; +import { Button, Flex, Spinner } from "@chakra-ui/react"; +import moment from "moment"; import { Post } from "../../components/post"; import { isReply } from "../../helpers/nostr-event"; -import { useEventTimelineLoader } from "../../hooks/use-event-timeline-loader"; +import { useTimelineLoader } from "../../hooks/use-timeline-loader"; export const UserRepliesTab = ({ pubkey }: { pubkey: string }) => { - const { timeline, more } = useEventTimelineLoader( - { authors: [pubkey], kinds: [1] }, - { filter: isReply, name: "user replies" } + const { loader, events, loading } = useTimelineLoader( + `${pubkey} replies`, + { authors: [pubkey], kinds: [1], since: moment().subtract(4, "hours").unix() }, + { pageSize: moment.duration(1, "day").asSeconds() } ); - - if (timeline.length === 0) { - return ; - } + const timeline = events.filter(isReply); return ( {timeline.map((event) => ( ))} - + {loading ? ( + + ) : ( + + )} ); };