mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-29 13:03:33 +02:00
use IntersectionObserver to set timeline cursor
This commit is contained in:
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 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([
|
||||||
{
|
{
|
||||||
|
@@ -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() {
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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);
|
||||||
|
@@ -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();
|
||||||
|
@@ -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"} />
|
||||||
|
@@ -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">
|
||||||
|
@@ -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);
|
||||||
|
|
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 { 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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
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 { 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}
|
||||||
/>
|
/>
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user