mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-27 18:22:02 +01:00
use IntersectionObserver to set timeline cursor
This commit is contained in:
parent
b23fe91476
commit
c036a9a541
5
.changeset/fair-brooms-warn.md
Normal file
5
.changeset/fair-brooms-warn.md
Normal 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)
|
21
src/app.tsx
21
src/app.tsx
@ -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);
|
||||
|
27
src/hooks/use-timeline-cursor-intersection-callback.ts
Normal file
27
src/hooks/use-timeline-cursor-intersection-callback.ts
Normal 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]
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
109
src/providers/intersection-observer.tsx
Normal file
109
src/providers/intersection-observer.tsx
Normal 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>;
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user