rebuild timeline loader

This commit is contained in:
hzrd149 2023-06-29 23:02:26 -05:00
parent 14421ea115
commit b23fe91476
24 changed files with 444 additions and 366 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Rebuild timeline loader class

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Remove broken discover tab

View File

@ -10,7 +10,6 @@ import SettingsView from "./views/settings";
import LoginView from "./views/login"; import LoginView from "./views/login";
import ProfileView from "./views/profile"; import ProfileView from "./views/profile";
import FollowingTab from "./views/home/following-tab"; import FollowingTab from "./views/home/following-tab";
import DiscoverTab from "./views/home/discover-tab";
import GlobalTab from "./views/home/global-tab"; import GlobalTab from "./views/home/global-tab";
import HashTagView from "./views/hashtag"; import HashTagView from "./views/hashtag";
import UserView from "./views/user"; import UserView from "./views/user";
@ -102,7 +101,6 @@ const router = createHashRouter([
children: [ children: [
{ path: "", element: <FollowingTab /> }, { path: "", element: <FollowingTab /> },
{ path: "following", element: <FollowingTab /> }, { path: "following", element: <FollowingTab /> },
{ path: "discover", element: <DiscoverTab /> },
{ path: "global", element: <GlobalTab /> }, { path: "global", element: <GlobalTab /> },
], ],
}, },

View File

