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

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

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

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

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

@ -14,6 +14,8 @@ class RelayTimelineLoader {
relay: string;
query: NostrQuery;
blockSize = BLOCK_SIZE;
private name?: string;
private requestId = 0;
loading = false;
events: NostrEvent[] = [];
@ -23,19 +25,20 @@ class RelayTimelineLoader {
onEvent = new Subject<NostrEvent>();
onBlockFinish = new Subject<void>();
constructor(relay: string, query: NostrQuery) {
constructor(relay: string, query: NostrQuery, name?: string) {
this.relay = relay;
this.query = query;
this.name = name;
}
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;
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;
request.onEvent.subscribe((e) => {
@ -114,7 +117,7 @@ export class TimelineLoader {
private createLoaders() {
for (const relay of this.relays) {
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);
loader.onEvent.subscribe(this.handleEvent, this);
loader.onBlockFinish.subscribe(this.updateLoading, this);
@ -141,6 +144,7 @@ export class TimelineLoader {
this.createLoaders();
this.subscription.setRelays(relays);
this.updateComplete();
}
setQuery(query: NostrQuery) {
this.removeLoaders();
@ -151,6 +155,7 @@ export class TimelineLoader {
this.seenEvents.clear();
this.createLoaders();
this.updateComplete();
// update the subscription
this.subscription.forgetEvents();
@ -200,7 +205,6 @@ export class TimelineLoader {
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);

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

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

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

@ -2,7 +2,7 @@ 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 }) {
export default function TimelineActionAndStatus({ timeline }: { timeline: TimelineLoader }) {
const loading = useSubject(timeline.loading);
const complete = useSubject(timeline.complete);

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

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

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

@ -24,7 +24,10 @@ import { CheckIcon, EditIcon, RelayIcon } from "../../components/icons";
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";
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() {
const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = useEditableControls();
@ -59,58 +62,76 @@ export default function HashTagView() {
},
[showReplies]
);
const { timeline, loader } = useTimelineLoader(
const timeline = useTimelineLoader(
`${hashtag}-hashtag`,
selectedRelays,
{ kinds: [1], "#t": [hashtag] },
{ eventFilter }
);
const events = useSubject(timeline.timeline);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<>
<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}
onChange={(v) => setEditableHashtag(v)}
fontSize="3xl"
fontWeight="bold"
display="flex"
gap="2"
alignItems="center"
selectAllOnFocus
onSubmit={(v) => navigate("/t/" + String(v).toLowerCase())}
flexShrink={0}
>
<div>
#<EditablePreview p={0} />
</div>
<Input as={EditableInput} maxW="md" />
<EditableControls />
</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>
{timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={600} />
))}
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex
direction="column"
gap="4"
overflowY="auto"
overflowX="hidden"
flex={1}
pb="4"
pt="4"
pl="1"
pr="1"
ref={scrollBox}
>
<Flex gap="4" alignItems="center" wrap="wrap">
<Editable
value={editableHashtag}
onChange={(v) => setEditableHashtag(v)}
fontSize="3xl"
fontWeight="bold"
display="flex"
gap="2"
alignItems="center"
selectAllOnFocus
onSubmit={(v) => navigate("/t/" + String(v).toLowerCase())}
flexShrink={0}
>
<div>
#<EditablePreview p={0} />
</div>
<Input as={EditableInput} maxW="md" />
<EditableControls />
</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} />
</Flex>
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
{relaysModal.isOpen && (
<RelaySelectionModal
selected={selectedRelays}
onSubmit={(relays) => {
setSelectedRelays(relays);
loader.forgetEvents();
timeline.forgetEvents();
}}
onClose={relaysModal.onClose}
/>

@ -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 { useInterval } from "react-use";
import { Note } from "../../components/note";
import { isReply, truncatedId } from "../../helpers/nostr-event";
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 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";
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 FollowingTabBody() {
const account = useCurrentAccount()!;
@ -27,9 +28,6 @@ function FollowingTabBody() {
showReplies ? setSearch({}) : setSearch({ replies: "show" });
};
const scrollBox = useRef<HTMLDivElement | null>(null);
const scrollPosition = useScrollPosition(scrollBox);
const eventFilter = useCallback(
(event: NostrEvent) => {
if (!showReplies && isReply(event)) return false;
@ -39,44 +37,47 @@ function FollowingTabBody() {
);
const following = contacts?.contacts || [];
const { timeline, loader } = useTimelineLoader(
const timeline = useTimelineLoader(
`${truncatedId(account.pubkey)}-following`,
readRelays,
{ authors: following, kinds: [1, 6] },
{ enabled: following.length > 0, eventFilter }
);
useInterval(() => {
if (scrollPosition > 0.9) loader.loadMore();
}, 1000);
const events = useSubject(timeline.timeline);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<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">
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} />
</FormControl>
{timeline.map((event) =>
event.kind === 6 ? (
<RepostNote key={event.id} event={event} maxHeight={600} />
) : (
<Note key={event.id} event={event} maxHeight={600} />
)
)}
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<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">
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} />
</FormControl>
{events.map((event) =>
event.kind === 6 ? (
<RepostNote key={event.id} event={event} maxHeight={600} />
) : (
<Note key={event.id} event={event} maxHeight={600} />
)
)}
<LoadMoreButton timeline={loader} />
</Flex>
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
);
}

@ -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 { useSearchParams } from "react-router-dom";
import { Note } from "../../components/note";
@ -8,7 +8,10 @@ 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";
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() {
useAppTitle("global");
@ -32,42 +35,49 @@ export default function GlobalTab() {
[showReplies]
);
const { timeline, loader } = useTimelineLoader(
`global`,
const timeline = useTimelineLoader(
[`global`, ...selectedRelay].join(","),
selectedRelay ? [selectedRelay] : [],
{ kinds: [1] },
{ eventFilter }
);
return (
<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} />
))}
const events = useSubject(timeline.timeline);
<LoadMoreButton timeline={loader} />
</Flex>
const scrollBox = useRef<HTMLDivElement | null>(null);
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>
);
}

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

@ -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 { useNavigate, useOutletContext } from "react-router-dom";
import { useMount, useUnmount } from "react-use";
@ -11,12 +11,40 @@ 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";
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 UserMediaTab = () => {
const ImagePreview = React.memo(({ image }: { image: ImagePreview }) => {
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 { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext();
@ -29,7 +57,6 @@ const UserMediaTab = () => {
}, [timeline, eventFilter]);
const events = useSubject(timeline.timeline);
const loading = useSubject(timeline.loading);
useEffect(() => {
timeline.setRelays(contextRelays);
@ -53,38 +80,23 @@ const UserMediaTab = () => {
return images;
}, [events]);
return (
<Flex direction="column" gap="2" px="2" pb="8" h="full" overflowY="auto">
<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>
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
<LoadMoreButton timeline={timeline} />
</Flex>
return (
<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>
);
};

@ -1,3 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { Flex, FormControl, FormLabel, Switch, useDisclosure } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom";
import { Note } from "../../components/note";
@ -5,13 +6,30 @@ 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 { useCallback, useEffect, useMemo, useRef } from "react";
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 { NostrEvent } from "../../types/nostr-event";
import useScrollPosition from "../../hooks/use-scroll-position";
import LoadMoreButton from "../../components/load-more-button";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
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 { pubkey } = useOutletContext() as { pubkey: string };
@ -21,7 +39,6 @@ const UserNotesTab = () => {
const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure();
const scrollBox = useRef<HTMLDivElement | null>(null);
const scrollPosition = useScrollPosition(scrollBox);
const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]);
const eventFilter = useCallback(
@ -42,41 +59,28 @@ const UserNotesTab = () => {
useMount(() => timeline.open());
useUnmount(() => timeline.close());
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);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<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">
Replies
</FormLabel>
<Switch id="reposts" mr="2" isChecked={!hideReposts} onChange={toggleReposts} />
<FormLabel htmlFor="reposts" mb="0">
Reposts
</FormLabel>
<RelayIconStack ml="auto" relays={readRelays} direction="row-reverse" mr="4" maxRelays={4} />
</FormControl>
{eventsTimeline.map((event) =>
event.kind === 6 ? (
<RepostNote key={event.id} event={event} maxHeight={1200} />
) : (
<Note key={event.id} event={event} maxHeight={1200} />
)
)}
<IntersectionObserverProvider<string> root={scrollBox} callback={callback}>
<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">
Replies
</FormLabel>
<Switch id="reposts" mr="2" isChecked={!hideReposts} onChange={toggleReposts} />
<FormLabel htmlFor="reposts" mb="0">
Reposts
</FormLabel>
<RelayIconStack ml="auto" relays={readRelays} direction="row-reverse" mr="4" maxRelays={4} />
</FormControl>
<LoadMoreButton timeline={timeline} />
</Flex>
<NoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
);
};

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

@ -1,6 +1,6 @@
import { Box, Button, Flex, Select, Text, useDisclosure } from "@chakra-ui/react";
import dayjs from "dayjs";
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useOutletContext } from "react-router-dom";
import { ErrorBoundary, ErrorFallback } from "../../components/error-boundary";
import { LightningIcon } from "../../components/icons";
@ -14,10 +14,17 @@ 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";
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 { isOpen, onToggle } = useDisclosure();
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, zapEvent.id);
try {
const { request, payment, eventId } = parseZapEvent(zapEvent);
@ -31,6 +38,7 @@ const Zap = ({ zapEvent }: { zapEvent: NostrEvent }) => {
gap="2"
flexDirection="column"
flexShrink={0}
ref={ref}
>
<Flex gap="2" alignItems="center" wrap="wrap">
<UserAvatarLink pubkey={request.pubkey} size="xs" />
@ -82,39 +90,46 @@ const UserZapsTab = () => {
[filter]
);
const { timeline, loader } = useTimelineLoader(
const timeline = useTimelineLoader(
`${truncatedId(pubkey)}-zaps`,
relays,
{ "#p": [pubkey], kinds: [9735] },
{ eventFilter }
);
return (
<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>
))}
const zaps = useSubject(timeline.timeline);
<LoadMoreButton timeline={loader} />
</Flex>
const scrollBox = useRef<HTMLDivElement | null>(null);
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>
);
};