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

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

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

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

@ -4,84 +4,210 @@ 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 Subject, { PersistentSubject } from "./subject";
type Options = {
name?: string;
pageSize: number;
startLimit: number;
};
export type TimelineLoaderOptions = Partial<Options>;
const BLOCK_SIZE = 10;
export class TimelineLoader {
relays: string[];
type EventFilter = (event: NostrEvent) => boolean;
class RelayTimelineLoader {
relay: string;
query: NostrQuery;
events = new PersistentSubject<NostrEvent[]>([]);
loading = new PersistentSubject(false);
page = new PersistentSubject(0);
blockSize = BLOCK_SIZE;
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 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 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;
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.createLoaders();
}
setQuery(query: NostrQuery) {
this.query = { ...query, limit: this.opts.startLimit };
this.subscription.setQuery(this.query);
}
setRelays(relays: string[]) {
this.relays = relays;
this.subscription.setRelays(relays);
}
private seenEvents = new Set<string>();
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);
if (!this.eventFilter || this.eventFilter(event)) {
this.timeline.next(utils.insertEventIntoDescendingList(Array.from(this.timeline.value), event));
}
}
}
private getPageDates(page: number) {
const start = this.events.value[0]?.created_at ?? dayjs().unix();
const until = start - page * this.opts.pageSize;
const since = until - this.opts.pageSize;
return {
until,
since,
};
private createLoaders() {
for (const relay of this.relays) {
if (!this.relayTimelineLoaders.has(relay)) {
const loader = new RelayTimelineLoader(relay, this.query);
this.relayTimelineLoaders.set(relay, loader);
loader.onEvent.subscribe(this.handleEvent, this);
loader.onBlockFinish.subscribe(this.updateLoading, this);
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() {
if (this.loading.value) return;
setRelays(relays: string[]) {
// remove loaders
this.removeLoaders((loader) => !relays.includes(loader.relay));
const query = { ...this.query, ...this.getPageDates(this.page.value) };
const request = new NostrRequest(this.relays);
request.onEvent.subscribe(this.handleEvent, this);
request.onComplete.then(() => {
this.loading.next(false);
});
request.start(query);
this.relays = relays;
this.createLoaders();
this.loading.next(true);
this.page.next(this.page.value + 1);
this.subscription.setRelays(relays);
}
setQuery(query: NostrQuery) {
this.removeLoaders();
forgetEvents() {
this.query = query;
this.events.next([]);
this.timeline.next([]);
this.seenEvents.clear();
this.createLoaders();
// update the subscription
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() {
this.subscription.open();
@ -89,4 +215,13 @@ export class TimelineLoader {
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();
}
}

@ -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 { TimelineLoader } from "./timeline-loader";
const PAGE_SIZE = 60 * 60 * 24 * 7; //in seconds
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;
export default class UserTimeline extends TimelineLoader {
constructor(pubkey: string) {
this.pubkey = pubkey;
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();
super([], { authors: [pubkey], kinds: [1, 6] }, truncatedId(pubkey) + "-timeline");
}
}

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

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

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

@ -17,7 +17,7 @@ import { UserAvatarLink } from "../user-avatar-link";
import { UserLink } from "../user-link";
import dayjs from "dayjs";
import { DislikeIcon, LightningIcon, LikeIcon } from "../icons";
import { parseZapNote } from "../../helpers/zaps";
import { parseZapEvent } from "../../helpers/zaps";
import { readablizeSats } from "../../helpers/bolt11";
import useEventReactions from "../../hooks/use-event-reactions";
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 isMobile = useIsMobile();
try {
const { payment, request } = parseZapNote(event);
const { payment, request } = parseZapEvent(event);
if (!payment.amount) return null;

@ -55,7 +55,7 @@ export function totalZaps(events: NostrEvent[]) {
return total;
}
export function parseZapNote(event: NostrEvent) {
function parseZapEvent(event: NostrEvent) {
const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1];
if (!zapRequestStr) throw new Error("no description tag");
@ -77,3 +77,14 @@ export function parseZapNote(event: NostrEvent) {
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 };

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

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

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

@ -9,7 +9,6 @@ import {
FormLabel,
IconButton,
Input,
Spinner,
Switch,
useDisclosure,
useEditableControls,
@ -22,8 +21,10 @@ import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isReply } from "../../helpers/nostr-event";
import { Note } from "../../components/note";
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 { NostrEvent } from "../../types/nostr-event";
import LoadMoreButton from "../../components/load-more-button";
function EditableControls() {
const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = useEditableControls();
@ -51,18 +52,23 @@ export default function HashTagView() {
const relaysModal = 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`,
selectedRelays,
{ kinds: [1], "#t": [hashtag] },
{ pageSize: 60 * 10 }
{ eventFilter }
);
const timeline = showReplies ? events : events.filter((e) => !isReply(e));
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">
<Editable
value={editableHashtag}
@ -95,13 +101,8 @@ export default function HashTagView() {
{timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={600} />
))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
) : (
<Button onClick={() => loadMore()} flexShrink={0}>
Load More
</Button>
)}
<LoadMoreButton timeline={loader} />
</Flex>
{relaysModal.isOpen && (

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

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

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

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

@ -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 { memo } from "react";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
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 { NoteLink } from "../../components/note-link";
import RequireCurrentAccount from "../../providers/require-current-account";
import LoadMoreButton from "../../components/load-more-button";
const Kind1Notification = ({ event }: { event: NostrEvent }) => (
<Card size="sm" variant="outline">
@ -37,26 +38,25 @@ const NotificationItem = memo(({ event }: { event: NostrEvent }) => {
function NotificationsPage() {
const readRelays = useReadRelayUrls();
const account = useCurrentAccount()!;
const { events, loading, loadMore } = useTimelineLoader(
const eventFilter = useCallback((event: NostrEvent) => event.pubkey !== account.pubkey, [account]);
const { timeline, loader } = useTimelineLoader(
"notifications",
readRelays,
{
"#p": [account.pubkey],
kinds: [1],
},
{ pageSize: 60 * 60 * 24 }
{ eventFilter }
);
const timeline = events
// ignore events made my the user
.filter((e) => e.pubkey !== account.pubkey);
return (
<Flex direction="column" overflowX="hidden" overflowY="auto" gap="2">
{timeline.map((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>
);
}

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

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

@ -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 { Note } from "../../components/note";
import RepostNote from "../../components/repost-note";
import { isReply, isRepost } from "../../helpers/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
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 { useMount, useUnmount } from "react-use";
import { useInterval, useMount, useUnmount } from "react-use";
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 { pubkey } = useOutletContext() as { pubkey: string };
@ -17,11 +20,21 @@ const UserNotesTab = () => {
const { isOpen: showReplies, onToggle: toggleReplies } = 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 events = useSubject(timeline.events);
const loading = useSubject(timeline.loading);
const eventFilter = useCallback(
(event: NostrEvent) => {
if (!showReplies && isReply(event)) return false;
if (hideReposts && isRepost(event)) return false;
return true;
},
[showReplies, hideReposts]
);
useEffect(() => {
timeline.setFilter(eventFilter);
}, [timeline, eventFilter]);
useEffect(() => {
timeline.setRelays(readRelays);
}, [timeline, readRelays.join("|")]);
@ -29,14 +42,20 @@ const UserNotesTab = () => {
useMount(() => timeline.open());
useUnmount(() => timeline.close());
const filteredEvents = events.filter((event) => {
if (!showReplies && isReply(event)) return false;
if (hideReposts && isRepost(event)) return false;
return true;
});
useInterval(() => {
const events = timeline.timeline.value;
if (events.length > 0) {
const eventAtScrollPos = events[Math.floor(scrollPosition * (events.length - 1))];
timeline.setCursor(eventAtScrollPos.created_at);
}
timeline.loadNextBlocks();
}, 1000);
const eventsTimeline = useSubject(timeline.timeline);
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">
<Switch id="replies" mr="2" isChecked={showReplies} onChange={toggleReplies} />
<FormLabel htmlFor="replies" mb="0">
@ -48,20 +67,15 @@ const UserNotesTab = () => {
</FormLabel>
<RelayIconStack ml="auto" relays={readRelays} direction="row-reverse" mr="4" maxRelays={4} />
</FormControl>
{filteredEvents.map((event) =>
{eventsTimeline.map((event) =>
event.kind === 6 ? (
<RepostNote 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} />
) : (
<Button onClick={() => timeline.loadMore()} flexShrink={0}>
Load More
</Button>
)}
<LoadMoreButton timeline={timeline} />
</Flex>
);
};

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

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