mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-10-09 12:23:37 +02:00
create timeline loader
This commit is contained in:
@@ -3,7 +3,6 @@ import { NostrEvent } from "../types/nostr-event";
|
|||||||
import { NostrQuery } from "../types/nostr-query";
|
import { NostrQuery } from "../types/nostr-query";
|
||||||
import { Relay } from "../services/relays";
|
import { Relay } from "../services/relays";
|
||||||
import relayPool from "../services/relays/relay-pool";
|
import relayPool from "../services/relays/relay-pool";
|
||||||
import { IncomingEvent } from "../services/relays/relay";
|
|
||||||
|
|
||||||
let lastId = 0;
|
let lastId = 0;
|
||||||
|
|
||||||
@@ -73,9 +72,12 @@ export class NostrRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
console.log(`NostrRequest: ${this.id} timed out`);
|
||||||
this.cancel();
|
this.cancel();
|
||||||
}, this.timeout);
|
}, this.timeout);
|
||||||
|
|
||||||
|
console.log(`NostrRequest: ${this.id} started`);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
cancel() {
|
cancel() {
|
||||||
@@ -92,6 +94,8 @@ export class NostrRequest {
|
|||||||
this.relays = new Set();
|
this.relays = new Set();
|
||||||
this.onEvent.complete();
|
this.onEvent.complete();
|
||||||
|
|
||||||
|
console.log(`NostrRequest: ${this.id} complete`);
|
||||||
|
|
||||||
return this;
|
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";
|
import { isETag, isPTag, NostrEvent } from "../types/nostr-event";
|
||||||
|
|
||||||
export function isReply(event: NostrEvent) {
|
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) {
|
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 { useEffect, useState } from "react";
|
||||||
import { Flex, Text } from "@chakra-ui/react";
|
import { Button, Flex, Spinner } from "@chakra-ui/react";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { mergeAll, from } from "rxjs";
|
import { mergeAll, from } from "rxjs";
|
||||||
import { Post } from "../../components/post";
|
import { Post } from "../../components/post";
|
||||||
import { useEventDir } from "../../hooks/use-event-dir";
|
|
||||||
import useSubject from "../../hooks/use-subject";
|
import useSubject from "../../hooks/use-subject";
|
||||||
import { useSubscription } from "../../hooks/use-subscription";
|
|
||||||
import { useUserContacts } from "../../hooks/use-user-contacts";
|
import { useUserContacts } from "../../hooks/use-user-contacts";
|
||||||
import identity from "../../services/identity";
|
import identity from "../../services/identity";
|
||||||
import settings from "../../services/settings";
|
|
||||||
import userContactsService from "../../services/user-contacts";
|
import userContactsService from "../../services/user-contacts";
|
||||||
|
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
|
||||||
|
import { isPost } from "../../helpers/nostr-event";
|
||||||
|
|
||||||
function useExtendedContacts(pubkey: string) {
|
function useExtendedContacts(pubkey: string) {
|
||||||
const [extendedContacts, setExtendedContacts] = useState<string[]>([]);
|
const [extendedContacts, setExtendedContacts] = useState<string[]>([]);
|
||||||
@@ -42,27 +41,24 @@ export const DiscoverTab = () => {
|
|||||||
const pubkey = useSubject(identity.pubkey);
|
const pubkey = useSubject(identity.pubkey);
|
||||||
|
|
||||||
const contactsOfContacts = useExtendedContacts(pubkey);
|
const contactsOfContacts = useExtendedContacts(pubkey);
|
||||||
|
const { loader, events, loading } = useTimelineLoader(
|
||||||
const [since, setSince] = useState(moment().subtract(1, "hour"));
|
`discover-posts`,
|
||||||
const [after, setAfter] = useState(moment());
|
{ authors: contactsOfContacts, kinds: [1], since: moment().subtract(1, "hour").unix() },
|
||||||
|
{ pageSize: moment.duration(1, "hour").asSeconds(), enabled: contactsOfContacts.length > 0 }
|
||||||
const sub = useSubscription(
|
|
||||||
{
|
|
||||||
authors: contactsOfContacts,
|
|
||||||
kinds: [1],
|
|
||||||
since: since.unix(),
|
|
||||||
},
|
|
||||||
{ name: "home-discover", enabled: contactsOfContacts.length > 0 }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { events } = useEventDir(sub);
|
const timeline = events.filter(isPost);
|
||||||
const timeline = Object.values(events).sort((a, b) => b.created_at - a.created_at);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" overflow="auto" gap="2">
|
<Flex direction="column" overflow="auto" gap="2">
|
||||||
{timeline.map((event) => (
|
{timeline.map((event) => (
|
||||||
<Post key={event.id} event={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>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
import { Flex } from "@chakra-ui/react";
|
import { Button, Flex, Spinner } from "@chakra-ui/react";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { Post } from "../../components/post";
|
import { Post } from "../../components/post";
|
||||||
import { isPost } from "../../helpers/nostr-event";
|
import { isPost } from "../../helpers/nostr-event";
|
||||||
import { useEventTimelineLoader } from "../../hooks/use-event-timeline-loader";
|
|
||||||
import useSubject from "../../hooks/use-subject";
|
import useSubject from "../../hooks/use-subject";
|
||||||
|
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
|
||||||
import { useUserContacts } from "../../hooks/use-user-contacts";
|
import { useUserContacts } from "../../hooks/use-user-contacts";
|
||||||
import identity from "../../services/identity";
|
import identity from "../../services/identity";
|
||||||
|
|
||||||
@@ -12,26 +12,24 @@ export const FollowingPostsTab = () => {
|
|||||||
const contacts = useUserContacts(pubkey);
|
const contacts = useUserContacts(pubkey);
|
||||||
|
|
||||||
const following = contacts?.contacts || [];
|
const following = contacts?.contacts || [];
|
||||||
|
const { loader, events, loading } = useTimelineLoader(
|
||||||
const { timeline } = useEventTimelineLoader(
|
`following-posts`,
|
||||||
{
|
{ authors: following, kinds: [1], since: moment().subtract(2, "hour").unix() },
|
||||||
authors: following,
|
{ pageSize: moment.duration(2, "hour").asSeconds(), enabled: following.length > 0 }
|
||||||
kinds: [1],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "following-posts",
|
|
||||||
enabled: following.length > 0,
|
|
||||||
filter: isPost,
|
|
||||||
initialSince: moment().subtract(1, "hour").unix(),
|
|
||||||
pageSize: moment.duration(1, "hour").asSeconds(),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const timeline = events.filter(isPost);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" overflow="auto" gap="2">
|
<Flex direction="column" overflow="auto" gap="2">
|
||||||
{timeline.map((event) => (
|
{timeline.map((event) => (
|
||||||
<Post key={event.id} event={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>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -13,7 +13,6 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
Box,
|
Box,
|
||||||
Image,
|
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { UserPostsTab } from "./posts";
|
import { UserPostsTab } from "./posts";
|
||||||
@@ -27,7 +26,6 @@ import { normalizeToHex } from "../../helpers/nip-19";
|
|||||||
import { Page } from "../../components/page";
|
import { Page } from "../../components/page";
|
||||||
import { UserProfileMenu } from "./user-profile-menu";
|
import { UserProfileMenu } from "./user-profile-menu";
|
||||||
import { UserFollowersTab } from "./followers";
|
import { UserFollowersTab } from "./followers";
|
||||||
import { useUserFollowers } from "../../hooks/use-user-followers";
|
|
||||||
import { UserRepliesTab } from "./replies";
|
import { UserRepliesTab } from "./replies";
|
||||||
|
|
||||||
export const UserPage = () => {
|
export const UserPage = () => {
|
||||||
@@ -62,7 +60,6 @@ export const UserView = ({ pubkey }: UserViewProps) => {
|
|||||||
|
|
||||||
const metadata = useUserMetadata(pubkey, [], true);
|
const metadata = useUserMetadata(pubkey, [], true);
|
||||||
const label = getUserDisplayName(metadata, pubkey);
|
const label = getUserDisplayName(metadata, pubkey);
|
||||||
const followers = useUserFollowers(pubkey);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" alignItems="stretch" gap="2" overflow="hidden" height="100%">
|
<Flex direction="column" alignItems="stretch" gap="2" overflow="hidden" height="100%">
|
||||||
@@ -81,7 +78,7 @@ export const UserView = ({ pubkey }: UserViewProps) => {
|
|||||||
<TabList>
|
<TabList>
|
||||||
<Tab>Posts</Tab>
|
<Tab>Posts</Tab>
|
||||||
<Tab>Replies</Tab>
|
<Tab>Replies</Tab>
|
||||||
<Tab>Followers ({followers?.length})</Tab>
|
<Tab>Followers</Tab>
|
||||||
<Tab>Following</Tab>
|
<Tab>Following</Tab>
|
||||||
<Tab>Relays</Tab>
|
<Tab>Relays</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
|
@@ -1,22 +1,27 @@
|
|||||||
import { Button, Flex, Spinner } from "@chakra-ui/react";
|
import { Button, Flex, Spinner } from "@chakra-ui/react";
|
||||||
|
import moment from "moment";
|
||||||
import { Post } from "../../components/post";
|
import { Post } from "../../components/post";
|
||||||
import { isPost } from "../../helpers/nostr-event";
|
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 }) => {
|
export const UserPostsTab = ({ pubkey }: { pubkey: string }) => {
|
||||||
const { timeline, more } = useEventTimelineLoader(
|
const { loader, events, loading } = useTimelineLoader(
|
||||||
{ authors: [pubkey], kinds: [1] },
|
`${pubkey} posts`,
|
||||||
{ filter: isPost, name: "user posts" }
|
{ authors: [pubkey], kinds: [1], since: moment().subtract(1, "day").unix() },
|
||||||
|
{ pageSize: moment.duration(1, "day").asSeconds() }
|
||||||
);
|
);
|
||||||
|
const timeline = events.filter(isPost);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" gap="2" pr="2" pl="2">
|
<Flex direction="column" gap="2" pr="2" pl="2">
|
||||||
{timeline.length > 0 ? (
|
{timeline.map((event) => (
|
||||||
timeline.map((event) => <Post key={event.id} event={event} />)
|
<Post key={event.id} event={event} />
|
||||||
) : (
|
))}
|
||||||
|
{loading ? (
|
||||||
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
|
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => loader?.loadMore()}>Load More</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => more(1)}>Load More</Button>
|
|
||||||
</Flex>
|
</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 { Post } from "../../components/post";
|
||||||
import { isReply } from "../../helpers/nostr-event";
|
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 }) => {
|
export const UserRepliesTab = ({ pubkey }: { pubkey: string }) => {
|
||||||
const { timeline, more } = useEventTimelineLoader(
|
const { loader, events, loading } = useTimelineLoader(
|
||||||
{ authors: [pubkey], kinds: [1] },
|
`${pubkey} replies`,
|
||||||
{ filter: isReply, name: "user replies" }
|
{ authors: [pubkey], kinds: [1], since: moment().subtract(4, "hours").unix() },
|
||||||
|
{ pageSize: moment.duration(1, "day").asSeconds() }
|
||||||
);
|
);
|
||||||
|
const timeline = events.filter(isReply);
|
||||||
if (timeline.length === 0) {
|
|
||||||
return <SkeletonText />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" gap="2" pr="2" pl="2">
|
<Flex direction="column" gap="2" pr="2" pl="2">
|
||||||
{timeline.map((event) => (
|
{timeline.map((event) => (
|
||||||
<Post key={event.id} event={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>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user