use IntersectionObserver to set timeline cursor

This commit is contained in:
hzrd149
2023-06-30 06:59:03 -05:00
parent b23fe91476
commit c036a9a541
20 changed files with 505 additions and 281 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Fix all pop-in issues when loading timelines (rebuild timeline loader to use IntersectionObserver to correctly set cursor)

View File

@@ -1,5 +1,5 @@
import React, { Suspense, useEffect } from "react"; import React, { Suspense, useEffect } from "react";
import { createHashRouter, Outlet, RouterProvider } from "react-router-dom"; import { createHashRouter, Outlet, RouterProvider, ScrollRestoration, useLocation } from "react-router-dom";
import { Spinner, useColorMode } from "@chakra-ui/react"; import { Spinner, useColorMode } from "@chakra-ui/react";
import { ErrorBoundary } from "./components/error-boundary"; import { ErrorBoundary } from "./components/error-boundary";
import { Page } from "./components/page"; import { Page } from "./components/page";
@@ -37,13 +37,18 @@ import UserAboutTab from "./views/user/about";
// code split search view because QrScanner library is 400kB // code split search view because QrScanner library is 400kB
const SearchView = React.lazy(() => import("./views/search")); const SearchView = React.lazy(() => import("./views/search"));
const RootPage = () => ( const RootPage = () => {
<Page> console.log(useLocation());
<Suspense fallback={<Spinner />}>
<Outlet /> return (
</Suspense> <Page>
</Page> <ScrollRestoration />
); <Suspense fallback={<Spinner />}>
<Outlet />
</Suspense>
</Page>
);
};
const router = createHashRouter([ const router = createHashRouter([
{ {

View File

@@ -70,10 +70,6 @@ export class NostrMultiSubscription {
this.subscribeToRelays(); this.subscribeToRelays();
if (import.meta.env.DEV) {
console.info(`Subscription: "${this.name || this.id}" opened`);
}
return this; return this;
} }
setQuery(query: NostrQuery) { setQuery(query: NostrQuery) {
@@ -126,10 +122,6 @@ export class NostrMultiSubscription {
// unsubscribe from relay messages // unsubscribe from relay messages
this.unsubscribeFromRelays(); this.unsubscribeFromRelays();
if (import.meta.env.DEV) {
console.info(`Subscription: "${this.name || this.id}" closed`);
}
return this; return this;
} }
forgetEvents() { forgetEvents() {

View File

@@ -21,8 +21,8 @@ export class NostrRequest {
onComplete = createDefer<void>(); onComplete = createDefer<void>();
seenEvents = new Set<string>(); seenEvents = new Set<string>();
constructor(relayUrls: string[], timeout?: number) { constructor(relayUrls: string[], timeout?: number, name?: string) {
this.id = `request-${lastId++}`; this.id = name || `request-${lastId++}`;
this.relays = new Set(relayUrls.map((url) => relayPoolService.requestRelay(url))); this.relays = new Set(relayUrls.map((url) => relayPoolService.requestRelay(url)));
for (const relay of this.relays) { for (const relay of this.relays) {

View File

@@ -14,6 +14,8 @@ class RelayTimelineLoader {
relay: string; relay: string;
query: NostrQuery; query: NostrQuery;
blockSize = BLOCK_SIZE; blockSize = BLOCK_SIZE;
private name?: string;
private requestId = 0;
loading = false; loading = false;
events: NostrEvent[] = []; events: NostrEvent[] = [];
@@ -23,19 +25,20 @@ class RelayTimelineLoader {
onEvent = new Subject<NostrEvent>(); onEvent = new Subject<NostrEvent>();
onBlockFinish = new Subject<void>(); onBlockFinish = new Subject<void>();
constructor(relay: string, query: NostrQuery) { constructor(relay: string, query: NostrQuery, name?: string) {
this.relay = relay; this.relay = relay;
this.query = query; this.query = query;
this.name = name;
} }
loadNextBlock() { loadNextBlock() {
this.loading = true; this.loading = true;
const query: NostrQuery = { ...this.query, limit: this.blockSize }; const query: NostrQuery = { ...this.query, limit: this.blockSize };
if (this.events[this.events.length - 1]) { if (this.events[this.events.length - 1]) {
query.until = this.events[this.events.length - 1].created_at; query.until = this.events[this.events.length - 1].created_at + 1;
} }
const request = new NostrRequest([this.relay]); const request = new NostrRequest([this.relay], undefined, this.name + "-" + this.requestId++);
let gotEvents = 0; let gotEvents = 0;
request.onEvent.subscribe((e) => { request.onEvent.subscribe((e) => {
@@ -114,7 +117,7 @@ export class TimelineLoader {
private createLoaders() { private createLoaders() {
for (const relay of this.relays) { for (const relay of this.relays) {
if (!this.relayTimelineLoaders.has(relay)) { if (!this.relayTimelineLoaders.has(relay)) {
const loader = new RelayTimelineLoader(relay, this.query); const loader = new RelayTimelineLoader(relay, this.query, this.subscription.name);
this.relayTimelineLoaders.set(relay, loader); this.relayTimelineLoaders.set(relay, loader);
loader.onEvent.subscribe(this.handleEvent, this); loader.onEvent.subscribe(this.handleEvent, this);
loader.onBlockFinish.subscribe(this.updateLoading, this); loader.onBlockFinish.subscribe(this.updateLoading, this);
@@ -141,6 +144,7 @@ export class TimelineLoader {
this.createLoaders(); this.createLoaders();
this.subscription.setRelays(relays); this.subscription.setRelays(relays);
this.updateComplete();
} }
setQuery(query: NostrQuery) { setQuery(query: NostrQuery) {
this.removeLoaders(); this.removeLoaders();
@@ -151,6 +155,7 @@ export class TimelineLoader {
this.seenEvents.clear(); this.seenEvents.clear();
this.createLoaders(); this.createLoaders();
this.updateComplete();
// update the subscription // update the subscription
this.subscription.forgetEvents(); this.subscription.forgetEvents();
@@ -200,7 +205,6 @@ export class TimelineLoader {
if (this.loading.value) this.loading.next(false); if (this.loading.value) this.loading.next(false);
} }
private updateComplete() { private updateComplete() {
if (this.complete.value) return;
for (const [relay, loader] of this.relayTimelineLoaders) { for (const [relay, loader] of this.relayTimelineLoaders) {
if (!loader.complete) { if (!loader.complete) {
this.complete.next(false); this.complete.next(false);

View File

@@ -1,4 +1,3 @@
import { DownloadIcon } from "@chakra-ui/icons";
import { import {
LinkProps, LinkProps,
Link, Link,
@@ -13,7 +12,7 @@ import {
ModalFooter, ModalFooter,
Button, Button,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { PropsWithChildren, createContext, useContext, useState } from "react"; import { PropsWithChildren, createContext, forwardRef, useContext, useState } from "react";
const GalleryContext = createContext({ const GalleryContext = createContext({
isOpen: false, isOpen: false,
@@ -23,7 +22,7 @@ export function useGalleryContext() {
return useContext(GalleryContext); return useContext(GalleryContext);
} }
export function ImageGalleryLink({ children, href, ...props }: Omit<LinkProps, "onClick">) { export const ImageGalleryLink = forwardRef(({ children, href, ...props }: Omit<LinkProps, "onClick">, ref) => {
const { openImage } = useGalleryContext(); const { openImage } = useGalleryContext();
return ( return (
@@ -36,11 +35,12 @@ export function ImageGalleryLink({ children, href, ...props }: Omit<LinkProps, "
openImage(href); openImage(href);
} }
}} }}
ref={ref}
> >
{children} {children}
</Link> </Link>
); );
} });
export function ImageGalleryProvider({ children }: PropsWithChildren) { export function ImageGalleryProvider({ children }: PropsWithChildren) {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from "react"; import React, { useMemo, useRef } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { import {
Box, Box,
@@ -34,6 +34,7 @@ import { ExternalLinkIcon } from "../icons";
import NoteContentWithWarning from "./note-content-with-warning"; import NoteContentWithWarning from "./note-content-with-warning";
import { TrustProvider } from "./trust"; import { TrustProvider } from "./trust";
import { NoteLink } from "../note-link"; import { NoteLink } from "../note-link";
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
export type NoteProps = { export type NoteProps = {
event: NostrEvent; event: NostrEvent;
@@ -44,13 +45,17 @@ export const Note = React.memo(({ event, maxHeight, variant = "outline" }: NoteP
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { showReactions, showSignatureVerification } = useSubject(appSettings); const { showReactions, showSignatureVerification } = useSubject(appSettings);
// if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
// find mostr external link // find mostr external link
const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr"), [event]); const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr"), [event]);
return ( return (
<TrustProvider event={event}> <TrustProvider event={event}>
<ExpandProvider> <ExpandProvider>
<Card variant={variant}> <Card variant={variant} ref={ref}>
<CardHeader padding="2"> <CardHeader padding="2">
<Flex flex="1" gap="2" alignItems="center" wrap="wrap"> <Flex flex="1" gap="2" alignItems="center" wrap="wrap">
<UserAvatarLink pubkey={event.pubkey} size={isMobile ? "xs" : "sm"} /> <UserAvatarLink pubkey={event.pubkey} size={isMobile ? "xs" : "sm"} />

View File

@@ -1,4 +1,5 @@
import { Box, Flex, Heading, SkeletonText, Text } from "@chakra-ui/react"; import { useRef } from "react";
import { Flex, Heading, SkeletonText, Text } from "@chakra-ui/react";
import { useAsync } from "react-use"; import { useAsync } from "react-use";
import singleEventService from "../services/single-event"; import singleEventService from "../services/single-event";
import { isETag, NostrEvent } from "../types/nostr-event"; import { isETag, NostrEvent } from "../types/nostr-event";
@@ -12,6 +13,7 @@ import { TrustProvider } from "./note/trust";
import { safeJson } from "../helpers/parse"; import { safeJson } from "../helpers/parse";
import { verifySignature } from "nostr-tools"; import { verifySignature } from "nostr-tools";
import { useReadRelayUrls } from "../hooks/use-client-relays"; import { useReadRelayUrls } from "../hooks/use-client-relays";
import { useRegisterIntersectionEntity } from "../providers/intersection-observer";
function parseHardcodedNoteContent(event: NostrEvent): NostrEvent | null { function parseHardcodedNoteContent(event: NostrEvent): NostrEvent | null {
const json = safeJson(event.content, null); const json = safeJson(event.content, null);
@@ -20,6 +22,9 @@ function parseHardcodedNoteContent(event: NostrEvent): NostrEvent | null {
} }
export default function RepostNote({ event, maxHeight }: { event: NostrEvent; maxHeight?: number }) { export default function RepostNote({ event, maxHeight }: { event: NostrEvent; maxHeight?: number }) {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
const hardCodedNote = parseHardcodedNoteContent(event); const hardCodedNote = parseHardcodedNoteContent(event);
const [_, eventId, relay] = event.tags.find(isETag) ?? []; const [_, eventId, relay] = event.tags.find(isETag) ?? [];
@@ -40,7 +45,7 @@ export default function RepostNote({ event, maxHeight }: { event: NostrEvent; ma
return ( return (
<TrustProvider event={event}> <TrustProvider event={event}>
<Flex gap="2" direction="column"> <Flex gap="2" direction="column" ref={ref}>
<Flex gap="2" alignItems="center" pl="1"> <Flex gap="2" alignItems="center" pl="1">
<UserAvatar pubkey={event.pubkey} size="xs" /> <UserAvatar pubkey={event.pubkey} size="xs" />
<Heading size="sm" display="inline" isTruncated whiteSpace="pre"> <Heading size="sm" display="inline" isTruncated whiteSpace="pre">

View File

@@ -2,7 +2,7 @@ import { Alert, AlertIcon, Button, Spinner } from "@chakra-ui/react";
import { TimelineLoader } from "../classes/timeline-loader"; import { TimelineLoader } from "../classes/timeline-loader";
import useSubject from "../hooks/use-subject"; import useSubject from "../hooks/use-subject";
export default function LoadMoreButton({ timeline }: { timeline: TimelineLoader }) { export default function TimelineActionAndStatus({ timeline }: { timeline: TimelineLoader }) {
const loading = useSubject(timeline.loading); const loading = useSubject(timeline.loading);
const complete = useSubject(timeline.complete); const complete = useSubject(timeline.complete);

View File

@@ -0,0 +1,27 @@
import { useInterval } from "react-use";
import { TimelineLoader } from "../classes/timeline-loader";
import { useIntersectionMapCallback } from "../providers/intersection-observer";
export function useTimelineCurserIntersectionCallback(timeline: TimelineLoader) {
// if the cursor is set too far ahead and the last block did not overlap with the cursor
// we need to keep loading blocks until the timeline is complete or the blocks pass the cursor
useInterval(() => {
timeline.loadNextBlocks();
}, 1000);
return useIntersectionMapCallback<string>(
(map) => {
// find oldest event that is visible
for (let i = timeline.timeline.value.length - 1; i >= 0; i--) {
const event = timeline.timeline.value[i];
if (map.get(event.id)?.isIntersecting) {
timeline.setCursor(event.created_at);
timeline.loadNextBlocks();
return;
}
}
},
[timeline]
);
}

View File

@@ -2,7 +2,6 @@ import { useEffect, useRef } from "react";
import { useUnmount } from "react-use"; import { useUnmount } from "react-use";
import { TimelineLoader } 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 { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
type Options = { type Options = {
@@ -45,14 +44,5 @@ export function useTimelineLoader(key: string, relays: string[], query: NostrQue
loader.close(); loader.close();
}); });
const timeline = useSubject(loader.timeline); return loader;
const loading = useSubject(loader.loading);
const complete = useSubject(loader.complete);
return {
loader,
timeline,
loading,
complete,
};
} }

View File

@@ -0,0 +1,109 @@
import {
DependencyList,
MutableRefObject,
PropsWithChildren,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useMount, useUnmount } from "react-use";
const IntersectionObserverContext = createContext<{
observer?: IntersectionObserver;
setElementId: (element: Element, id: any) => void;
}>({ setElementId: () => {} });
export type ExtendedIntersectionObserverEntry<T> = { entry: IntersectionObserverEntry; id: T | undefined };
export type ExtendedIntersectionObserverCallback<T> = (
entries: ExtendedIntersectionObserverEntry<T>[],
observer: IntersectionObserver
) => void;
export function useIntersectionObserver() {
return useContext(IntersectionObserverContext);
}
export function useRegisterIntersectionEntity<T>(ref: MutableRefObject<Element | null>, id?: T) {
const { observer, setElementId } = useIntersectionObserver();
useEffect(() => {
if (observer && ref.current) {
observer.observe(ref.current);
if (id) setElementId(ref.current, id);
}
}, [observer]);
useUnmount(() => {
if (observer && ref.current) observer.unobserve(ref.current);
});
}
export function useIntersectionMapCallback<T>(
callback: (map: Map<T, IntersectionObserverEntry>) => void,
watch: DependencyList
) {
const map = useMemo(() => new Map<T, IntersectionObserverEntry>(), []);
return useCallback<ExtendedIntersectionObserverCallback<T>>(
(entries) => {
for (const { id, entry } of entries) {
if (id) map.set(id, entry);
}
callback(map);
},
[callback, ...watch]
);
}
export default function IntersectionObserverProvider<T = undefined>({
children,
root,
rootMargin,
threshold,
callback,
}: PropsWithChildren & {
root: MutableRefObject<HTMLElement | null>;
rootMargin?: IntersectionObserverInit["rootMargin"];
threshold?: IntersectionObserverInit["threshold"];
callback: ExtendedIntersectionObserverCallback<T>;
}) {
const elementIds = useMemo(() => new WeakMap<Element, T>(), []);
const [observer, setObserver] = useState<IntersectionObserver>();
useMount(() => {
if (root.current) {
const observer = new IntersectionObserver(
(entries, observer) => {
callback(
entries.map((entry) => {
return { entry, id: elementIds.get(entry.target) };
}),
observer
);
},
{ rootMargin, threshold }
);
setObserver(observer);
}
});
useUnmount(() => {
if (observer) observer.disconnect();
});
const setElementId = useCallback(
(element: Element, id: T) => {
elementIds.set(element, id);
},
[elementIds]
);
const context = {
observer,
setElementId,
};
return <IntersectionObserverContext.Provider value={context}>{children}</IntersectionObserverContext.Provider>;
}

View File

@@ -24,7 +24,10 @@ import { CheckIcon, EditIcon, RelayIcon } from "../../components/icons";
import { useCallback, useEffect, useRef, 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 { NostrEvent } from "../../types/nostr-event";
import LoadMoreButton from "../../components/load-more-button"; import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import useSubject from "../../hooks/use-subject";
function EditableControls() { function EditableControls() {
const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = useEditableControls(); const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = useEditableControls();
@@ -59,58 +62,76 @@ export default function HashTagView() {
}, },
[showReplies] [showReplies]
); );
const { timeline, loader } = useTimelineLoader( const timeline = useTimelineLoader(
`${hashtag}-hashtag`, `${hashtag}-hashtag`,
selectedRelays, selectedRelays,
{ kinds: [1], "#t": [hashtag] }, { kinds: [1], "#t": [hashtag] },
{ eventFilter } { eventFilter }
); );
const events = useSubject(timeline.timeline);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return ( return (
<> <>
<Flex direction="column" gap="4" overflowY="auto" overflowX="hidden" flex={1} pb="4" pt="4" pl="1" pr="1"> <IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex gap="4" alignItems="center" wrap="wrap"> <Flex
<Editable direction="column"
value={editableHashtag} gap="4"
onChange={(v) => setEditableHashtag(v)} overflowY="auto"
fontSize="3xl" overflowX="hidden"
fontWeight="bold" flex={1}
display="flex" pb="4"
gap="2" pt="4"
alignItems="center" pl="1"
selectAllOnFocus pr="1"
onSubmit={(v) => navigate("/t/" + String(v).toLowerCase())} ref={scrollBox}
flexShrink={0} >
> <Flex gap="4" alignItems="center" wrap="wrap">
<div> <Editable
#<EditablePreview p={0} /> value={editableHashtag}
</div> onChange={(v) => setEditableHashtag(v)}
<Input as={EditableInput} maxW="md" /> fontSize="3xl"
<EditableControls /> fontWeight="bold"
</Editable> display="flex"
<Button leftIcon={<RelayIcon />} onClick={relaysModal.onOpen}> gap="2"
{selectedRelays.length} Relays alignItems="center"
</Button> selectAllOnFocus
<FormControl display="flex" alignItems="center" w="auto"> onSubmit={(v) => navigate("/t/" + String(v).toLowerCase())}
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} mr="2" /> flexShrink={0}
<FormLabel htmlFor="show-replies" mb="0"> >
Show Replies <div>
</FormLabel> #<EditablePreview p={0} />
</FormControl> </div>
</Flex> <Input as={EditableInput} maxW="md" />
{timeline.map((event) => ( <EditableControls />
<Note key={event.id} event={event} maxHeight={600} /> </Editable>
))} <Button leftIcon={<RelayIcon />} onClick={relaysModal.onOpen}>
{selectedRelays.length} Relays
</Button>
<FormControl display="flex" alignItems="center" w="auto">
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} mr="2" />
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
</FormControl>
</Flex>
{events.map((event) => (
<Note key={event.id} event={event} maxHeight={600} />
))}
<LoadMoreButton timeline={loader} /> <TimelineActionAndStatus timeline={timeline} />
</Flex> </Flex>
</IntersectionObserverProvider>
{relaysModal.isOpen && ( {relaysModal.isOpen && (
<RelaySelectionModal <RelaySelectionModal
selected={selectedRelays} selected={selectedRelays}
onSubmit={(relays) => { onSubmit={(relays) => {
setSelectedRelays(relays); setSelectedRelays(relays);
loader.forgetEvents(); timeline.forgetEvents();
}} }}
onClose={relaysModal.onClose} onClose={relaysModal.onClose}
/> />

View File

@@ -1,6 +1,5 @@
import { Button, Flex, FormControl, FormLabel, Spinner, Switch } from "@chakra-ui/react"; import { Button, Flex, FormControl, FormLabel, Switch } from "@chakra-ui/react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
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";
@@ -13,8 +12,10 @@ 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 { NostrEvent } from "../../types/nostr-event";
import useScrollPosition from "../../hooks/use-scroll-position"; import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import LoadMoreButton from "../../components/load-more-button"; import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import useSubject from "../../hooks/use-subject";
function FollowingTabBody() { function FollowingTabBody() {
const account = useCurrentAccount()!; const account = useCurrentAccount()!;
@@ -27,9 +28,6 @@ function FollowingTabBody() {
showReplies ? setSearch({}) : setSearch({ replies: "show" }); showReplies ? setSearch({}) : setSearch({ replies: "show" });
}; };
const scrollBox = useRef<HTMLDivElement | null>(null);
const scrollPosition = useScrollPosition(scrollBox);
const eventFilter = useCallback( const eventFilter = useCallback(
(event: NostrEvent) => { (event: NostrEvent) => {
if (!showReplies && isReply(event)) return false; if (!showReplies && isReply(event)) return false;
@@ -39,44 +37,47 @@ function FollowingTabBody() {
); );
const following = contacts?.contacts || []; const following = contacts?.contacts || [];
const { timeline, loader } = useTimelineLoader( const timeline = useTimelineLoader(
`${truncatedId(account.pubkey)}-following`, `${truncatedId(account.pubkey)}-following`,
readRelays, readRelays,
{ authors: following, kinds: [1, 6] }, { authors: following, kinds: [1, 6] },
{ enabled: following.length > 0, eventFilter } { enabled: following.length > 0, eventFilter }
); );
useInterval(() => { const events = useSubject(timeline.timeline);
if (scrollPosition > 0.9) loader.loadMore();
}, 1000); const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return ( return (
<Flex py="4" direction="column" gap="2" overflowY="auto" overflowX="hidden" ref={scrollBox}> <IntersectionObserverProvider callback={callback} root={scrollBox}>
<Button <Flex py="4" direction="column" gap="2" overflowY="auto" overflowX="hidden" ref={scrollBox}>
variant="outline" <Button
leftIcon={<AddIcon />} variant="outline"
onClick={() => openModal()} leftIcon={<AddIcon />}
isDisabled={account.readonly} onClick={() => openModal()}
flexShrink={0} isDisabled={account.readonly}
> flexShrink={0}
New Post >
</Button> New Post
<FormControl display="flex" alignItems="center"> </Button>
<FormLabel htmlFor="show-replies" mb="0"> <FormControl display="flex" alignItems="center">
Show Replies <FormLabel htmlFor="show-replies" mb="0">
</FormLabel> Show Replies
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} /> </FormLabel>
</FormControl> <Switch id="show-replies" isChecked={showReplies} onChange={onToggle} />
{timeline.map((event) => </FormControl>
event.kind === 6 ? ( {events.map((event) =>
<RepostNote key={event.id} event={event} maxHeight={600} /> event.kind === 6 ? (
) : ( <RepostNote key={event.id} event={event} maxHeight={600} />
<Note key={event.id} event={event} maxHeight={600} /> ) : (
) <Note key={event.id} event={event} maxHeight={600} />
)} )
)}
<LoadMoreButton timeline={loader} /> <TimelineActionAndStatus timeline={timeline} />
</Flex> </Flex>
</IntersectionObserverProvider>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react"; import { useCallback, useRef } from "react";
import { Flex, FormControl, FormLabel, Select, Switch, useDisclosure } from "@chakra-ui/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";
@@ -8,7 +8,10 @@ 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 { NostrEvent } from "../../types/nostr-event";
import LoadMoreButton from "../../components/load-more-button"; import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import useSubject from "../../hooks/use-subject";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
export default function GlobalTab() { export default function GlobalTab() {
useAppTitle("global"); useAppTitle("global");
@@ -32,42 +35,49 @@ export default function GlobalTab() {
[showReplies] [showReplies]
); );
const { timeline, loader } = useTimelineLoader( const timeline = useTimelineLoader(
`global`, [`global`, ...selectedRelay].join(","),
selectedRelay ? [selectedRelay] : [], selectedRelay ? [selectedRelay] : [],
{ kinds: [1] }, { kinds: [1] },
{ eventFilter } { eventFilter }
); );
return ( const events = useSubject(timeline.timeline);
<Flex py="4" direction="column" gap="2" overflowY="auto" overflowX="hidden">
<Flex gap="2">
<Select
placeholder="Select Relay"
maxWidth="250"
value={selectedRelay}
onChange={(e) => {
setSelectedRelay(e.target.value);
}}
>
{availableRelays.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</Select>
<FormControl display="flex" alignItems="center">
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} mr="2" />
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
</FormControl>
</Flex>
{timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={600} />
))}
<LoadMoreButton timeline={loader} /> const scrollBox = useRef<HTMLDivElement | null>(null);
</Flex> const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex py="4" direction="column" gap="2" overflowY="auto" overflowX="hidden" ref={scrollBox}>
<Flex gap="2">
<Select
placeholder="Select Relay"
maxWidth="250"
value={selectedRelay}
onChange={(e) => {
setSelectedRelay(e.target.value);
}}
>
{availableRelays.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</Select>
<FormControl display="flex" alignItems="center">
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} mr="2" />
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
</FormControl>
</Flex>
{events.map((event) => (
<Note key={event.id} event={event} maxHeight={600} />
))}
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
); );
} }

View File

@@ -1,4 +1,4 @@
import { memo, useCallback } from "react"; import { memo, useCallback, useRef } from "react";
import { Card, CardBody, CardHeader, Flex, Text } from "@chakra-ui/react"; import { Card, CardBody, CardHeader, Flex, Text } from "@chakra-ui/react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { UserAvatar } from "../../components/user-avatar"; import { UserAvatar } from "../../components/user-avatar";
@@ -9,24 +9,33 @@ 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"; import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import useSubject from "../../hooks/use-subject";
import { truncatedId } from "../../helpers/nostr-event";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
const Kind1Notification = ({ event }: { event: NostrEvent }) => ( const Kind1Notification = ({ event }: { event: NostrEvent }) => {
<Card size="sm" variant="outline"> const ref = useRef<HTMLDivElement | null>(null);
<CardHeader> useRegisterIntersectionEntity(ref, event.id);
<Flex gap="4" alignItems="center">
<UserAvatar pubkey={event.pubkey} size="sm" /> return (
<UserLink pubkey={event.pubkey} /> <Card size="sm" variant="outline" ref={ref}>
<NoteLink noteId={event.id} color="current" ml="auto"> <CardHeader>
{dayjs.unix(event.created_at).fromNow()} <Flex gap="4" alignItems="center">
</NoteLink> <UserAvatar pubkey={event.pubkey} size="sm" />
</Flex> <UserLink pubkey={event.pubkey} />
</CardHeader> <NoteLink noteId={event.id} color="current" ml="auto">
<CardBody pt={0}> {dayjs.unix(event.created_at).fromNow()}
<Text>{event.content.replace("\n", " ").slice(0, 64)}</Text> </NoteLink>
</CardBody> </Flex>
</Card> </CardHeader>
); <CardBody pt={0}>
<Text>{event.content.replace("\n", " ").slice(0, 64)}</Text>
</CardBody>
</Card>
);
};
const NotificationItem = memo(({ event }: { event: NostrEvent }) => { const NotificationItem = memo(({ event }: { event: NostrEvent }) => {
if (event.kind === 1) { if (event.kind === 1) {
@@ -40,8 +49,8 @@ function NotificationsPage() {
const account = useCurrentAccount()!; const account = useCurrentAccount()!;
const eventFilter = useCallback((event: NostrEvent) => event.pubkey !== account.pubkey, [account]); const eventFilter = useCallback((event: NostrEvent) => event.pubkey !== account.pubkey, [account]);
const { timeline, loader } = useTimelineLoader( const timeline = useTimelineLoader(
"notifications", `${truncatedId(account.pubkey)}-notifications`,
readRelays, readRelays,
{ {
"#p": [account.pubkey], "#p": [account.pubkey],
@@ -50,14 +59,21 @@ function NotificationsPage() {
{ eventFilter } { eventFilter }
); );
return ( const events = useSubject(timeline.timeline);
<Flex direction="column" overflowX="hidden" overflowY="auto" gap="2">
{timeline.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<LoadMoreButton timeline={loader} /> const scrollBox = useRef<HTMLDivElement | null>(null);
</Flex> const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex direction="column" overflowX="hidden" overflowY="auto" gap="2" ref={scrollBox}>
{events.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from "react"; import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { Box, Flex, Grid, IconButton } 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";
@@ -11,12 +11,40 @@ 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 { NostrEvent } from "../../types/nostr-event";
import LoadMoreButton from "../../components/load-more-button"; import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
type ImagePreview = { eventId: string; src: string; index: number };
const matchAllImages = new RegExp(matchImageUrls, "ig"); const matchAllImages = new RegExp(matchImageUrls, "ig");
const UserMediaTab = () => { const ImagePreview = React.memo(({ image }: { image: ImagePreview }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, image.eventId);
return (
<ImageGalleryLink href={image.src} position="relative" ref={ref}>
<Box aspectRatio={1} backgroundImage={`url(${image.src})`} backgroundSize="cover" backgroundPosition="center" />
<IconButton
icon={<ExternalLinkIcon />}
aria-label="Open note"
position="absolute"
right="2"
top="2"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigate(`/n/${getSharableNoteId(image.eventId)}`);
}}
/>
</ImageGalleryLink>
);
});
const UserMediaTab = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { pubkey } = useOutletContext() as { pubkey: string }; const { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext(); const contextRelays = useAdditionalRelayContext();
@@ -29,7 +57,6 @@ const UserMediaTab = () => {
}, [timeline, eventFilter]); }, [timeline, eventFilter]);
const events = useSubject(timeline.timeline); const events = useSubject(timeline.timeline);
const loading = useSubject(timeline.loading);
useEffect(() => { useEffect(() => {
timeline.setRelays(contextRelays); timeline.setRelays(contextRelays);
@@ -53,38 +80,23 @@ const UserMediaTab = () => {
return images; return images;
}, [events]); }, [events]);
return ( const scrollBox = useRef<HTMLDivElement | null>(null);
<Flex direction="column" gap="2" px="2" pb="8" h="full" overflowY="auto"> const callback = useTimelineCurserIntersectionCallback(timeline);
<ImageGalleryProvider>
<Grid templateColumns={`repeat(${isMobile ? 2 : 5}, 1fr)`} gap="4">
{images.map((image) => (
<ImageGalleryLink key={image.eventId + "-" + image.index} href={image.src} position="relative">
<Box
aspectRatio={1}
backgroundImage={`url(${image.src})`}
backgroundSize="cover"
backgroundPosition="center"
/>
<IconButton
icon={<ExternalLinkIcon />}
aria-label="Open note"
position="absolute"
right="2"
top="2"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigate(`/n/${getSharableNoteId(image.eventId)}`);
}}
/>
</ImageGalleryLink>
))}
</Grid>
</ImageGalleryProvider>
<LoadMoreButton timeline={timeline} /> return (
</Flex> <IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex direction="column" gap="2" px="2" pb="8" h="full" overflowY="auto" ref={scrollBox}>
<ImageGalleryProvider>
<Grid templateColumns={`repeat(${isMobile ? 2 : 5}, 1fr)`} gap="4">
{images.map((image) => (
<ImagePreview key={image.eventId + "-" + image.index} image={image} />
))}
</Grid>
</ImageGalleryProvider>
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
); );
}; };

View File

@@ -1,3 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { Flex, FormControl, FormLabel, 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";
@@ -5,13 +6,30 @@ 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 { useCallback, useEffect, useMemo, useRef } from "react";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import { useInterval, useMount, useUnmount } from "react-use"; import { 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 { NostrEvent } from "../../types/nostr-event";
import useScrollPosition from "../../hooks/use-scroll-position"; import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import LoadMoreButton from "../../components/load-more-button"; import IntersectionObserverProvider from "../../providers/intersection-observer";
import { TimelineLoader } from "../../classes/timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
const NoteTimeline = React.memo(({ timeline }: { timeline: TimelineLoader }) => {
const notes = useSubject(timeline.timeline);
return (
<>
{notes.map((note) =>
note.kind === 6 ? (
<RepostNote key={note.id} event={note} maxHeight={1200} />
) : (
<Note key={note.id} event={note} maxHeight={1200} />
)
)}
</>
);
});
const UserNotesTab = () => { const UserNotesTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string }; const { pubkey } = useOutletContext() as { pubkey: string };
@@ -21,7 +39,6 @@ const UserNotesTab = () => {
const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure(); const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure();
const scrollBox = useRef<HTMLDivElement | null>(null); 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 eventFilter = useCallback(
@@ -42,41 +59,28 @@ const UserNotesTab = () => {
useMount(() => timeline.open()); useMount(() => timeline.open());
useUnmount(() => timeline.close()); useUnmount(() => timeline.close());
useInterval(() => { const callback = useTimelineCurserIntersectionCallback(timeline);
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 ( return (
<Flex direction="column" gap="2" pt="4" pb="8" h="full" overflowY="auto" overflowX="hidden" ref={scrollBox}> <IntersectionObserverProvider<string> root={scrollBox} callback={callback}>
<FormControl display="flex" alignItems="center" mx="2"> <Flex direction="column" gap="2" pt="4" pb="8" h="full" overflowY="auto" overflowX="hidden" ref={scrollBox}>
<Switch id="replies" mr="2" isChecked={showReplies} onChange={toggleReplies} /> <FormControl display="flex" alignItems="center" mx="2">
<FormLabel htmlFor="replies" mb="0"> <Switch id="replies" mr="2" isChecked={showReplies} onChange={toggleReplies} />
Replies <FormLabel htmlFor="replies" mb="0">
</FormLabel> Replies
<Switch id="reposts" mr="2" isChecked={!hideReposts} onChange={toggleReposts} /> </FormLabel>
<FormLabel htmlFor="reposts" mb="0"> <Switch id="reposts" mr="2" isChecked={!hideReposts} onChange={toggleReposts} />
Reposts <FormLabel htmlFor="reposts" mb="0">
</FormLabel> Reposts
<RelayIconStack ml="auto" relays={readRelays} direction="row-reverse" mr="4" maxRelays={4} /> </FormLabel>
</FormControl> <RelayIconStack ml="auto" relays={readRelays} direction="row-reverse" mr="4" maxRelays={4} />
{eventsTimeline.map((event) => </FormControl>
event.kind === 6 ? (
<RepostNote key={event.id} event={event} maxHeight={1200} />
) : (
<Note key={event.id} event={event} maxHeight={1200} />
)
)}
<LoadMoreButton timeline={timeline} /> <NoteTimeline timeline={timeline} />
</Flex>
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
); );
}; };

View File

@@ -6,7 +6,8 @@ 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"; import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import useSubject from "../../hooks/use-subject";
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];
@@ -38,18 +39,20 @@ export default function UserReportsTab() {
const { pubkey } = useOutletContext() as { pubkey: string }; const { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext(); const contextRelays = useAdditionalRelayContext();
const { timeline, loader } = useTimelineLoader(`${truncatedId(pubkey)}-reports`, contextRelays, { const timeline = useTimelineLoader(`${truncatedId(pubkey)}-reports`, contextRelays, {
authors: [pubkey], authors: [pubkey],
kinds: [1984], kinds: [1984],
}); });
const events = useSubject(timeline.timeline);
return ( return (
<Flex direction="column" gap="2" pr="2" pl="2"> <Flex direction="column" gap="2" pr="2" pl="2">
{timeline.map((report) => ( {events.map((report) => (
<ReportEvent key={report.id} report={report} /> <ReportEvent key={report.id} report={report} />
))} ))}
<LoadMoreButton timeline={loader} /> <TimelineActionAndStatus timeline={timeline} />
</Flex> </Flex>
); );
} }

View File

@@ -1,6 +1,6 @@
import { Box, Button, Flex, Select, 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 { useCallback, useState } from "react"; import { useCallback, useRef, 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";
@@ -14,10 +14,17 @@ 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"; import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import useSubject from "../../hooks/use-subject";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
const Zap = ({ zapEvent }: { zapEvent: NostrEvent }) => { const Zap = ({ zapEvent }: { zapEvent: NostrEvent }) => {
const { isOpen, onToggle } = useDisclosure(); const { isOpen, onToggle } = useDisclosure();
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, zapEvent.id);
try { try {
const { request, payment, eventId } = parseZapEvent(zapEvent); const { request, payment, eventId } = parseZapEvent(zapEvent);
@@ -31,6 +38,7 @@ const Zap = ({ zapEvent }: { zapEvent: NostrEvent }) => {
gap="2" gap="2"
flexDirection="column" flexDirection="column"
flexShrink={0} flexShrink={0}
ref={ref}
> >
<Flex gap="2" alignItems="center" wrap="wrap"> <Flex gap="2" alignItems="center" wrap="wrap">
<UserAvatarLink pubkey={request.pubkey} size="xs" /> <UserAvatarLink pubkey={request.pubkey} size="xs" />
@@ -82,39 +90,46 @@ const UserZapsTab = () => {
[filter] [filter]
); );
const { timeline, loader } = useTimelineLoader( const timeline = useTimelineLoader(
`${truncatedId(pubkey)}-zaps`, `${truncatedId(pubkey)}-zaps`,
relays, relays,
{ "#p": [pubkey], kinds: [9735] }, { "#p": [pubkey], kinds: [9735] },
{ eventFilter } { eventFilter }
); );
return ( const zaps = useSubject(timeline.timeline);
<Flex direction="column" gap="2" p="2" pb="8" h="full" overflowY="auto">
<Flex gap="2" alignItems="center" wrap="wrap">
<Select value={filter} onChange={(e) => setFilter(e.target.value)} maxW="md">
<option value="both">Note & Profile Zaps</option>
<option value="note">Note Zaps</option>
<option value="profile">Profile Zaps</option>
</Select>
{timeline.length && (
<Flex gap="2">
<LightningIcon color="yellow.400" />
<Text>
{readablizeSats(totalZaps(timeline) / 1000)} sats in the last{" "}
{dayjs.unix(timeline[timeline.length - 1].created_at).fromNow(true)}
</Text>
</Flex>
)}
</Flex>
{timeline.map((event) => (
<ErrorBoundary key={event.id}>
<Zap zapEvent={event} />
</ErrorBoundary>
))}
<LoadMoreButton timeline={loader} /> const scrollBox = useRef<HTMLDivElement | null>(null);
</Flex> const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex direction="column" gap="2" p="2" pb="8" h="full" overflowY="auto" ref={scrollBox}>
<Flex gap="2" alignItems="center" wrap="wrap">
<Select value={filter} onChange={(e) => setFilter(e.target.value)} maxW="md">
<option value="both">Note & Profile Zaps</option>
<option value="note">Note Zaps</option>
<option value="profile">Profile Zaps</option>
</Select>
{zaps.length && (
<Flex gap="2">
<LightningIcon color="yellow.400" />
<Text>
{readablizeSats(totalZaps(zaps) / 1000)} sats in the last{" "}
{dayjs.unix(zaps[zaps.length - 1].created_at).fromNow(true)}
</Text>
</Flex>
)}
</Flex>
{zaps.map((event) => (
<ErrorBoundary key={event.id}>
<Zap zapEvent={event} />
</ErrorBoundary>
))}
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
); );
}; };