create timeline loader

This commit is contained in:
hzrd149 2023-02-07 17:04:18 -06:00
parent 77995c6638
commit c0b352e09f
10 changed files with 193 additions and 109 deletions

View File

@ -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;
}
}

View File

@ -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<Options>;
export class TimelineLoader {
relays: string[];
query: NostrQueryWithStart;
events = new BehaviorSubject<NostrEvent[]>([]);
loading = new BehaviorSubject(false);
page = new BehaviorSubject(0);
private seenEvents = new Set<string>();
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();
}
}

View File

@ -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) {

View File

@ -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<NostrQuery, "since" | "until">, opts?: Options) {
const enabled = opts?.enabled ?? true;
const pageSize = opts?.pageSize ?? moment.duration(1, "day").asSeconds();
const [until, setUntil] = useState<number | undefined>(undefined);
const [since, setSince] = useState<number>(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,
};
}

View File

@ -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<TimelineLoader | null>(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,
};
}

View File

@ -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<string[]>([]);
@ -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 (
<Flex direction="column" overflow="auto" gap="2">
{timeline.map((event) => (
<Post key={event.id} event={event} />
))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
) : (
<Button onClick={() => loader?.loadMore()}>Load More</Button>
)}
</Flex>
);
};

View File

@ -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 (
<Flex direction="column" overflow="auto" gap="2">
{timeline.map((event) => (
<Post key={event.id} event={event} />
))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
) : (
<Button onClick={() => loader?.loadMore()}>Load More</Button>
)}
</Flex>
);
};

View File

@ -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 (
<Flex direction="column" alignItems="stretch" gap="2" overflow="hidden" height="100%">
@ -81,7 +78,7 @@ export const UserView = ({ pubkey }: UserViewProps) => {
<TabList>
<Tab>Posts</Tab>
<Tab>Replies</Tab>
<Tab>Followers ({followers?.length})</Tab>
<Tab>Followers</Tab>
<Tab>Following</Tab>
<Tab>Relays</Tab>
</TabList>

View File

@ -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 (
<Flex direction="column" gap="2" pr="2" pl="2">
{timeline.length > 0 ? (
timeline.map((event) => <Post key={event.id} event={event} />)
) : (
{timeline.map((event) => (
<Post key={event.id} event={event} />
))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
) : (
<Button onClick={() => loader?.loadMore()}>Load More</Button>
)}
<Button onClick={() => more(1)}>Load More</Button>
</Flex>
);
};

View File

@ -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 <SkeletonText />;
}
const timeline = events.filter(isReply);
return (
<Flex direction="column" gap="2" pr="2" pl="2">
{timeline.map((event) => (
<Post key={event.id} event={event} />
))}
<Button onClick={() => more(1)}>Load More</Button>
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
) : (
<Button onClick={() => loader?.loadMore()}>Load More</Button>
)}
</Flex>
);
};