Merge branch 'next'

This commit is contained in:
hzrd149 2023-06-30 07:35:57 -05:00
commit 7ee48c705e
35 changed files with 857 additions and 580 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {

View File

@ -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) {

View File

@ -94,10 +94,6 @@ export class Relay {
}
this.sendQueued();
if (import.meta.env.DEV) {
console.info(`Relay: ${this.url} connected`);
}
};
this.ws.onclose = () => {
this.onClose.next(this);
@ -106,10 +102,6 @@ export class Relay {
this.ejectTimer();
this.ejectTimer = undefined;
}
if (import.meta.env.DEV) {
console.info(`Relay: ${this.url} disconnected`);
}
};
this.ws.onmessage = this.handleMessage.bind(this);
}
@ -144,9 +136,6 @@ export class Relay {
private sendQueued() {
if (this.connected) {
if (import.meta.env.DEV) {
console.info(`Relay: ${this.url} sending ${this.queue.length} queued messages`);
}
for (const message of this.queue) {
this.send(message);
}

View File

@ -4,84 +4,214 @@ import { NostrEvent } from "../types/nostr-event";
import { NostrQuery } from "../types/nostr-query";
import { NostrRequest } from "./nostr-request";
import { NostrMultiSubscription } from "./nostr-multi-subscription";
import { PersistentSubject } from "./subject";
import Subject, { PersistentSubject } from "./subject";
type Options = {
name?: string;
pageSize: number;
startLimit: number;
};
export type TimelineLoaderOptions = Partial<Options>;
const BLOCK_SIZE = 10;
export class TimelineLoader {
relays: string[];
type EventFilter = (event: NostrEvent) => boolean;
class RelayTimelineLoader {
relay: string;
query: NostrQuery;
events = new PersistentSubject<NostrEvent[]>([]);
loading = new PersistentSubject(false);
page = new PersistentSubject(0);
blockSize = BLOCK_SIZE;
private name?: string;
private requestId = 0;
loading = false;
events: NostrEvent[] = [];
/** set to true when the next block produces 0 events */
complete = false;
onEvent = new Subject<NostrEvent>();
onBlockFinish = new Subject<void>();
constructor(relay: string, query: NostrQuery, 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 + 1;
}
const request = new NostrRequest([this.relay], undefined, this.name + "-" + this.requestId++);
let gotEvents = 0;
request.onEvent.subscribe((e) => {
if (this.handleEvent(e)) {
gotEvents++;
}
});
request.onComplete.then(() => {
this.loading = false;
if (gotEvents === 0) this.complete = true;
this.onBlockFinish.next();
});
request.start(query);
}
private seenEvents = new Set<string>();
private handleEvent(event: NostrEvent) {
if (!this.seenEvents.has(event.id)) {
this.seenEvents.add(event.id);
this.events = utils.insertEventIntoDescendingList(Array.from(this.events), event);
this.onEvent.next(event);
return true;
}
return false;
}
getLastEvent(nth = 0, filter?: EventFilter) {
const events = filter ? this.events.filter(filter) : this.events;
for (let i = nth; i >= 0; i--) {
const event = events[events.length - 1 - i];
if (event) return event;
}
}
}
export class TimelineLoader {
cursor = dayjs().unix();
query: NostrQuery;
relays: string[];
events = new PersistentSubject<NostrEvent[]>([]);
timeline = new PersistentSubject<NostrEvent[]>([]);
loading = new PersistentSubject(false);
complete = new PersistentSubject(false);
loadNextBlockBuffer = 2;
eventFilter?: (event: NostrEvent) => boolean;
private subscription: NostrMultiSubscription;
private opts: Options = { pageSize: 60*60, startLimit: 10 };
constructor(relays: string[], query: NostrQuery, opts?: TimelineLoaderOptions) {
private relayTimelineLoaders = new Map<string, RelayTimelineLoader>();
constructor(relays: string[], query: NostrQuery, name?: string) {
this.query = query;
this.relays = relays;
Object.assign(this.opts, opts);
this.query = { ...query, limit: this.opts.startLimit };
this.subscription = new NostrMultiSubscription(relays, query, opts?.name);
this.subscription = new NostrMultiSubscription(relays, { ...query, limit: BLOCK_SIZE / 2 }, name);
this.subscription.onEvent.subscribe(this.handleEvent, this);
this.createLoaders();
}
setQuery(query: NostrQuery) {
this.query = { ...query, limit: this.opts.startLimit };
this.subscription.setQuery(this.query);
}
setRelays(relays: string[]) {
this.relays = relays;
this.subscription.setRelays(relays);
}
private seenEvents = new Set<string>();
private handleEvent(event: NostrEvent) {
if (!this.seenEvents.has(event.id)) {
this.seenEvents.add(event.id);
this.events.next(utils.insertEventIntoDescendingList(Array.from(this.events.value), event));
if (this.loading.value) this.loading.next(false);
if (!this.eventFilter || this.eventFilter(event)) {
this.timeline.next(utils.insertEventIntoDescendingList(Array.from(this.timeline.value), event));
}
}
}
private getPageDates(page: number) {
const start = this.events.value[0]?.created_at ?? dayjs().unix();
const until = start - page * this.opts.pageSize;
const since = until - this.opts.pageSize;
return {
until,
since,
};
private createLoaders() {
for (const relay of this.relays) {
if (!this.relayTimelineLoaders.has(relay)) {
const loader = new RelayTimelineLoader(relay, this.query, this.subscription.name);
this.relayTimelineLoaders.set(relay, loader);
loader.onEvent.subscribe(this.handleEvent, this);
loader.onBlockFinish.subscribe(this.updateLoading, this);
loader.onBlockFinish.subscribe(this.updateComplete, this);
}
}
}
private removeLoaders(filter?: (loader: RelayTimelineLoader) => boolean) {
for (const [relay, loader] of this.relayTimelineLoaders) {
if (!filter || filter(loader)) {
loader?.onEvent.unsubscribe(this.handleEvent, this);
loader?.onBlockFinish.unsubscribe(this.updateLoading, this);
loader?.onBlockFinish.unsubscribe(this.updateComplete, this);
this.relayTimelineLoaders.delete(relay);
}
}
}
loadMore() {
if (this.loading.value) return;
setRelays(relays: string[]) {
// remove loaders
this.removeLoaders((loader) => !relays.includes(loader.relay));
const query = { ...this.query, ...this.getPageDates(this.page.value) };
const request = new NostrRequest(this.relays);
request.onEvent.subscribe(this.handleEvent, this);
request.onComplete.then(() => {
this.loading.next(false);
});
request.start(query);
this.relays = relays;
this.createLoaders();
this.loading.next(true);
this.page.next(this.page.value + 1);
this.subscription.setRelays(relays);
this.updateComplete();
}
setQuery(query: NostrQuery) {
this.removeLoaders();
forgetEvents() {
this.query = query;
this.events.next([]);
this.timeline.next([]);
this.seenEvents.clear();
this.createLoaders();
this.updateComplete();
// update the subscription
this.subscription.forgetEvents();
this.subscription.setQuery({ ...query, limit: BLOCK_SIZE / 2 });
}
setFilter(filter?: (event: NostrEvent) => boolean) {
this.eventFilter = filter;
if (this.eventFilter) {
this.timeline.next(this.events.value.filter(this.eventFilter));
}
}
setCursor(cursor: number) {
this.cursor = cursor;
this.loadNextBlocks();
}
loadNextBlocks() {
let triggeredLoad = false;
for (const [relay, loader] of this.relayTimelineLoaders) {
if (loader.complete || loader.loading) continue;
const event = loader.getLastEvent(this.loadNextBlockBuffer, this.eventFilter);
if (!event || event.created_at >= this.cursor) {
loader.loadNextBlock();
triggeredLoad = true;
}
}
if (triggeredLoad) this.updateLoading();
}
/** @deprecated */
loadMore() {
for (const [relay, loader] of this.relayTimelineLoaders) {
if (loader.complete || loader.loading) continue;
loader.loadNextBlock();
}
}
private updateLoading() {
for (const [relay, loader] of this.relayTimelineLoaders) {
if (loader.loading) {
if (!this.loading.value) {
this.loading.next(true);
return;
}
}
}
if (this.loading.value) this.loading.next(false);
}
private updateComplete() {
for (const [relay, loader] of this.relayTimelineLoaders) {
if (!loader.complete) {
this.complete.next(false);
return;
}
}
return this.complete.next(true);
}
open() {
this.subscription.open();
@ -89,4 +219,13 @@ export class TimelineLoader {
close() {
this.subscription.close();
}
// TODO: this is only needed because the current logic dose not remove events when the relay they where fetched from is removed
/** @deprecated */
forgetEvents() {
this.events.next([]);
this.timeline.next([]);
this.seenEvents.clear();
this.subscription.forgetEvents();
}
}

View File

@ -1,80 +1,8 @@
import dayjs from "dayjs";
import { NostrEvent } from "../types/nostr-event";
import { NostrQuery } from "../types/nostr-query";
import { NostrRequest } from "./nostr-request";
import { NostrMultiSubscription } from "./nostr-multi-subscription";
import { PersistentSubject } from "./subject";
import { utils } from "nostr-tools";
import { truncatedId } from "../helpers/nostr-event";
import { TimelineLoader } from "./timeline-loader";
const PAGE_SIZE = 60 * 60 * 24 * 7; //in seconds
export default class UserTimeline {
pubkey: string;
query: NostrQuery;
events = new PersistentSubject<NostrEvent[]>([]);
loading = new PersistentSubject(false);
page = new PersistentSubject(0);
private seenEvents = new Set<string>();
private subscription: NostrMultiSubscription;
export default class UserTimeline extends TimelineLoader {
constructor(pubkey: string) {
this.pubkey = pubkey;
this.query = { authors: [pubkey], kinds: [1, 6], limit: 20 };
this.subscription = new NostrMultiSubscription([], this.query, truncatedId(pubkey) + "-timeline");
this.subscription.onEvent.subscribe(this.handleEvent, this);
}
setRelays(relays: string[]) {
this.subscription.setRelays(relays);
}
private handleEvent(event: NostrEvent) {
if (!this.seenEvents.has(event.id)) {
this.seenEvents.add(event.id);
this.events.next(utils.insertEventIntoDescendingList(Array.from(this.events.value), event));
if (this.loading.value) this.loading.next(false);
}
}
private getPageDates(page: number) {
const start = this.events.value[0]?.created_at ?? dayjs().unix();
const until = start - page * PAGE_SIZE;
const since = until - PAGE_SIZE;
return {
until,
since,
};
}
loadMore() {
if (this.loading.value) return;
const query = { ...this.query, ...this.getPageDates(this.page.value) };
const request = new NostrRequest(this.subscription.relayUrls);
request.onEvent.subscribe(this.handleEvent, this);
request.onComplete.then(() => {
this.loading.next(false);
});
request.start(query);
this.loading.next(true);
this.page.next(this.page.value + 1);
}
forgetEvents() {
this.events.next([]);
this.seenEvents.clear();
this.subscription.forgetEvents();
}
open() {
this.subscription.open();
}
close() {
this.subscription.close();
super([], { authors: [pubkey], kinds: [1, 6] }, truncatedId(pubkey) + "-timeline");
}
}

View File

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

View File

@ -0,0 +1,23 @@
import React from "react";
import useSubject from "../hooks/use-subject";
import { TimelineLoader } from "../classes/timeline-loader";
import RepostNote from "./repost-note";
import { Note } from "./note";
const GenericNoteTimeline = 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} />
)
)}
</>
);
});
export default GenericNoteTimeline;

View File

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

View File

@ -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"} />

View File

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

View File

@ -17,7 +17,7 @@ import { UserAvatarLink } from "../user-avatar-link";
import { UserLink } from "../user-link";
import dayjs from "dayjs";
import { DislikeIcon, LightningIcon, LikeIcon } from "../icons";
import { parseZapNote } from "../../helpers/zaps";
import { parseZapEvent } from "../../helpers/zaps";
import { readablizeSats } from "../../helpers/bolt11";
import useEventReactions from "../../hooks/use-event-reactions";
import useEventZaps from "../../hooks/use-event-zaps";
@ -50,7 +50,7 @@ const ReactionEvent = React.memo(({ event }: { event: NostrEvent }) => (
const ZapEvent = React.memo(({ event }: { event: NostrEvent }) => {
const isMobile = useIsMobile();
try {
const { payment, request } = parseZapNote(event);
const { payment, request } = parseZapEvent(event);
if (!payment.amount) return null;

View File

@ -1,4 +1,5 @@
import { Box, Flex, Heading, SkeletonText, Text } from "@chakra-ui/react";
import { useRef } from "react";
import { Flex, Heading, SkeletonText, Text } from "@chakra-ui/react";
import { useAsync } from "react-use";
import 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">

View File

@ -0,0 +1,27 @@
import { Alert, AlertIcon, Button, Spinner } from "@chakra-ui/react";
import { TimelineLoader } from "../classes/timeline-loader";
import useSubject from "../hooks/use-subject";
export default function TimelineActionAndStatus({ timeline }: { timeline: TimelineLoader }) {
const loading = useSubject(timeline.loading);
const complete = useSubject(timeline.complete);
if (complete) {
return (
<Alert status="info" flexShrink={0}>
<AlertIcon />
No more events
</Alert>
);
}
if (loading) {
return <Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />;
}
return (
<Button onClick={() => timeline.loadMore()} flexShrink={0} size="lg" mx="auto" minW="lg">
Load More
</Button>
);
}

View File

@ -55,7 +55,7 @@ export function totalZaps(events: NostrEvent[]) {
return total;
}
export function parseZapNote(event: NostrEvent) {
function parseZapEvent(event: NostrEvent) {
const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1];
if (!zapRequestStr) throw new Error("no description tag");
@ -77,3 +77,14 @@ export function parseZapNote(event: NostrEvent) {
eventId,
};
}
const zapEventCache = new Map<string, ReturnType<typeof parseZapEvent>>();
function cachedParseZapEvent(event: NostrEvent) {
let result = zapEventCache.get(event.id);
if (result) return result;
result = parseZapEvent(event);
if (result) zapEventCache.set(event.id, result);
return result;
}
export { cachedParseZapEvent as parseZapEvent };

View File

@ -0,0 +1,18 @@
import { MutableRefObject, useState } from "react";
import { useInterval } from "react-use";
export default function useScrollPosition(ref: MutableRefObject<HTMLDivElement | null>, interval = 1000) {
const [percent, setPercent] = useState(0);
useInterval(() => {
if (!ref.current) return;
const scrollBottom = ref.current.scrollTop + ref.current.getClientRects()[0].height;
if (ref.current.scrollHeight === 0) {
return setPercent(1);
}
const scrollPosition = Math.min(scrollBottom / ref.current.scrollHeight, 1);
setPercent(scrollPosition);
}, interval);
return percent;
}

View File

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

View File

@ -1,27 +1,36 @@
import { useCallback, useEffect, useRef } from "react";
import { useEffect, useRef } from "react";
import { useUnmount } from "react-use";
import { TimelineLoader, TimelineLoaderOptions } from "../classes/timeline-loader";
import { TimelineLoader } from "../classes/timeline-loader";
import { NostrQuery } from "../types/nostr-query";
import useSubject from "./use-subject";
import { NostrEvent } from "../types/nostr-event";
type Options = TimelineLoaderOptions & {
type Options = {
enabled?: boolean;
eventFilter?: (event: NostrEvent) => boolean;
cursor?: number;
name?: string;
};
export function useTimelineLoader(key: string, relays: string[], query: NostrQuery, opts?: Options) {
if (opts && !opts.name) opts.name = key;
const ref = useRef<TimelineLoader | null>(null);
const loader = (ref.current = ref.current || new TimelineLoader(relays, query, opts));
const loader = (ref.current = ref.current || new TimelineLoader(relays, query, opts?.name));
useEffect(() => {
loader.forgetEvents();
loader.setQuery(query);
}, [key]);
}, [JSON.stringify(query)]);
useEffect(() => {
loader.setRelays(relays);
}, [relays.join("|")]);
useEffect(() => {
loader.setFilter(opts?.eventFilter);
}, [opts?.eventFilter]);
useEffect(() => {
if (opts?.cursor !== undefined) {
loader.setCursor(opts.cursor);
}
}, [opts?.cursor]);
const enabled = opts?.enabled ?? true;
useEffect(() => {
@ -35,17 +44,5 @@ export function useTimelineLoader(key: string, relays: string[], query: NostrQue
loader.close();
});
const events = useSubject(loader.events);
const loading = useSubject(loader.loading);
const loadMore = useCallback(() => {
if (enabled) loader.loadMore();
}, [enabled]);
return {
loader,
events,
loading,
loadMore,
};
return loader;
}

View File

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

View File

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

View File

@ -247,9 +247,7 @@ class RelayScoreboardService {
const relayScoreboardService = new RelayScoreboardService();
relayScoreboardService.loadStats().then(() => {
console.log("Loaded relay scoreboard stats");
});
relayScoreboardService.loadStats();
setInterval(() => {
relayScoreboardService.saveStats();

View File

@ -9,7 +9,6 @@ import {
FormLabel,
IconButton,
Input,
Spinner,
Switch,
useDisclosure,
useEditableControls,
@ -20,10 +19,14 @@ import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isReply } from "../../helpers/nostr-event";
import { Note } from "../../components/note";
import { CheckIcon, EditIcon, RelayIcon } from "../../components/icons";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import RelaySelectionModal from "./relay-selection-modal";
import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/generric-note-timeline";
function EditableControls() {
const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = useEditableControls();
@ -51,65 +54,79 @@ export default function HashTagView() {
const relaysModal = useDisclosure();
const { isOpen: showReplies, onToggle } = useDisclosure();
const { events, loading, loadMore, loader } = useTimelineLoader(
const eventFilter = useCallback(
(event: NostrEvent) => {
return showReplies ? true : !isReply(event);
},
[showReplies]
);
const timeline = useTimelineLoader(
`${hashtag}-hashtag`,
selectedRelays,
{ kinds: [1], "#t": [hashtag] },
{ pageSize: 60 * 10 }
{ eventFilter }
);
const timeline = showReplies ? events : events.filter((e) => !isReply(e));
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<>
<Flex direction="column" gap="4" overflow="auto" 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>
<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>
<GenericNoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
{timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={600} />
))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
) : (
<Button onClick={() => loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex>
</IntersectionObserverProvider>
{relaysModal.isOpen && (
<RelaySelectionModal
selected={selectedRelays}
onSubmit={(relays) => {
setSelectedRelays(relays);
loader.forgetEvents();
timeline.forgetEvents();
}}
onClose={relaysModal.onClose}
/>

View File

@ -1,97 +0,0 @@
import { useMemo } from "react";
import { Button, Flex, Spinner } from "@chakra-ui/react";
import dayjs from "dayjs";
import { Note } from "../../components/note";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isReply, truncatedId } from "../../helpers/nostr-event";
import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useCurrentAccount } from "../../hooks/use-current-account";
import userContactsService, { UserContacts } from "../../services/user-contacts";
import { PersistentSubject } from "../../classes/subject";
import useSubject from "../../hooks/use-subject";
import { useThrottle } from "react-use";
import RequireCurrentAccount from "../../providers/require-current-account";
class DiscoverContacts {
pubkey: string;
relays: string[];
pubkeys = new PersistentSubject<string[]>([]);
constructor(pubkey: string, relays: string[]) {
this.pubkey = pubkey;
this.relays = relays;
userContactsService.requestContacts(pubkey, relays).subscribe(this.handleContacts, this);
}
private personalContacts: UserContacts | undefined;
handleContacts(contacts: UserContacts) {
if (contacts.pubkey === this.pubkey) {
this.personalContacts = contacts;
// unsubscribe from old contacts
if (this.pubkeys.value.length > 0) {
for (const key of this.pubkeys.value) {
userContactsService.getSubject(key).unsubscribe(this.handleContacts, this);
}
this.pubkeys.next([]);
}
// request new contacts
for (const key of contacts.contacts) {
userContactsService.requestContacts(key, this.relays).subscribe(this.handleContacts, this);
}
} else {
// add the pubkeys to contacts
const keysToAdd = contacts.contacts.filter(
(key) =>
(!this.personalContacts || !this.personalContacts.contacts.includes(key)) && !this.pubkeys.value.includes(key)
);
this.pubkeys.next([...this.pubkeys.value, ...keysToAdd]);
}
}
cleanup() {
userContactsService.getSubject(this.pubkey).unsubscribe(this.handleContacts, this);
for (const key of this.pubkeys.value) {
userContactsService.getSubject(key).unsubscribe(this.handleContacts, this);
}
}
}
function DiscoverTabBody() {
useAppTitle("discover");
const account = useCurrentAccount()!;
const relays = useReadRelayUrls();
const discover = useMemo(() => new DiscoverContacts(account.pubkey, relays), [account.pubkey, relays.join("|")]);
const pubkeys = useSubject(discover.pubkeys);
const throttledPubkeys = useThrottle(pubkeys, 1000);
const { events, loading, loadMore } = useTimelineLoader(
`${truncatedId(account.pubkey)}-discover`,
relays,
{ authors: throttledPubkeys, kinds: [1], since: dayjs().subtract(1, "hour").unix() },
{ pageSize: 60 * 60, enabled: throttledPubkeys.length > 0 }
);
const timeline = events.filter((e) => !isReply(e));
return (
<Flex direction="column" gap="2">
{timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={600} />
))}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
</Flex>
);
}
export default function DiscoverTab() {
return (
<RequireCurrentAccount>
<DiscoverTabBody />
</RequireCurrentAccount>
);
}

View File

@ -1,17 +1,19 @@
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 dayjs from "dayjs";
import { Note } from "../../components/note";
import { isReply, truncatedId } from "../../helpers/nostr-event";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { useUserContacts } from "../../hooks/use-user-contacts";
import { AddIcon } from "@chakra-ui/icons";
import { useContext } from "react";
import { useCallback, useContext, useRef } from "react";
import { PostModalContext } from "../../providers/post-modal-provider";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useCurrentAccount } from "../../hooks/use-current-account";
import RepostNote from "../../components/repost-note";
import RequireCurrentAccount from "../../providers/require-current-account";
import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/generric-note-timeline";
function FollowingTabBody() {
const account = useCurrentAccount()!;
@ -24,36 +26,49 @@ function FollowingTabBody() {
showReplies ? setSearch({}) : setSearch({ replies: "show" });
};
const following = contacts?.contacts || [];
const { events, loading, loadMore } = useTimelineLoader(
`${truncatedId(account.pubkey)}-following-posts`,
readRelays,
{ authors: following, kinds: [1, 6], since: dayjs().subtract(2, "hour").unix() },
{ pageSize: 60 * 60, enabled: following.length > 0 }
const eventFilter = useCallback(
(event: NostrEvent) => {
if (!showReplies && isReply(event)) return false;
return true;
},
[showReplies]
);
const timeline = showReplies ? events : events.filter((e) => !isReply(e));
const following = contacts?.contacts || [];
const timeline = useTimelineLoader(
`${truncatedId(account.pubkey)}-following`,
readRelays,
{ authors: following, kinds: [1, 6] },
{ enabled: following.length > 0, eventFilter }
);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<Flex direction="column" gap="2">
<Button variant="outline" leftIcon={<AddIcon />} onClick={() => openModal()} isDisabled={account.readonly}>
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} />
)
)}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
</Flex>
<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>
<GenericNoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
);
}

View File

@ -1,11 +1,16 @@
import { Button, Flex, FormControl, FormLabel, Select, Spinner, Switch, useDisclosure } from "@chakra-ui/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";
import { unique } from "../../helpers/array";
import { isReply } from "../../helpers/nostr-event";
import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/generric-note-timeline";
export default function GlobalTab() {
useAppTitle("global");
@ -17,48 +22,57 @@ export default function GlobalTab() {
setSearchParams({ relay: url });
} else setSearchParams({});
};
const { isOpen: showReplies, onToggle } = useDisclosure();
const availableRelays = unique([...defaultRelays, selectedRelay]).filter(Boolean);
const { isOpen: showReplies, onToggle } = useDisclosure();
const { events, loading, loadMore, loader } = useTimelineLoader(
`global`,
const eventFilter = useCallback(
(event: NostrEvent) => {
if (!showReplies && isReply(event)) return false;
return true;
},
[showReplies]
);
const timeline = useTimelineLoader(
[`global`, ...selectedRelay].join(","),
selectedRelay ? [selectedRelay] : [],
{ kinds: [1] },
{ pageSize: 60*10 }
{ eventFilter }
);
const timeline = showReplies ? events : events.filter((e) => !isReply(e));
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<Flex direction="column" gap="2">
<Flex gap="2">
<Select
placeholder="Select Relay"
maxWidth="250"
value={selectedRelay}
onChange={(e) => {
setSelectedRelay(e.target.value);
loader.forgetEvents();
}}
>
{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>
<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>
<GenericNoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
{timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={600} />
))}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
</Flex>
</IntersectionObserverProvider>
);
}

View File

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

View File

@ -1,6 +1,6 @@
import { Button, Card, CardBody, CardHeader, Flex, Spinner, Text } from "@chakra-ui/react";
import { memo, useCallback, useRef } from "react";
import { Card, CardBody, CardHeader, Flex, Text } from "@chakra-ui/react";
import dayjs from "dayjs";
import { memo } from "react";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
@ -9,23 +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 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) {
@ -37,27 +47,33 @@ const NotificationItem = memo(({ event }: { event: NostrEvent }) => {
function NotificationsPage() {
const readRelays = useReadRelayUrls();
const account = useCurrentAccount()!;
const { events, loading, loadMore } = useTimelineLoader(
"notifications",
const eventFilter = useCallback((event: NostrEvent) => event.pubkey !== account.pubkey, [account]);
const timeline = useTimelineLoader(
`${truncatedId(account.pubkey)}-notifications`,
readRelays,
{
"#p": [account.pubkey],
kinds: [1],
},
{ pageSize: 60 * 60 * 24 }
{ eventFilter }
);
const timeline = events
// ignore events made my the user
.filter((e) => e.pubkey !== account.pubkey);
const events = useSubject(timeline.timeline);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<Flex direction="column" overflowX="hidden" overflowY="auto" gap="2">
{timeline.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
</Flex>
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex direction="column" overflowX="hidden" overflowY="auto" gap="2" ref={scrollBox}>
{events.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
);
}

View File

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

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react";
import { Box, Button, Flex, Grid, IconButton, Spinner } from "@chakra-ui/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";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
@ -10,21 +10,53 @@ import { ExternalLinkIcon } from "../../components/icons";
import { getSharableNoteId } from "../../helpers/nip19";
import useSubject from "../../hooks/use-subject";
import userTimelineService from "../../services/user-timeline";
import { NostrEvent } from "../../types/nostr-event";
import 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();
const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]);
const events = useSubject(timeline.events);
const loading = useSubject(timeline.loading);
const eventFilter = useCallback((e: NostrEvent) => e.kind === 1 && !!e.content.match(matchAllImages), []);
useEffect(() => {
timeline.setFilter(eventFilter);
}, [timeline, eventFilter]);
const filteredEvents = useMemo(() => events.filter((e) => e.kind === 1), [events]);
const events = useSubject(timeline.timeline);
useEffect(() => {
timeline.setRelays(contextRelays);
@ -36,7 +68,7 @@ const UserMediaTab = () => {
const images = useMemo(() => {
var images: { eventId: string; src: string; index: number }[] = [];
for (const event of filteredEvents) {
for (const event of events) {
const urls = event.content.matchAll(matchAllImages);
let i = 0;
@ -46,45 +78,25 @@ const UserMediaTab = () => {
}
return images;
}, [filteredEvents]);
}, [events]);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
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>
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
) : (
<Button onClick={() => timeline.loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex>
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex direction="column" gap="2" px="2" pb="8" h="full" overflowY="auto" ref={scrollBox}>
<ImageGalleryProvider>
<Grid templateColumns={`repeat(${isMobile ? 2 : 5}, 1fr)`} gap="4">
{images.map((image) => (
<ImagePreview key={image.eventId + "-" + image.index} image={image} />
))}
</Grid>
</ImageGalleryProvider>
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
);
};

View File

@ -1,14 +1,20 @@
import { Button, Flex, FormControl, FormLabel, Spinner, Switch, useDisclosure } from "@chakra-ui/react";
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";
import RepostNote from "../../components/repost-note";
import { isReply, isRepost } from "../../helpers/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import userTimelineService from "../../services/user-timeline";
import { useEffect, useMemo } from "react";
import useSubject from "../../hooks/use-subject";
import { useMount, useUnmount } from "react-use";
import { RelayIconStack } from "../../components/relay-icon-stack";
import { NostrEvent } from "../../types/nostr-event";
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";
import GenericNoteTimeline from "../../components/generric-note-timeline";
const UserNotesTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
@ -17,11 +23,20 @@ const UserNotesTab = () => {
const { isOpen: showReplies, onToggle: toggleReplies } = useDisclosure();
const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure();
const scrollBox = useRef<HTMLDivElement | null>(null);
const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]);
const events = useSubject(timeline.events);
const loading = useSubject(timeline.loading);
const eventFilter = useCallback(
(event: NostrEvent) => {
if (!showReplies && isReply(event)) return false;
if (hideReposts && isRepost(event)) return false;
return true;
},
[showReplies, hideReposts]
);
useEffect(() => {
timeline.setFilter(eventFilter);
}, [timeline, eventFilter]);
useEffect(() => {
timeline.setRelays(readRelays);
}, [timeline, readRelays.join("|")]);
@ -29,40 +44,27 @@ const UserNotesTab = () => {
useMount(() => timeline.open());
useUnmount(() => timeline.close());
const filteredEvents = events.filter((event) => {
if (!showReplies && isReply(event)) return false;
if (hideReposts && isRepost(event)) return false;
return true;
});
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<Flex direction="column" gap="2" pt="4" pb="8" h="full" overflowY="auto" overflowX="hidden">
<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>
{filteredEvents.map((event) =>
event.kind === 6 ? (
<RepostNote key={event.id} event={event} maxHeight={1200} />
) : (
<Note key={event.id} event={event} maxHeight={1200} />
)
)}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
) : (
<Button onClick={() => timeline.loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex>
<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>
<GenericNoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
);
};

View File

@ -1,4 +1,4 @@
import { Button, Flex, Spinner, Text } from "@chakra-ui/react";
import { Flex, Text } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom";
import { NoteLink } from "../../components/note-link";
import { UserLink } from "../../components/user-link";
@ -6,6 +6,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 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];
@ -37,29 +39,20 @@ export default function UserReportsTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext();
const {
events: reports,
loading,
loadMore,
} = useTimelineLoader(
`${truncatedId(pubkey)}-reports`,
contextRelays,
{ authors: [pubkey], kinds: [1984] },
{ pageSize: 60 * 60 * 24 * 7 }
);
const timeline = useTimelineLoader(`${truncatedId(pubkey)}-reports`, contextRelays, {
authors: [pubkey],
kinds: [1984],
});
const events = useSubject(timeline.timeline);
return (
<Flex direction="column" gap="2" pr="2" pl="2">
{reports.map((report) => (
{events.map((report) => (
<ReportEvent key={report.id} report={report} />
))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
) : (
<Button onClick={() => loadMore()} flexShrink={0}>
Load More
</Button>
)}
<TimelineActionAndStatus timeline={timeline} />
</Flex>
);
}

View File

@ -1,6 +1,6 @@
import { Box, Button, Flex, Select, Spinner, Text, useDisclosure } from "@chakra-ui/react";
import { Box, Button, Flex, Select, Text, useDisclosure } from "@chakra-ui/react";
import dayjs from "dayjs";
import { useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useOutletContext } from "react-router-dom";
import { ErrorBoundary, ErrorFallback } from "../../components/error-boundary";
import { LightningIcon } from "../../components/icons";
@ -9,16 +9,24 @@ import { UserAvatarLink } from "../../components/user-avatar-link";
import { UserLink } from "../../components/user-link";
import { readablizeSats } from "../../helpers/bolt11";
import { truncatedId } from "../../helpers/nostr-event";
import { isProfileZap, isNoteZap, parseZapNote, totalZaps } from "../../helpers/zaps";
import { isProfileZap, isNoteZap, parseZapEvent, totalZaps } from "../../helpers/zaps";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import 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 } = parseZapNote(zapEvent);
const { request, payment, eventId } = parseZapEvent(zapEvent);
return (
<Box
@ -30,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" />
@ -68,47 +77,59 @@ const UserZapsTab = () => {
const contextRelays = useAdditionalRelayContext();
const relays = useReadRelayUrls(contextRelays);
const { events, loading, loadMore } = useTimelineLoader(
const eventFilter = useCallback(
(event: NostrEvent) => {
switch (filter) {
case "note":
return isNoteZap(event);
case "profile":
return isProfileZap(event);
}
return true;
},
[filter]
);
const timeline = useTimelineLoader(
`${truncatedId(pubkey)}-zaps`,
relays,
{ "#p": [pubkey], kinds: [9735] },
{ pageSize: 60 * 60 * 24 * 7 }
{ eventFilter }
);
const timeline =
filter === "note" ? events.filter(isNoteZap) : filter === "profile" ? events.filter(isProfileZap) : events;
const zaps = useSubject(timeline.timeline);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
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>
)}
<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>
{timeline.map((event) => (
<ErrorBoundary key={event.id}>
<Zap zapEvent={event} />
</ErrorBoundary>
))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
) : (
<Button onClick={() => loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex>
</IntersectionObserverProvider>
);
};