mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-17 21:31:43 +01:00
create timeline loader
This commit is contained in:
parent
77995c6638
commit
c0b352e09f
@ -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;
|
||||
}
|
||||
}
|
||||
|
91
src/classes/timeline-loader.ts
Normal file
91
src/classes/timeline-loader.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
43
src/hooks/use-timeline-loader.ts
Normal file
43
src/hooks/use-timeline-loader.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user