@ -4,84 +4,210 @@ import { NostrEvent } from "../types/nostr-event";
import { NostrQuery } from "../types/nostr-query"; import { NostrQuery } from "../types/nostr-query";
import { NostrRequest } from "./nostr-request"; import { NostrRequest } from "./nostr-request";
import { NostrMultiSubscription } from "./nostr-multi-subscription"; import { NostrMultiSubscription } from "./nostr-multi-subscription";
import { PersistentSubject } from "./subject"; import Subject, { PersistentSubject } from "./subject";
type Options = { const BLOCK_SIZE = 10;
name?: string;
pageSize: number;
startLimit: number;
};
export type TimelineLoaderOptions = Partial<Options>;
export class TimelineLoader { type EventFilter = (event: NostrEvent) => boolean;
relays: string[];
class RelayTimelineLoader {
relay: string;
query: NostrQuery; query: NostrQuery;
events = new PersistentSubject<NostrEvent[]>([]); blockSize = BLOCK_SIZE;
loading = new PersistentSubject(false);
page = new PersistentSubject(0); loading = false;
events: NostrEvent[] = [];
/** set to true when the next block produces 0 events */
complete = false;
onEvent = new Subject<NostrEvent>();
onBlockFinish = new Subject<void>();
constructor(relay: string, query: NostrQuery) {
this.relay = relay;
this.query = query;
}
loadNextBlock() {
this.loading = true;
const query: NostrQuery = { ...this.query, limit: this.blockSize };
if (this.events[this.events.length - 1]) {
query.until = this.events[this.events.length - 1].created_at;
}
const request = new NostrRequest([this.relay]);
let gotEvents = 0;
request.onEvent.subscribe((e) => {
if (this.handleEvent(e)) {
gotEvents++;
}
});
request.onComplete.then(() => {
this.loading = false;
if (gotEvents === 0) this.complete = true;
this.onBlockFinish.next();
});
request.start(query);
}
private seenEvents = new Set<string>(); private seenEvents = new Set<string>();
private handleEvent(event: NostrEvent) {
if (!this.seenEvents.has(event.id)) {
this.seenEvents.add(event.id);
this.events = utils.insertEventIntoDescendingList(Array.from(this.events), event);
this.onEvent.next(event);
return true;
}
return false;
}
getLastEvent(nth = 0, filter?: EventFilter) {
const events = filter ? this.events.filter(filter) : this.events;
for (let i = nth; i >= 0; i--) {
const event = events[events.length - 1 - i];
if (event) return event;
}
}
}
export class TimelineLoader {
cursor = dayjs().unix();
query: NostrQuery;
relays: string[];
events = new PersistentSubject<NostrEvent[]>([]);
timeline = new PersistentSubject<NostrEvent[]>([]);
loading = new PersistentSubject(false);
complete = new PersistentSubject(false);
loadNextBlockBuffer = 2;
eventFilter?: (event: NostrEvent) => boolean;
private subscription: NostrMultiSubscription; private subscription: NostrMultiSubscription;
private opts: Options = { pageSize: 60*60, startLimit: 10 };
constructor(relays: string[], query: NostrQuery, opts?: TimelineLoaderOptions) { private relayTimelineLoaders = new Map<string, RelayTimelineLoader>();
constructor(relays: string[], query: NostrQuery, name?: string) {
this.query = query;
this.relays = relays; this.relays = relays;
Object.assign(this.opts, opts);
this.query = { ...query, limit: this.opts.startLimit };
this.subscription = new NostrMultiSubscription(relays, query, opts?.name);
this.subscription = new NostrMultiSubscription(relays, { ...query, limit: BLOCK_SIZE / 2 }, name);
this.subscription.onEvent.subscribe(this.handleEvent, this); this.subscription.onEvent.subscribe(this.handleEvent, this);
this.createLoaders();
} }
setQuery(query: NostrQuery) { private seenEvents = new Set<string>();
this.query = { ...query, limit: this.opts.startLimit };
this.subscription.setQuery(this.query);
}
setRelays(relays: string[]) {
this.relays = relays;
this.subscription.setRelays(relays);
}
private handleEvent(event: NostrEvent) { private handleEvent(event: NostrEvent) {
if (!this.seenEvents.has(event.id)) { if (!this.seenEvents.has(event.id)) {
this.seenEvents.add(event.id); this.seenEvents.add(event.id);
this.events.next(utils.insertEventIntoDescendingList(Array.from(this.events.value), event)); this.events.next(utils.insertEventIntoDescendingList(Array.from(this.events.value), event));
if (this.loading.value) this.loading.next(false);
if (!this.eventFilter || this.eventFilter(event)) {
this.timeline.next(utils.insertEventIntoDescendingList(Array.from(this.timeline.value), event));
}
} }
} }
private getPageDates(page: number) { private createLoaders() {
const start = this.events.value[0]?.created_at ?? dayjs().unix(); for (const relay of this.relays) {
const until = start - page * this.opts.pageSize; if (!this.relayTimelineLoaders.has(relay)) {
const since = until - this.opts.pageSize; const loader = new RelayTimelineLoader(relay, this.query);
this.relayTimelineLoaders.set(relay, loader);
return { loader.onEvent.subscribe(this.handleEvent, this);
until, loader.onBlockFinish.subscribe(this.updateLoading, this);
since, loader.onBlockFinish.subscribe(this.updateComplete, this);
}; }
}
}
private removeLoaders(filter?: (loader: RelayTimelineLoader) => boolean) {
for (const [relay, loader] of this.relayTimelineLoaders) {
if (!filter || filter(loader)) {
loader?.onEvent.unsubscribe(this.handleEvent, this);
loader?.onBlockFinish.unsubscribe(this.updateLoading, this);
loader?.onBlockFinish.unsubscribe(this.updateComplete, this);
this.relayTimelineLoaders.delete(relay);
}
}
} }
loadMore() { setRelays(relays: string[]) {
if (this.loading.value) return; // remove loaders
this.removeLoaders((loader) => !relays.includes(loader.relay));
const query = { ...this.query, ...this.getPageDates(this.page.value) }; this.relays = relays;
const request = new NostrRequest(this.relays); this.createLoaders();
request.onEvent.subscribe(this.handleEvent, this);
request.onComplete.then(() => {
this.loading.next(false);
});
request.start(query);
this.loading.next(true); this.subscription.setRelays(relays);
this.page.next(this.page.value + 1);
} }
setQuery(query: NostrQuery) {
this.removeLoaders();
forgetEvents() { this.query = query;
this.events.next([]); this.events.next([]);
this.timeline.next([]);
this.seenEvents.clear(); this.seenEvents.clear();
this.createLoaders();
// update the subscription
this.subscription.forgetEvents(); this.subscription.forgetEvents();
this.subscription.setQuery({ ...query, limit: BLOCK_SIZE / 2 });
}
setFilter(filter?: (event: NostrEvent) => boolean) {
this.eventFilter = filter;
if (this.eventFilter) {
this.timeline.next(this.events.value.filter(this.eventFilter));
}
}
setCursor(cursor: number) {
this.cursor = cursor;
this.loadNextBlocks();
}
loadNextBlocks() {
let triggeredLoad = false;
for (const [relay, loader] of this.relayTimelineLoaders) {
if (loader.complete || loader.loading) continue;
const event = loader.getLastEvent(this.loadNextBlockBuffer, this.eventFilter);
if (!event || event.created_at >= this.cursor) {
loader.loadNextBlock();
triggeredLoad = true;
}
}
if (triggeredLoad) this.updateLoading();
}
/** @deprecated */
loadMore() {
for (const [relay, loader] of this.relayTimelineLoaders) {
if (loader.complete || loader.loading) continue;
loader.loadNextBlock();
}
}
private updateLoading() {
for (const [relay, loader] of this.relayTimelineLoaders) {
if (loader.loading) {
if (!this.loading.value) {
this.loading.next(true);
return;
}
}
}
if (this.loading.value) this.loading.next(false);
}
private updateComplete() {
if (this.complete.value) return;
for (const [relay, loader] of this.relayTimelineLoaders) {
if (!loader.complete) {
this.complete.next(false);
return;
}
}
return this.complete.next(true);
} }
open() { open() {
this.subscription.open(); this.subscription.open();
@ -89,4 +215,13 @@ export class TimelineLoader {
close() { close() {
this.subscription.close(); this.subscription.close();
} }
// TODO: this is only needed because the current logic dose not remove events when the relay they where fetched from is removed
/** @deprecated */
forgetEvents() {
this.events.next([]);
this.timeline.next([]);
this.seenEvents.clear();
this.subscription.forgetEvents();
}
} }

View File

@ -1,80 +1,8 @@
import dayjs from "dayjs";
import { NostrEvent } from "../types/nostr-event";
import { NostrQuery } from "../types/nostr-query";
import { NostrRequest } from "./nostr-request";
import { NostrMultiSubscription } from "./nostr-multi-subscription";
import { PersistentSubject } from "./subject";
import { utils } from "nostr-tools";
import { truncatedId } from "../helpers/nostr-event"; import { truncatedId } from "../helpers/nostr-event";
import { TimelineLoader } from "./timeline-loader";
const PAGE_SIZE = 60 * 60 * 24 * 7; //in seconds export default class UserTimeline extends TimelineLoader {
export default class UserTimeline {
pubkey: string;
query: NostrQuery;
events = new PersistentSubject<NostrEvent[]>([]);
loading = new PersistentSubject(false);
page = new PersistentSubject(0);
private seenEvents = new Set<string>();
private subscription: NostrMultiSubscription;
constructor(pubkey: string) { constructor(pubkey: string) {
this.pubkey = pubkey; super([], { authors: [pubkey], kinds: [1, 6] }, truncatedId(pubkey) + "-timeline");
this.query = { authors: [pubkey], kinds: [1, 6], limit: 20 };
this.subscription = new NostrMultiSubscription([], this.query, truncatedId(pubkey) + "-timeline");
this.subscription.onEvent.subscribe(this.handleEvent, this);
}
setRelays(relays: string[]) {
this.subscription.setRelays(relays);
}
private handleEvent(event: NostrEvent) {
if (!this.seenEvents.has(event.id)) {
this.seenEvents.add(event.id);
this.events.next(utils.insertEventIntoDescendingList(Array.from(this.events.value), event));
if (this.loading.value) this.loading.next(false);
}
}
private getPageDates(page: number) {
const start = this.events.value[0]?.created_at ?? dayjs().unix();
const until = start - page * PAGE_SIZE;
const since = until - PAGE_SIZE;
return {
until,
since,
};
}
loadMore() {
if (this.loading.value) return;
const query = { ...this.query, ...this.getPageDates(this.page.value) };
const request = new NostrRequest(this.subscription.relayUrls);
request.onEvent.subscribe(this.handleEvent, this);
request.onComplete.then(() => {
this.loading.next(false);
});
request.start(query);
this.loading.next(true);
this.page.next(this.page.value + 1);
}
forgetEvents() {
this.events.next([]);
this.seenEvents.clear();
this.subscription.forgetEvents();
}
open() {
this.subscription.open();
}
close() {
this.subscription.close();
} }
} }

View File

@ -4,7 +4,7 @@ import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
export function embedEmoji(content: EmbedableContent, note: NostrEvent | DraftNostrEvent) { export function embedEmoji(content: EmbedableContent, note: NostrEvent | DraftNostrEvent) {
return embedJSX(content, { return embedJSX(content, {
regexp: /:([a-zA-Z0-9]+):/i, regexp: /:([a-zA-Z0-9_]+):/i,
render: (match) => { render: (match) => {
const emojiTag = note.tags.find( const emojiTag = note.tags.find(
(tag) => tag[0] === "emoji" && tag[1].toLowerCase() === match[1].toLowerCase() && tag[2] (tag) => tag[0] === "emoji" && tag[1].toLowerCase() === match[1].toLowerCase() && tag[2]

View File

@ -0,0 +1,27 @@
import { Alert, AlertIcon, Button, Spinner } from "@chakra-ui/react";
import { TimelineLoader } from "../classes/timeline-loader";
import useSubject from "../hooks/use-subject";
export default function LoadMoreButton({ timeline }: { timeline: TimelineLoader }) {
const loading = useSubject(timeline.loading);
const complete = useSubject(timeline.complete);
if (complete) {
return (
<Alert status="info" flexShrink={0}>
<AlertIcon />
No more events
</Alert>
);
}
if (loading) {
return <Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />;
}
return (
<Button onClick={() => timeline.loadMore()} flexShrink={0} size="lg" mx="auto" minW="lg">
Load More
</Button>
);
}

View File

@ -1,7 +1,7 @@
import { Button, ButtonProps, useDisclosure } from "@chakra-ui/react"; import { Button, ButtonProps, useDisclosure } from "@chakra-ui/react";
import { useMemo } from "react"; import { useMemo } from "react";
import { readablizeSats } from "../../helpers/bolt11"; import { readablizeSats } from "../../helpers/bolt11";
import { parseZapNote, totalZaps } from "../../helpers/zaps"; import { parseZapEvent, totalZaps } from "../../helpers/zaps";
import { useCurrentAccount } from "../../hooks/use-current-account"; import { useCurrentAccount } from "../../hooks/use-current-account";
import useEventZaps from "../../hooks/use-event-zaps"; import useEventZaps from "../../hooks/use-event-zaps";
import { useUserMetadata } from "../../hooks/use-user-metadata"; import { useUserMetadata } from "../../hooks/use-user-metadata";
@ -19,7 +19,7 @@ export default function NoteZapButton({ note, ...props }: { note: NostrEvent } &
const parsed = []; const parsed = [];
for (const zap of zaps) { for (const zap of zaps) {
try { try {
parsed.push(parseZapNote(zap)); parsed.push(parseZapEvent(zap));
} catch (e) {} } catch (e) {}
} }
return parsed; return parsed;

View File

@ -17,7 +17,7 @@ import { UserAvatarLink } from "../user-avatar-link";
import { UserLink } from "../user-link"; import { UserLink } from "../user-link";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { DislikeIcon, LightningIcon, LikeIcon } from "../icons"; import { DislikeIcon, LightningIcon, LikeIcon } from "../icons";
import { parseZapNote } from "../../helpers/zaps"; import { parseZapEvent } from "../../helpers/zaps";
import { readablizeSats } from "../../helpers/bolt11"; import { readablizeSats } from "../../helpers/bolt11";
import useEventReactions from "../../hooks/use-event-reactions"; import useEventReactions from "../../hooks/use-event-reactions";
import useEventZaps from "../../hooks/use-event-zaps"; import useEventZaps from "../../hooks/use-event-zaps";
@ -50,7 +50,7 @@ const ReactionEvent = React.memo(({ event }: { event: NostrEvent }) => (
const ZapEvent = React.memo(({ event }: { event: NostrEvent }) => { const ZapEvent = React.memo(({ event }: { event: NostrEvent }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
try { try {
const { payment, request } = parseZapNote(event); const { payment, request } = parseZapEvent(event);
if (!payment.amount) return null; if (!payment.amount) return null;

View File

@ -55,7 +55,7 @@ export function totalZaps(events: NostrEvent[]) {
return total; return total;
} }
export function parseZapNote(event: NostrEvent) { function parseZapEvent(event: NostrEvent) {
const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1]; const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1];
if (!zapRequestStr) throw new Error("no description tag"); if (!zapRequestStr) throw new Error("no description tag");
@ -77,3 +77,14 @@ export function parseZapNote(event: NostrEvent) {
eventId, eventId,
}; };
} }
const zapEventCache = new Map<string, ReturnType<typeof parseZapEvent>>();
function cachedParseZapEvent(event: NostrEvent) {
let result = zapEventCache.get(event.id);
if (result) return result;
result = parseZapEvent(event);
if (result) zapEventCache.set(event.id, result);
return result;
}
export { cachedParseZapEvent as parseZapEvent };

View File

@ -0,0 +1,18 @@
import { MutableRefObject, useState } from "react";
import { useInterval } from "react-use";
export default function useScrollPosition(ref: MutableRefObject<HTMLDivElement | null>, interval = 1000) {
const [percent, setPercent] = useState(0);
useInterval(() => {
if (!ref.current) return;
const scrollBottom = ref.current.scrollTop + ref.current.getClientRects()[0].height;
if (ref.current.scrollHeight === 0) {
return setPercent(1);
}
const scrollPosition = Math.min(scrollBottom / ref.current.scrollHeight, 1);
setPercent(scrollPosition);
}, interval);
return percent;
}

View File

@ -1,27 +1,37 @@
import { useCallback, useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useUnmount } from "react-use"; import { useUnmount } from "react-use";
import { TimelineLoader, TimelineLoaderOptions } from "../classes/timeline-loader"; import { TimelineLoader } from "../classes/timeline-loader";
import { NostrQuery } from "../types/nostr-query"; import { NostrQuery } from "../types/nostr-query";
import useSubject from "./use-subject"; import useSubject from "./use-subject";
import { NostrEvent } from "../types/nostr-event";
type Options = TimelineLoaderOptions & { type Options = {
enabled?: boolean; enabled?: boolean;
eventFilter?: (event: NostrEvent) => boolean;
cursor?: number;
name?: string;
}; };
export function useTimelineLoader(key: string, relays: string[], query: NostrQuery, opts?: Options) { export function useTimelineLoader(key: string, relays: string[], query: NostrQuery, opts?: Options) {
if (opts && !opts.name) opts.name = key; if (opts && !opts.name) opts.name = key;
const ref = useRef<TimelineLoader | null>(null); const ref = useRef<TimelineLoader | null>(null);
const loader = (ref.current = ref.current || new TimelineLoader(relays, query, opts)); const loader = (ref.current = ref.current || new TimelineLoader(relays, query, opts?.name));
useEffect(() => { useEffect(() => {
loader.forgetEvents();
loader.setQuery(query); loader.setQuery(query);
}, [key]); }, [JSON.stringify(query)]);
useEffect(() => { useEffect(() => {
loader.setRelays(relays); loader.setRelays(relays);
}, [relays.join("|")]); }, [relays.join("|")]);
useEffect(() => {
loader.setFilter(opts?.eventFilter);
}, [opts?.eventFilter]);
useEffect(() => {
if (opts?.cursor !== undefined) {
loader.setCursor(opts.cursor);
}
}, [opts?.cursor]);
const enabled = opts?.enabled ?? true; const enabled = opts?.enabled ?? true;
useEffect(() => { useEffect(() => {
@ -35,17 +45,14 @@ export function useTimelineLoader(key: string, relays: string[], query: NostrQue
loader.close(); loader.close();
}); });
const events = useSubject(loader.events); const timeline = useSubject(loader.timeline);
const loading = useSubject(loader.loading); const loading = useSubject(loader.loading);
const complete = useSubject(loader.complete);
const loadMore = useCallback(() => {
if (enabled) loader.loadMore();
}, [enabled]);
return { return {
loader, loader,
events, timeline,
loading, loading,
loadMore, complete,
}; };
} }

View File

@ -215,7 +215,7 @@ export function mediaSetup(ogObject: OgObjectInteral) {
fields fields
.filter((item) => item.multiple && item.fieldName && item.fieldName.match("(ogImage|ogVideo|twitter|musicSong).*")) .filter((item) => item.multiple && item.fieldName && item.fieldName.match("(ogImage|ogVideo|twitter|musicSong).*"))
.forEach((item) => { .forEach((item) => {
// @ts-ignore // @ts-ignore
delete ogObject[item.fieldName]; delete ogObject[item.fieldName];
}); });

View File

@ -9,7 +9,6 @@ import {
FormLabel, FormLabel,
IconButton, IconButton,
Input, Input,
Spinner,
Switch, Switch,
useDisclosure, useDisclosure,
useEditableControls, useEditableControls,
@ -22,8 +21,10 @@ import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isReply } from "../../helpers/nostr-event"; import { isReply } from "../../helpers/nostr-event";
import { Note } from "../../components/note"; import { Note } from "../../components/note";
import { CheckIcon, EditIcon, RelayIcon } from "../../components/icons"; import { CheckIcon, EditIcon, RelayIcon } from "../../components/icons";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import RelaySelectionModal from "./relay-selection-modal"; import RelaySelectionModal from "./relay-selection-modal";
import { NostrEvent } from "../../types/nostr-event";
import LoadMoreButton from "../../components/load-more-button";
function EditableControls() { function EditableControls() {
const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = useEditableControls(); const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = useEditableControls();
@ -51,18 +52,23 @@ export default function HashTagView() {
const relaysModal = useDisclosure(); const relaysModal = useDisclosure();
const { isOpen: showReplies, onToggle } = useDisclosure(); const { isOpen: showReplies, onToggle } = useDisclosure();
const { events, loading, loadMore, loader } = useTimelineLoader(
const eventFilter = useCallback(
(event: NostrEvent) => {
return showReplies ? true : !isReply(event);
},
[showReplies]
);
const { timeline, loader } = useTimelineLoader(
`${hashtag}-hashtag`, `${hashtag}-hashtag`,
selectedRelays, selectedRelays,
{ kinds: [1], "#t": [hashtag] }, { kinds: [1], "#t": [hashtag] },
{ pageSize: 60 * 10 } { eventFilter }
); );
const timeline = showReplies ? events : events.filter((e) => !isReply(e));
return ( return (
<> <>
<Flex direction="column" gap="4" overflow="auto" flex={1} pb="4" pt="4" pl="1" pr="1"> <Flex direction="column" gap="4" overflowY="auto" overflowX="hidden" flex={1} pb="4" pt="4" pl="1" pr="1">
<Flex gap="4" alignItems="center" wrap="wrap"> <Flex gap="4" alignItems="center" wrap="wrap">
<Editable <Editable
value={editableHashtag} value={editableHashtag}
@ -95,13 +101,8 @@ export default function HashTagView() {
{timeline.map((event) => ( {timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={600} /> <Note key={event.id} event={event} maxHeight={600} />
))} ))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} /> <LoadMoreButton timeline={loader} />
) : (
<Button onClick={() => loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex> </Flex>
{relaysModal.isOpen && ( {relaysModal.isOpen && (

View File

@ -1,97 +0,0 @@
import { useMemo } from "react";
import { Button, Flex, Spinner } from "@chakra-ui/react";
import dayjs from "dayjs";
import { Note } from "../../components/note";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isReply, truncatedId } from "../../helpers/nostr-event";
import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useCurrentAccount } from "../../hooks/use-current-account";
import userContactsService, { UserContacts } from "../../services/user-contacts";
import { PersistentSubject } from "../../classes/subject";
import useSubject from "../../hooks/use-subject";
import { useThrottle } from "react-use";
import RequireCurrentAccount from "../../providers/require-current-account";
class DiscoverContacts {
pubkey: string;
relays: string[];
pubkeys = new PersistentSubject<string[]>([]);
constructor(pubkey: string, relays: string[]) {
this.pubkey = pubkey;
this.relays = relays;
userContactsService.requestContacts(pubkey, relays).subscribe(this.handleContacts, this);
}
private personalContacts: UserContacts | undefined;
handleContacts(contacts: UserContacts) {
if (contacts.pubkey === this.pubkey) {
this.personalContacts = contacts;
// unsubscribe from old contacts
if (this.pubkeys.value.length > 0) {
for (const key of this.pubkeys.value) {
userContactsService.getSubject(key).unsubscribe(this.handleContacts, this);
}
this.pubkeys.next([]);
}
// request new contacts
for (const key of contacts.contacts) {
userContactsService.requestContacts(key, this.relays).subscribe(this.handleContacts, this);
}
} else {
// add the pubkeys to contacts
const keysToAdd = contacts.contacts.filter(
(key) =>
(!this.personalContacts || !this.personalContacts.contacts.includes(key)) && !this.pubkeys.value.includes(key)
);
this.pubkeys.next([...this.pubkeys.value, ...keysToAdd]);
}
}
cleanup() {
userContactsService.getSubject(this.pubkey).unsubscribe(this.handleContacts, this);
for (const key of this.pubkeys.value) {
userContactsService.getSubject(key).unsubscribe(this.handleContacts, this);
}
}
}
function DiscoverTabBody() {
useAppTitle("discover");
const account = useCurrentAccount()!;
const relays = useReadRelayUrls();
const discover = useMemo(() => new DiscoverContacts(account.pubkey, relays), [account.pubkey, relays.join("|")]);
const pubkeys = useSubject(discover.pubkeys);
const throttledPubkeys = useThrottle(pubkeys, 1000);
const { events, loading, loadMore } = useTimelineLoader(
`${truncatedId(account.pubkey)}-discover`,
relays,
{ authors: throttledPubkeys, kinds: [1], since: dayjs().subtract(1, "hour").unix() },
{ pageSize: 60 * 60, enabled: throttledPubkeys.length > 0 }
);
const timeline = events.filter((e) => !isReply(e));
return (
<Flex direction="column" gap="2">
{timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={600} />
))}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
</Flex>
);
}
export default function DiscoverTab() {
return (
<RequireCurrentAccount>
<DiscoverTabBody />
</RequireCurrentAccount>
);
}

View File

@ -1,17 +1,20 @@
import { Button, Flex, FormControl, FormLabel, Spinner, Switch } from "@chakra-ui/react"; import { Button, Flex, FormControl, FormLabel, Spinner, Switch } from "@chakra-ui/react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import dayjs from "dayjs"; import { useInterval } from "react-use";
import { Note } from "../../components/note"; import { Note } from "../../components/note";
import { isReply, truncatedId } from "../../helpers/nostr-event"; import { isReply, truncatedId } from "../../helpers/nostr-event";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { useUserContacts } from "../../hooks/use-user-contacts"; import { useUserContacts } from "../../hooks/use-user-contacts";
import { AddIcon } from "@chakra-ui/icons"; import { AddIcon } from "@chakra-ui/icons";
import { useContext } from "react"; import { useCallback, useContext, useRef } from "react";
import { PostModalContext } from "../../providers/post-modal-provider"; import { PostModalContext } from "../../providers/post-modal-provider";
import { useReadRelayUrls } from "../../hooks/use-client-relays"; import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useCurrentAccount } from "../../hooks/use-current-account"; import { useCurrentAccount } from "../../hooks/use-current-account";
import RepostNote from "../../components/repost-note"; import RepostNote from "../../components/repost-note";
import RequireCurrentAccount from "../../providers/require-current-account"; import RequireCurrentAccount from "../../providers/require-current-account";
import { NostrEvent } from "../../types/nostr-event";
import useScrollPosition from "../../hooks/use-scroll-position";
import LoadMoreButton from "../../components/load-more-button";
function FollowingTabBody() { function FollowingTabBody() {
const account = useCurrentAccount()!; const account = useCurrentAccount()!;
@ -24,19 +27,38 @@ function FollowingTabBody() {
showReplies ? setSearch({}) : setSearch({ replies: "show" }); showReplies ? setSearch({}) : setSearch({ replies: "show" });
}; };
const following = contacts?.contacts || []; const scrollBox = useRef<HTMLDivElement | null>(null);
const { events, loading, loadMore } = useTimelineLoader( const scrollPosition = useScrollPosition(scrollBox);
`${truncatedId(account.pubkey)}-following-posts`,
readRelays, const eventFilter = useCallback(
{ authors: following, kinds: [1, 6], since: dayjs().subtract(2, "hour").unix() }, (event: NostrEvent) => {
{ pageSize: 60 * 60, enabled: following.length > 0 } if (!showReplies && isReply(event)) return false;
return true;
},
[showReplies]
); );
const timeline = showReplies ? events : events.filter((e) => !isReply(e)); const following = contacts?.contacts || [];
const { timeline, loader } = useTimelineLoader(
`${truncatedId(account.pubkey)}-following`,
readRelays,
{ authors: following, kinds: [1, 6] },
{ enabled: following.length > 0, eventFilter }
);
useInterval(() => {
if (scrollPosition > 0.9) loader.loadMore();
}, 1000);
return ( return (
<Flex direction="column" gap="2"> <Flex py="4" direction="column" gap="2" overflowY="auto" overflowX="hidden" ref={scrollBox}>
<Button variant="outline" leftIcon={<AddIcon />} onClick={() => openModal()} isDisabled={account.readonly}> <Button
variant="outline"
leftIcon={<AddIcon />}
onClick={() => openModal()}
isDisabled={account.readonly}
flexShrink={0}
>
New Post New Post
</Button> </Button>
<FormControl display="flex" alignItems="center"> <FormControl display="flex" alignItems="center">
@ -52,7 +74,8 @@ function FollowingTabBody() {
<Note key={event.id} event={event} maxHeight={600} /> <Note key={event.id} event={event} maxHeight={600} />
) )
)} )}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
<LoadMoreButton timeline={loader} />
</Flex> </Flex>
); );
} }

View File

@ -1,4 +1,5 @@
import { Button, Flex, FormControl, FormLabel, Select, Spinner, Switch, useDisclosure } from "@chakra-ui/react"; import { useCallback } from "react";
import { Flex, FormControl, FormLabel, Select, Switch, useDisclosure } from "@chakra-ui/react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { Note } from "../../components/note"; import { Note } from "../../components/note";
import { unique } from "../../helpers/array"; import { unique } from "../../helpers/array";
@ -6,6 +7,8 @@ import { isReply } from "../../helpers/nostr-event";
import { useAppTitle } from "../../hooks/use-app-title"; import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays"; import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import LoadMoreButton from "../../components/load-more-button";
export default function GlobalTab() { export default function GlobalTab() {
useAppTitle("global"); useAppTitle("global");
@ -17,21 +20,27 @@ export default function GlobalTab() {
setSearchParams({ relay: url }); setSearchParams({ relay: url });
} else setSearchParams({}); } else setSearchParams({});
}; };
const { isOpen: showReplies, onToggle } = useDisclosure();
const availableRelays = unique([...defaultRelays, selectedRelay]).filter(Boolean); const availableRelays = unique([...defaultRelays, selectedRelay]).filter(Boolean);
const { isOpen: showReplies, onToggle } = useDisclosure(); const eventFilter = useCallback(
const { events, loading, loadMore, loader } = useTimelineLoader( (event: NostrEvent) => {
if (!showReplies && isReply(event)) return false;
return true;
},
[showReplies]
);
const { timeline, loader } = useTimelineLoader(
`global`, `global`,
selectedRelay ? [selectedRelay] : [], selectedRelay ? [selectedRelay] : [],
{ kinds: [1] }, { kinds: [1] },
{ pageSize: 60*10 } { eventFilter }
); );
const timeline = showReplies ? events : events.filter((e) => !isReply(e));
return ( return (
<Flex direction="column" gap="2"> <Flex py="4" direction="column" gap="2" overflowY="auto" overflowX="hidden">
<Flex gap="2"> <Flex gap="2">
<Select <Select
placeholder="Select Relay" placeholder="Select Relay"
@ -39,7 +48,6 @@ export default function GlobalTab() {
value={selectedRelay} value={selectedRelay}
onChange={(e) => { onChange={(e) => {
setSelectedRelay(e.target.value); setSelectedRelay(e.target.value);
loader.forgetEvents();
}} }}
> >
{availableRelays.map((url) => ( {availableRelays.map((url) => (
@ -58,7 +66,8 @@ export default function GlobalTab() {
{timeline.map((event) => ( {timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={600} /> <Note key={event.id} event={event} maxHeight={600} />
))} ))}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
<LoadMoreButton timeline={loader} />
</Flex> </Flex>
); );
} }

View File

@ -3,7 +3,7 @@ import { Outlet, useMatches, useNavigate } from "react-router-dom";
const tabs = [ const tabs = [
{ label: "Following", path: "/following" }, { label: "Following", path: "/following" },
{ label: "Discover", path: "/discover" }, // { label: "Discover", path: "/discover" },
// { label: "Popular", path: "/popular" }, // { label: "Popular", path: "/popular" },
{ label: "Global", path: "/global" }, { label: "Global", path: "/global" },
]; ];
@ -30,9 +30,9 @@ export default function HomeView() {
<Tab key={label}>{label}</Tab> <Tab key={label}>{label}</Tab>
))} ))}
</TabList> </TabList>
<TabPanels overflow="auto" height="100%"> <TabPanels overflow="hidden" h="full">
{tabs.map(({ label }) => ( {tabs.map(({ label }) => (
<TabPanel key={label} pr={0} pl={0}> <TabPanel key={label} p={0} overflow="hidden" h="full" display="flex" flexDirection="column">
<Outlet /> <Outlet />
</TabPanel> </TabPanel>
))} ))}

View File

@ -1,6 +1,6 @@
import { Button, Card, CardBody, CardHeader, Flex, Spinner, Text } from "@chakra-ui/react"; import { memo, useCallback } from "react";
import { Card, CardBody, CardHeader, Flex, Text } from "@chakra-ui/react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { memo } from "react";
import { UserAvatar } from "../../components/user-avatar"; import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link"; import { UserLink } from "../../components/user-link";
import { useReadRelayUrls } from "../../hooks/use-client-relays"; import { useReadRelayUrls } from "../../hooks/use-client-relays";
@ -9,6 +9,7 @@ import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
import { NoteLink } from "../../components/note-link"; import { NoteLink } from "../../components/note-link";
import RequireCurrentAccount from "../../providers/require-current-account"; import RequireCurrentAccount from "../../providers/require-current-account";
import LoadMoreButton from "../../components/load-more-button";
const Kind1Notification = ({ event }: { event: NostrEvent }) => ( const Kind1Notification = ({ event }: { event: NostrEvent }) => (
<Card size="sm" variant="outline"> <Card size="sm" variant="outline">
@ -37,26 +38,25 @@ const NotificationItem = memo(({ event }: { event: NostrEvent }) => {
function NotificationsPage() { function NotificationsPage() {
const readRelays = useReadRelayUrls(); const readRelays = useReadRelayUrls();
const account = useCurrentAccount()!; const account = useCurrentAccount()!;
const { events, loading, loadMore } = useTimelineLoader(
const eventFilter = useCallback((event: NostrEvent) => event.pubkey !== account.pubkey, [account]);
const { timeline, loader } = useTimelineLoader(
"notifications", "notifications",
readRelays, readRelays,
{ {
"#p": [account.pubkey], "#p": [account.pubkey],
kinds: [1], kinds: [1],
}, },
{ pageSize: 60 * 60 * 24 } { eventFilter }
); );
const timeline = events
// ignore events made my the user
.filter((e) => e.pubkey !== account.pubkey);
return ( return (
<Flex direction="column" overflowX="hidden" overflowY="auto" gap="2"> <Flex direction="column" overflowX="hidden" overflowY="auto" gap="2">
{timeline.map((event) => ( {timeline.map((event) => (
<NotificationItem key={event.id} event={event} /> <NotificationItem key={event.id} event={event} />
))} ))}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
<LoadMoreButton timeline={loader} />
</Flex> </Flex>
); );
} }

View File

@ -155,7 +155,7 @@ export default function UserAboutTab() {
)} )}
</Flex> </Flex>
<Accordion allowToggle allowMultiple> <Accordion allowMultiple>
<AccordionItem> <AccordionItem>
<h2> <h2>
<AccordionButton> <AccordionButton>
@ -170,9 +170,7 @@ export default function UserAboutTab() {
<Stat> <Stat>
<StatLabel>Following</StatLabel> <StatLabel>Following</StatLabel>
<StatNumber>{contacts ? readablizeSats(contacts.contacts.length) : "Unknown"}</StatNumber> <StatNumber>{contacts ? readablizeSats(contacts.contacts.length) : "Unknown"}</StatNumber>
{contacts && ( {contacts && <StatHelpText>Updated {dayjs.unix(contacts.created_at).fromNow()}</StatHelpText>}
<StatHelpText>Updated {dayjs.unix(contacts.created_at).fromNow()}</StatHelpText>
)}
</Stat> </Stat>
{stats && ( {stats && (

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { Box, Button, Flex, Grid, IconButton, Spinner } from "@chakra-ui/react"; import { Box, Flex, Grid, IconButton } from "@chakra-ui/react";
import { useNavigate, useOutletContext } from "react-router-dom"; import { useNavigate, useOutletContext } from "react-router-dom";
import { useMount, useUnmount } from "react-use"; import { useMount, useUnmount } from "react-use";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
@ -10,6 +10,8 @@ import { ExternalLinkIcon } from "../../components/icons";
import { getSharableNoteId } from "../../helpers/nip19"; import { getSharableNoteId } from "../../helpers/nip19";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import userTimelineService from "../../services/user-timeline"; import userTimelineService from "../../services/user-timeline";
import { NostrEvent } from "../../types/nostr-event";
import LoadMoreButton from "../../components/load-more-button";
const matchAllImages = new RegExp(matchImageUrls, "ig"); const matchAllImages = new RegExp(matchImageUrls, "ig");
@ -21,10 +23,13 @@ const UserMediaTab = () => {
const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]); const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]);
const events = useSubject(timeline.events); const eventFilter = useCallback((e: NostrEvent) => e.kind === 1 && !!e.content.match(matchAllImages), []);
const loading = useSubject(timeline.loading); useEffect(() => {
timeline.setFilter(eventFilter);
}, [timeline, eventFilter]);
const filteredEvents = useMemo(() => events.filter((e) => e.kind === 1), [events]); const events = useSubject(timeline.timeline);
const loading = useSubject(timeline.loading);
useEffect(() => { useEffect(() => {
timeline.setRelays(contextRelays); timeline.setRelays(contextRelays);
@ -36,7 +41,7 @@ const UserMediaTab = () => {
const images = useMemo(() => { const images = useMemo(() => {
var images: { eventId: string; src: string; index: number }[] = []; var images: { eventId: string; src: string; index: number }[] = [];
for (const event of filteredEvents) { for (const event of events) {
const urls = event.content.matchAll(matchAllImages); const urls = event.content.matchAll(matchAllImages);
let i = 0; let i = 0;
@ -46,7 +51,7 @@ const UserMediaTab = () => {
} }
return images; return images;
}, [filteredEvents]); }, [events]);
return ( return (
<Flex direction="column" gap="2" px="2" pb="8" h="full" overflowY="auto"> <Flex direction="column" gap="2" px="2" pb="8" h="full" overflowY="auto">
@ -77,13 +82,8 @@ const UserMediaTab = () => {
))} ))}
</Grid> </Grid>
</ImageGalleryProvider> </ImageGalleryProvider>
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} /> <LoadMoreButton timeline={timeline} />
) : (
<Button onClick={() => timeline.loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex> </Flex>
); );
}; };

View File

@ -1,14 +1,17 @@
import { Button, Flex, FormControl, FormLabel, Spinner, Switch, useDisclosure } from "@chakra-ui/react"; import { Flex, FormControl, FormLabel, Switch, useDisclosure } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { Note } from "../../components/note"; import { Note } from "../../components/note";
import RepostNote from "../../components/repost-note"; import RepostNote from "../../components/repost-note";
import { isReply, isRepost } from "../../helpers/nostr-event"; import { isReply, isRepost } from "../../helpers/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import userTimelineService from "../../services/user-timeline"; import userTimelineService from "../../services/user-timeline";
import { useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import { useMount, useUnmount } from "react-use"; import { useInterval, useMount, useUnmount } from "react-use";
import { RelayIconStack } from "../../components/relay-icon-stack"; import { RelayIconStack } from "../../components/relay-icon-stack";
import { NostrEvent } from "../../types/nostr-event";
import useScrollPosition from "../../hooks/use-scroll-position";
import LoadMoreButton from "../../components/load-more-button";
const UserNotesTab = () => { const UserNotesTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string }; const { pubkey } = useOutletContext() as { pubkey: string };
@ -17,11 +20,21 @@ const UserNotesTab = () => {
const { isOpen: showReplies, onToggle: toggleReplies } = useDisclosure(); const { isOpen: showReplies, onToggle: toggleReplies } = useDisclosure();
const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure(); const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure();
const scrollBox = useRef<HTMLDivElement | null>(null);
const scrollPosition = useScrollPosition(scrollBox);
const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]); const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]);
const eventFilter = useCallback(
const events = useSubject(timeline.events); (event: NostrEvent) => {
const loading = useSubject(timeline.loading); if (!showReplies && isReply(event)) return false;
if (hideReposts && isRepost(event)) return false;
return true;
},
[showReplies, hideReposts]
);
useEffect(() => {
timeline.setFilter(eventFilter);
}, [timeline, eventFilter]);
useEffect(() => { useEffect(() => {
timeline.setRelays(readRelays); timeline.setRelays(readRelays);
}, [timeline, readRelays.join("|")]); }, [timeline, readRelays.join("|")]);
@ -29,14 +42,20 @@ const UserNotesTab = () => {
useMount(() => timeline.open()); useMount(() => timeline.open());
useUnmount(() => timeline.close()); useUnmount(() => timeline.close());
const filteredEvents = events.filter((event) => { useInterval(() => {
if (!showReplies && isReply(event)) return false; const events = timeline.timeline.value;
if (hideReposts && isRepost(event)) return false; if (events.length > 0) {
return true; const eventAtScrollPos = events[Math.floor(scrollPosition * (events.length - 1))];
}); timeline.setCursor(eventAtScrollPos.created_at);
}
timeline.loadNextBlocks();
}, 1000);
const eventsTimeline = useSubject(timeline.timeline);
return ( return (
<Flex direction="column" gap="2" pt="4" pb="8" h="full" overflowY="auto" overflowX="hidden"> <Flex direction="column" gap="2" pt="4" pb="8" h="full" overflowY="auto" overflowX="hidden" ref={scrollBox}>
<FormControl display="flex" alignItems="center" mx="2"> <FormControl display="flex" alignItems="center" mx="2">
<Switch id="replies" mr="2" isChecked={showReplies} onChange={toggleReplies} /> <Switch id="replies" mr="2" isChecked={showReplies} onChange={toggleReplies} />
<FormLabel htmlFor="replies" mb="0"> <FormLabel htmlFor="replies" mb="0">
@ -48,20 +67,15 @@ const UserNotesTab = () => {
</FormLabel> </FormLabel>
<RelayIconStack ml="auto" relays={readRelays} direction="row-reverse" mr="4" maxRelays={4} /> <RelayIconStack ml="auto" relays={readRelays} direction="row-reverse" mr="4" maxRelays={4} />
</FormControl> </FormControl>
{filteredEvents.map((event) => {eventsTimeline.map((event) =>
event.kind === 6 ? ( event.kind === 6 ? (
<RepostNote key={event.id} event={event} maxHeight={1200} /> <RepostNote key={event.id} event={event} maxHeight={1200} />
) : ( ) : (
<Note key={event.id} event={event} maxHeight={1200} /> <Note key={event.id} event={event} maxHeight={1200} />
) )
)} )}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} /> <LoadMoreButton timeline={timeline} />
) : (
<Button onClick={() => timeline.loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex> </Flex>
); );
}; };

View File

@ -1,4 +1,4 @@
import { Button, Flex, Spinner, Text } from "@chakra-ui/react"; import { Flex, Text } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { NoteLink } from "../../components/note-link"; import { NoteLink } from "../../components/note-link";
import { UserLink } from "../../components/user-link"; import { UserLink } from "../../components/user-link";
@ -6,6 +6,7 @@ import { filterTagsByContentRefs, truncatedId } from "../../helpers/nostr-event"
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isETag, isPTag, NostrEvent } from "../../types/nostr-event"; import { isETag, isPTag, NostrEvent } from "../../types/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import LoadMoreButton from "../../components/load-more-button";
function ReportEvent({ report }: { report: NostrEvent }) { function ReportEvent({ report }: { report: NostrEvent }) {
const reportedEvent = report.tags.filter(isETag)[0]?.[1]; const reportedEvent = report.tags.filter(isETag)[0]?.[1];
@ -37,29 +38,18 @@ export default function UserReportsTab() {
const { pubkey } = useOutletContext() as { pubkey: string }; const { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext(); const contextRelays = useAdditionalRelayContext();
const { const { timeline, loader } = useTimelineLoader(`${truncatedId(pubkey)}-reports`, contextRelays, {
events: reports, authors: [pubkey],
loading, kinds: [1984],
loadMore, });
} = useTimelineLoader(
`${truncatedId(pubkey)}-reports`,
contextRelays,
{ authors: [pubkey], kinds: [1984] },
{ pageSize: 60 * 60 * 24 * 7 }
);
return ( return (
<Flex direction="column" gap="2" pr="2" pl="2"> <Flex direction="column" gap="2" pr="2" pl="2">
{reports.map((report) => ( {timeline.map((report) => (
<ReportEvent key={report.id} report={report} /> <ReportEvent key={report.id} report={report} />
))} ))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} /> <LoadMoreButton timeline={loader} />
) : (
<Button onClick={() => loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex> </Flex>
); );
} }

View File

@ -1,6 +1,6 @@
import { Box, Button, Flex, Select, Spinner, Text, useDisclosure } from "@chakra-ui/react"; import { Box, Button, Flex, Select, Text, useDisclosure } from "@chakra-ui/react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useState } from "react"; import { useCallback, useState } from "react";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { ErrorBoundary, ErrorFallback } from "../../components/error-boundary"; import { ErrorBoundary, ErrorFallback } from "../../components/error-boundary";
import { LightningIcon } from "../../components/icons"; import { LightningIcon } from "../../components/icons";
@ -9,16 +9,17 @@ import { UserAvatarLink } from "../../components/user-avatar-link";
import { UserLink } from "../../components/user-link"; import { UserLink } from "../../components/user-link";
import { readablizeSats } from "../../helpers/bolt11"; import { readablizeSats } from "../../helpers/bolt11";
import { truncatedId } from "../../helpers/nostr-event"; import { truncatedId } from "../../helpers/nostr-event";
import { isProfileZap, isNoteZap, parseZapNote, totalZaps } from "../../helpers/zaps"; import { isProfileZap, isNoteZap, parseZapEvent, totalZaps } from "../../helpers/zaps";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { useReadRelayUrls } from "../../hooks/use-client-relays"; import { useReadRelayUrls } from "../../hooks/use-client-relays";
import LoadMoreButton from "../../components/load-more-button";
const Zap = ({ zapEvent }: { zapEvent: NostrEvent }) => { const Zap = ({ zapEvent }: { zapEvent: NostrEvent }) => {
const { isOpen, onToggle } = useDisclosure(); const { isOpen, onToggle } = useDisclosure();
try { try {
const { request, payment, eventId } = parseZapNote(zapEvent); const { request, payment, eventId } = parseZapEvent(zapEvent);
return ( return (
<Box <Box
@ -68,16 +69,26 @@ const UserZapsTab = () => {
const contextRelays = useAdditionalRelayContext(); const contextRelays = useAdditionalRelayContext();
const relays = useReadRelayUrls(contextRelays); const relays = useReadRelayUrls(contextRelays);
const { events, loading, loadMore } = useTimelineLoader( const eventFilter = useCallback(
(event: NostrEvent) => {
switch (filter) {
case "note":
return isNoteZap(event);
case "profile":
return isProfileZap(event);
}
return true;
},
[filter]
);
const { timeline, loader } = useTimelineLoader(
`${truncatedId(pubkey)}-zaps`, `${truncatedId(pubkey)}-zaps`,
relays, relays,
{ "#p": [pubkey], kinds: [9735] }, { "#p": [pubkey], kinds: [9735] },
{ pageSize: 60 * 60 * 24 * 7 } { eventFilter }
); );
const timeline =
filter === "note" ? events.filter(isNoteZap) : filter === "profile" ? events.filter(isProfileZap) : events;
return ( return (
<Flex direction="column" gap="2" p="2" pb="8" h="full" overflowY="auto"> <Flex direction="column" gap="2" p="2" pb="8" h="full" overflowY="auto">
<Flex gap="2" alignItems="center" wrap="wrap"> <Flex gap="2" alignItems="center" wrap="wrap">
@ -101,13 +112,8 @@ const UserZapsTab = () => {
<Zap zapEvent={event} /> <Zap zapEvent={event} />
</ErrorBoundary> </ErrorBoundary>
))} ))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} /> <LoadMoreButton timeline={loader} />
) : (
<Button onClick={() => loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex> </Flex>
); );
}; };