start rebuilding notifications view

This commit is contained in:
hzrd149 2023-11-25 11:49:00 -06:00
parent b69bfa3724
commit 6d44e534ff
14 changed files with 246 additions and 309 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Rebuild notifications view

View File

@ -1,56 +0,0 @@
import Subject from "./subject";
/** @deprecated */
export class PubkeySubjectCache<T> {
subjects = new Map<string, Subject<T | null>>();
relays = new Map<string, Set<string>>();
dirty = false;
hasSubject(pubkey: string) {
return this.subjects.has(pubkey);
}
getSubject(pubkey: string) {
let subject = this.subjects.get(pubkey);
if (!subject) {
subject = new Subject<T | null>(null);
this.subjects.set(pubkey, subject);
this.dirty = true;
}
return subject;
}
addRelays(pubkey: string, relays: string[]) {
const set = this.relays.get(pubkey) ?? new Set();
for (const url of relays) set.add(url);
this.relays.set(pubkey, set);
this.dirty = true;
}
getAllPubkeysMissingData(include: string[] = []) {
const pubkeys: string[] = [];
const relays = new Set<string>();
for (const [pubkey, subject] of this.subjects) {
if (subject.value === null || include.includes(pubkey)) {
pubkeys.push(pubkey);
const r = this.relays.get(pubkey);
if (r) {
for (const url of r) relays.add(url);
}
}
}
return { pubkeys, relays: Array.from(relays) };
}
prune() {
const prunedKeys: string[] = [];
for (const [key, subject] of this.subjects) {
if (!subject.hasListeners) {
this.subjects.delete(key);
this.relays.delete(key);
prunedKeys.push(key);
this.dirty = true;
}
}
return prunedKeys;
}
}

View File

@ -22,7 +22,7 @@ const BLOCK_SIZE = 30;
export type EventFilter = (event: NostrEvent, store: EventStore) => boolean; export type EventFilter = (event: NostrEvent, store: EventStore) => boolean;
export class RelayTimelineLoader { export class RelayBlockLoader {
relay: string; relay: string;
query: NostrRequestFilter; query: NostrRequestFilter;
blockSize = BLOCK_SIZE; blockSize = BLOCK_SIZE;
@ -53,7 +53,7 @@ export class RelayTimelineLoader {
query = addToQuery(query, { until: oldestEvent.created_at - 1 }); query = addToQuery(query, { until: oldestEvent.created_at - 1 });
} }
const request = new NostrRequest([this.relay], 20 * 1000); const request = new NostrRequest([this.relay]);
let gotEvents = 0; let gotEvents = 0;
request.onEvent.subscribe((e) => { request.onEvent.subscribe((e) => {
@ -89,11 +89,11 @@ export class RelayTimelineLoader {
deleteEventService.stream.unsubscribe(this.handleDeleteEvent, this); deleteEventService.stream.unsubscribe(this.handleDeleteEvent, this);
} }
getFirstEvent(nth = 0, filter?: EventFilter) { getFirstEvent(nth = 0, eventFilter?: EventFilter) {
return this.events.getFirstEvent(nth, filter); return this.events.getFirstEvent(nth, eventFilter);
} }
getLastEvent(nth = 0, filter?: EventFilter) { getLastEvent(nth = 0, eventFilter?: EventFilter) {
return this.events.getLastEvent(nth, filter); return this.events.getLastEvent(nth, eventFilter);
} }
} }
@ -114,7 +114,7 @@ export default class TimelineLoader {
private log: Debugger; private log: Debugger;
private subscription: NostrMultiSubscription; private subscription: NostrMultiSubscription;
relayTimelineLoaders = new Map<string, RelayTimelineLoader>(); blockLoaders = new Map<string, RelayBlockLoader>();
constructor(name: string) { constructor(name: string) {
this.name = name; this.name = name;
@ -153,27 +153,27 @@ export default class TimelineLoader {
if (eventId) this.events.deleteEvent(eventId); if (eventId) this.events.deleteEvent(eventId);
} }
private createLoaders() { private createBlockLoaders() {
if (!this.query) return; if (!this.query) return;
for (const relay of this.relays) { for (const relay of this.relays) {
if (!this.relayTimelineLoaders.has(relay)) { if (!this.blockLoaders.has(relay)) {
const loader = new RelayTimelineLoader(relay, this.query, this.log.extend(relay)); const loader = new RelayBlockLoader(relay, this.query, this.log.extend(relay));
this.relayTimelineLoaders.set(relay, loader); this.blockLoaders.set(relay, loader);
this.events.connect(loader.events); this.events.connect(loader.events);
loader.onBlockFinish.subscribe(this.updateLoading, this); loader.onBlockFinish.subscribe(this.updateLoading, this);
loader.onBlockFinish.subscribe(this.updateComplete, this); loader.onBlockFinish.subscribe(this.updateComplete, this);
} }
} }
} }
private removeLoaders(filter?: (loader: RelayTimelineLoader) => boolean) { private removeBlockLoaders(filter?: (loader: RelayBlockLoader) => boolean) {
for (const [relay, loader] of this.relayTimelineLoaders) { for (const [relay, loader] of this.blockLoaders) {
if (!filter || filter(loader)) { if (!filter || filter(loader)) {
loader.cleanup(); loader.cleanup();
this.events.disconnect(loader.events); this.events.disconnect(loader.events);
loader.onBlockFinish.unsubscribe(this.updateLoading, this); loader.onBlockFinish.unsubscribe(this.updateLoading, this);
loader.onBlockFinish.unsubscribe(this.updateComplete, this); loader.onBlockFinish.unsubscribe(this.updateComplete, this);
this.relayTimelineLoaders.delete(relay); this.blockLoaders.delete(relay);
} }
} }
} }
@ -182,10 +182,10 @@ export default class TimelineLoader {
if (this.relays.sort().join("|") === relays.sort().join("|")) return; if (this.relays.sort().join("|") === relays.sort().join("|")) return;
// remove loaders // remove loaders
this.removeLoaders((loader) => !relays.includes(loader.relay)); this.removeBlockLoaders((loader) => !relays.includes(loader.relay));
this.relays = relays; this.relays = relays;
this.createLoaders(); this.createBlockLoaders();
this.subscription.setRelays(relays); this.subscription.setRelays(relays);
this.updateComplete(); this.updateComplete();
@ -194,7 +194,7 @@ export default class TimelineLoader {
if (JSON.stringify(this.query) === JSON.stringify(query)) return; if (JSON.stringify(this.query) === JSON.stringify(query)) return;
// remove all loaders // remove all loaders
this.removeLoaders(); this.removeBlockLoaders();
this.log("set query", query); this.log("set query", query);
this.query = query; this.query = query;
@ -202,7 +202,7 @@ export default class TimelineLoader {
// forget all events // forget all events
this.forgetEvents(); this.forgetEvents();
// create any missing loaders // create any missing loaders
this.createLoaders(); this.createBlockLoaders();
// update the complete flag // update the complete flag
this.updateComplete(); this.updateComplete();
// update the subscription with the new query // update the subscription with the new query
@ -220,7 +220,7 @@ export default class TimelineLoader {
loadNextBlocks() { loadNextBlocks() {
let triggeredLoad = false; let triggeredLoad = false;
for (const [relay, loader] of this.relayTimelineLoaders) { for (const [relay, loader] of this.blockLoaders) {
if (loader.complete || loader.loading) continue; if (loader.complete || loader.loading) continue;
const event = loader.getLastEvent(this.loadNextBlockBuffer, this.eventFilter); const event = loader.getLastEvent(this.loadNextBlockBuffer, this.eventFilter);
if (!event || event.created_at >= this.cursor) { if (!event || event.created_at >= this.cursor) {
@ -233,7 +233,7 @@ export default class TimelineLoader {
/** @deprecated */ /** @deprecated */
loadMore() { loadMore() {
let triggeredLoad = false; let triggeredLoad = false;
for (const [relay, loader] of this.relayTimelineLoaders) { for (const [relay, loader] of this.blockLoaders) {
if (loader.complete || loader.loading) continue; if (loader.complete || loader.loading) continue;
loader.loadNextBlock(); loader.loadNextBlock();
triggeredLoad = true; triggeredLoad = true;
@ -242,7 +242,7 @@ export default class TimelineLoader {
} }
private updateLoading() { private updateLoading() {
for (const [relay, loader] of this.relayTimelineLoaders) { for (const [relay, loader] of this.blockLoaders) {
if (loader.loading) { if (loader.loading) {
if (!this.loading.value) { if (!this.loading.value) {
this.loading.next(true); this.loading.next(true);
@ -253,7 +253,7 @@ export default class TimelineLoader {
if (this.loading.value) this.loading.next(false); if (this.loading.value) this.loading.next(false);
} }
private updateComplete() { private updateComplete() {
for (const [relay, loader] of this.relayTimelineLoaders) { for (const [relay, loader] of this.blockLoaders) {
if (!loader.complete) { if (!loader.complete) {
this.complete.next(false); this.complete.next(false);
return; return;
@ -270,14 +270,14 @@ export default class TimelineLoader {
reset() { reset() {
this.cursor = dayjs().unix(); this.cursor = dayjs().unix();
this.removeLoaders(); this.removeBlockLoaders();
this.forgetEvents(); this.forgetEvents();
} }
/** close the subscription and remove any event listeners for this timeline */ /** close the subscription and remove any event listeners for this timeline */
cleanup() { cleanup() {
this.close(); this.close();
this.removeLoaders(); this.removeBlockLoaders();
this.events.cleanup(); this.events.cleanup();
deleteEventService.stream.unsubscribe(this.handleDeleteEvent, this); deleteEventService.stream.unsubscribe(this.handleDeleteEvent, this);
} }

View File

@ -13,7 +13,7 @@ import { TrustProvider } from "../../../providers/trust";
import { NoteLink } from "../../note-link"; import { NoteLink } from "../../note-link";
import Timestamp from "../../timestamp"; import Timestamp from "../../timestamp";
import { getSharableEventAddress } from "../../../helpers/nip19"; import { getSharableEventAddress } from "../../../helpers/nip19";
import { InlineNoteContent } from "../../note/inline-note-content"; import { InlineNoteContent } from "../../inline-note-content";
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider"; import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
import HoverLinkOverlay from "../../hover-link-overlay"; import HoverLinkOverlay from "../../hover-link-overlay";

View File

@ -18,7 +18,7 @@ import {
import { NostrEvent } from "../../../types/nostr-event"; import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user-avatar-link"; import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link"; import { UserLink } from "../../user-link";
import { InlineNoteContent } from "../../note/inline-note-content"; import { InlineNoteContent } from "../../inline-note-content";
import { getDownloadURL, getHashtags, getStreamURL } from "../../../helpers/nostr/stemstr"; import { getDownloadURL, getHashtags, getStreamURL } from "../../../helpers/nostr/stemstr";
import { DownloadIcon, ReplyIcon } from "../../icons"; import { DownloadIcon, ReplyIcon } from "../../icons";
import NoteZapButton from "../../note/note-zap-button"; import NoteZapButton from "../../note/note-zap-button";

View File

@ -1,10 +1,10 @@
import React from "react"; import React from "react";
import { Box, BoxProps } from "@chakra-ui/react"; import { Box, BoxProps } from "@chakra-ui/react";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event"; import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
import { EmbedableContent, embedUrls, truncateEmbedableContent } from "../../helpers/embeds"; import { EmbedableContent, embedUrls, truncateEmbedableContent } from "../helpers/embeds";
import { embedNostrLinks, embedNostrMentions, embedNostrHashtags, embedEmoji, renderGenericUrl } from "../embed-types"; import { embedNostrLinks, embedNostrMentions, embedNostrHashtags, embedEmoji, renderGenericUrl } from "./embed-types";
import { LightboxProvider } from "../lightbox-provider"; import { LightboxProvider } from "./lightbox-provider";
function buildContents(event: NostrEvent | DraftNostrEvent) { function buildContents(event: NostrEvent | DraftNostrEvent) {
let content: EmbedableContent = [event.content.trim().replace(/\n+/g, "\n")]; let content: EmbedableContent = [event.content.trim().replace(/\n+/g, "\n")];

View File

@ -46,7 +46,7 @@ import HoverLinkOverlay from "../hover-link-overlay";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import NoteCommunityMetadata from "./note-community-metadata"; import NoteCommunityMetadata from "./note-community-metadata";
import useSingleEvent from "../../hooks/use-single-event"; import useSingleEvent from "../../hooks/use-single-event";
import { InlineNoteContent } from "./inline-note-content"; import { InlineNoteContent } from "../inline-note-content";
import NoteProxyLink from "./components/note-proxy-link"; import NoteProxyLink from "./components/note-proxy-link";
import { NoteDetailsButton } from "./components/note-details-button"; import { NoteDetailsButton } from "./components/note-details-button";
import EventInteractionDetailsModal from "../event-interactions-modal"; import EventInteractionDetailsModal from "../event-interactions-modal";

View File

@ -6,6 +6,7 @@ import { RelayConfig, RelayMode } from "../../classes/relay";
import { getMatchNostrLink } from "../regexp"; import { getMatchNostrLink } from "../regexp";
import { AddressPointer } from "nostr-tools/lib/types/nip19"; import { AddressPointer } from "nostr-tools/lib/types/nip19";
import { safeJson } from "../parse"; import { safeJson } from "../parse";
import { getContentMentions } from "./post";
export function truncatedId(str: string, keep = 6) { export function truncatedId(str: string, keep = 6) {
if (str.length < keep * 2 + 3) return str; if (str.length < keep * 2 + 3) return str;
@ -28,6 +29,9 @@ export function getEventUID(event: NostrEvent) {
export function isReply(event: NostrEvent | DraftNostrEvent) { export function isReply(event: NostrEvent | DraftNostrEvent) {
return event.kind === 1 && !!getReferences(event).replyId; return event.kind === 1 && !!getReferences(event).replyId;
} }
export function isMentionedInContent(event: NostrEvent | DraftNostrEvent, pubkey: string) {
return event.kind === 1 && filterTagsByContentRefs(event.content, event.tags).some((t) => t[1] === pubkey);
}
export function isRepost(event: NostrEvent | DraftNostrEvent) { export function isRepost(event: NostrEvent | DraftNostrEvent) {
const match = event.content.match(getMatchNostrLink()); const match = event.content.match(getMatchNostrLink());

View File

@ -1,12 +1,12 @@
import { PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo } from "react"; import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from "react";
import { Kind } from "nostr-tools"; import { Kind } from "nostr-tools";
import { useReadRelayUrls } from "../hooks/use-client-relays"; import { useReadRelayUrls } from "../hooks/use-client-relays";
import useCurrentAccount from "../hooks/use-current-account"; import useCurrentAccount from "../hooks/use-current-account";
import TimelineLoader from "../classes/timeline-loader"; import TimelineLoader from "../classes/timeline-loader";
import timelineCacheService from "../services/timeline-cache";
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import useClientSideMuteFilter from "../hooks/use-client-side-mute-filter"; import useClientSideMuteFilter from "../hooks/use-client-side-mute-filter";
import useTimelineLoader from "../hooks/use-timeline-loader";
type NotificationTimelineContextType = { type NotificationTimelineContextType = {
timeline?: TimelineLoader; timeline?: TimelineLoader;
@ -25,12 +25,6 @@ export default function NotificationTimelineProvider({ children }: PropsWithChil
const account = useCurrentAccount(); const account = useCurrentAccount();
const readRelays = useReadRelayUrls(); const readRelays = useReadRelayUrls();
const timeline = useMemo(() => {
return account?.pubkey
? timelineCacheService.createTimeline(`${account?.pubkey ?? "anon"}-notification`)
: undefined;
}, [account?.pubkey]);
const userMuteFilter = useClientSideMuteFilter(); const userMuteFilter = useClientSideMuteFilter();
const eventFilter = useCallback( const eventFilter = useCallback(
(event: NostrEvent) => { (event: NostrEvent) => {
@ -39,24 +33,13 @@ export default function NotificationTimelineProvider({ children }: PropsWithChil
}, },
[userMuteFilter], [userMuteFilter],
); );
useEffect(() => {
timeline?.setFilter(eventFilter);
}, [timeline, eventFilter]);
useEffect(() => { const timeline = useTimelineLoader(
if (timeline && account?.pubkey) { `${account?.pubkey ?? "anon"}-notification`,
timeline.setQuery([{ "#p": [account?.pubkey], kinds: [Kind.Text, Kind.Repost, Kind.Reaction, Kind.Zap] }]); readRelays,
} { "#p": [account?.pubkey ?? "0000"], kinds: [Kind.Text, Kind.Repost, Kind.Reaction, Kind.Zap] },
}, [account?.pubkey, timeline]); { enabled: !!account?.pubkey, eventFilter },
);
useEffect(() => {
timeline?.setRelays(readRelays);
}, [readRelays.join("|")]);
useEffect(() => {
timeline?.open();
return () => timeline?.close();
}, [timeline]);
const context = useMemo(() => ({ timeline }), [timeline]); const context = useMemo(() => ({ timeline }), [timeline]);

View File

@ -21,7 +21,7 @@ import { getEventCommunityPointer, getPostSubject } from "../../../helpers/nostr
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider"; import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
import { getSharableEventAddress } from "../../../helpers/nip19"; import { getSharableEventAddress } from "../../../helpers/nip19";
import HoverLinkOverlay from "../../../components/hover-link-overlay"; import HoverLinkOverlay from "../../../components/hover-link-overlay";
import { InlineNoteContent } from "../../../components/note/inline-note-content"; import { InlineNoteContent } from "../../../components/inline-note-content";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { getEventUID, parseHardcodedNoteContent } from "../../../helpers/nostr/events"; import { getEventUID, parseHardcodedNoteContent } from "../../../helpers/nostr/events";
import { UserLink } from "../../../components/user-link"; import { UserLink } from "../../../components/user-link";

View File

@ -1,145 +1,64 @@
import { useCallback, useMemo } from "react"; import { useMemo } from "react";
import { Tab, TabList, TabPanel, TabPanelProps, TabPanels, Tabs, useDisclosure } from "@chakra-ui/react"; import { Flex, useDisclosure } from "@chakra-ui/react";
import { Kind } from "nostr-tools"; import { Kind } from "nostr-tools";
import { NostrEvent } from "../../types/nostr-event";
import RequireCurrentAccount from "../../providers/require-current-account"; import RequireCurrentAccount from "../../providers/require-current-account";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status"; import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer"; import IntersectionObserverProvider from "../../providers/intersection-observer";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import { useNotificationTimeline } from "../../providers/notification-timeline"; import { useNotificationTimeline } from "../../providers/notification-timeline";
import { getReferences } from "../../helpers/nostr/events"; import { isReply } from "../../helpers/nostr/events";
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider"; import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection"; import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import VerticalPageLayout from "../../components/vertical-page-layout"; import VerticalPageLayout from "../../components/vertical-page-layout";
import NotificationItem from "./notification-item"; import NotificationItem from "./notification-item";
import NotificationTypeToggles from "./notification-type-toggles";
function RepliesNotificationsTab({ events }: { events: NostrEvent[] }) {
const timeline = useNotificationTimeline();
const filtered = events.filter((event) => {
if (event.kind === Kind.Text) {
const refs = getReferences(event);
return !!refs.replyId;
}
return false;
});
return (
<>
{filtered.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</>
);
}
function MentionsNotificationsTab({ events }: { events: NostrEvent[] }) {
const timeline = useNotificationTimeline();
const filtered = events.filter((event) => {
if (event.kind === Kind.Text) {
const refs = getReferences(event);
return !refs.replyId;
}
return false;
});
return (
<>
{filtered.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</>
);
}
function ReactionsNotificationsTab({ events }: { events: NostrEvent[] }) {
const timeline = useNotificationTimeline();
const filtered = events.filter((e) => e.kind === Kind.Reaction);
return (
<>
{filtered.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</>
);
}
function SharesNotificationsTab({ events }: { events: NostrEvent[] }) {
const timeline = useNotificationTimeline();
const filtered = events.filter((e) => e.kind === Kind.Repost);
return (
<>
{filtered.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</>
);
}
function ZapNotificationsTab({ events }: { events: NostrEvent[] }) {
const timeline = useNotificationTimeline();
const filtered = events.filter((e) => e.kind === Kind.Zap);
return (
<>
{filtered.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</>
);
}
function NotificationsPage() { function NotificationsPage() {
const { people } = usePeopleListContext(); const { people } = usePeopleListContext();
const peoplePubkeys = useMemo(() => people?.map((p) => p.pubkey), [people]); const peoplePubkeys = useMemo(() => people?.map((p) => p.pubkey), [people]);
const showReplies = useDisclosure({ defaultIsOpen: true });
const showMentions = useDisclosure({ defaultIsOpen: true });
const showZaps = useDisclosure({ defaultIsOpen: true });
const showReposts = useDisclosure({ defaultIsOpen: true });
const showReactions = useDisclosure({ defaultIsOpen: true });
const timeline = useNotificationTimeline(); const timeline = useNotificationTimeline();
const callback = useTimelineCurserIntersectionCallback(timeline); const callback = useTimelineCurserIntersectionCallback(timeline);
const events = useSubject(timeline?.timeline).filter((e) => { const events = useSubject(timeline?.timeline).filter((e) => {
if (peoplePubkeys && e.kind !== Kind.Zap && !peoplePubkeys.includes(e.pubkey)) return false; if (peoplePubkeys && e.kind !== Kind.Zap && !peoplePubkeys.includes(e.pubkey)) return false;
if (e.kind === Kind.Text) {
if (!showReplies.isOpen && isReply(e)) return false;
if (!showMentions.isOpen && !isReply(e)) return false;
}
if (!showReactions.isOpen && e.kind === Kind.Reaction) return false;
if (!showReposts.isOpen && e.kind === Kind.Repost) return false;
if (!showZaps.isOpen && e.kind === Kind.Zap) return false;
return true; return true;
}); });
const tabPanelProps: TabPanelProps = { px: "0", pt: "2", display: "flex", flexDirection: "column", gap: "2" };
return ( return (
<IntersectionObserverProvider callback={callback}> <IntersectionObserverProvider callback={callback}>
<VerticalPageLayout> <VerticalPageLayout>
<Tabs isLazy colorScheme="primary"> <Flex gap="2">
<TabList overflowX="auto" overflowY="hidden"> <NotificationTypeToggles
<Tab>Replies</Tab> showReplies={showReplies}
<Tab>Mentions</Tab> showMentions={showMentions}
<Tab>Reactions</Tab> showZaps={showZaps}
<Tab>Shares</Tab> showReactions={showReactions}
<Tab>Zaps</Tab> showReposts={showReposts}
<PeopleListSelection ml="auto" flexShrink={0} /> />
</TabList> <PeopleListSelection flexShrink={0} />
<TabPanels> </Flex>
<TabPanel {...tabPanelProps}>
<RepliesNotificationsTab events={events} /> {events.map((event) => (
</TabPanel> <NotificationItem key={event.id} event={event} />
<TabPanel {...tabPanelProps}> ))}
<MentionsNotificationsTab events={events} /> <TimelineActionAndStatus timeline={timeline} />
</TabPanel>
<TabPanel {...tabPanelProps}>
<ReactionsNotificationsTab events={events} />
</TabPanel>
<TabPanel {...tabPanelProps}>
<SharesNotificationsTab events={events} />
</TabPanel>
<TabPanel {...tabPanelProps}>
<ZapNotificationsTab events={events} />
</TabPanel>
</TabPanels>
</Tabs>
</VerticalPageLayout> </VerticalPageLayout>
</IntersectionObserverProvider> </IntersectionObserverProvider>
); );

View File

@ -1,89 +1,119 @@
import { ReactNode, forwardRef, memo, useMemo, useRef } from "react"; import { PropsWithChildren, ReactNode, forwardRef, memo, useMemo, useRef } from "react";
import { Box, Card, Flex, Text } from "@chakra-ui/react"; import { AvatarGroup, Box, Flex, IconButton, IconButtonProps, Text, useDisclosure } from "@chakra-ui/react";
import { Kind, nip18, nip25 } from "nostr-tools"; import { Kind, nip18, nip25 } from "nostr-tools";
import UserAvatar from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import useCurrentAccount from "../../hooks/use-current-account"; import useCurrentAccount from "../../hooks/use-current-account";
import { NostrEvent, isATag, isETag } from "../../types/nostr-event"; import { NostrEvent, isATag, isETag } from "../../types/nostr-event";
import { NoteLink } from "../../components/note-link";
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer"; import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { parseZapEvent } from "../../helpers/nostr/zaps"; import { parseZapEvent } from "../../helpers/nostr/zaps";
import { readablizeSats } from "../../helpers/bolt11"; import { readablizeSats } from "../../helpers/bolt11";
import { getEventUID, getReferences, parseCoordinate } from "../../helpers/nostr/events"; import { getEventUID, getReferences, isMentionedInContent, isReply, parseCoordinate } from "../../helpers/nostr/events";
import Timestamp from "../../components/timestamp";
import { EmbedEvent, EmbedEventPointer } from "../../components/embed-event"; import { EmbedEvent, EmbedEventPointer } from "../../components/embed-event";
import EmbeddedUnknown from "../../components/embed-event/event-types/embedded-unknown"; import EmbeddedUnknown from "../../components/embed-event/event-types/embedded-unknown";
import { NoteContents } from "../../components/note/text-note-contents";
import { ErrorBoundary } from "../../components/error-boundary"; import { ErrorBoundary } from "../../components/error-boundary";
import { TrustProvider } from "../../providers/trust"; import { TrustProvider } from "../../providers/trust";
import Heart from "../../components/icons/heart";
import UserAvatarLink from "../../components/user-avatar-link";
import { AtIcon, ChevronDownIcon, ChevronUpIcon, LightningIcon, ReplyIcon, RepostIcon } from "../../components/icons";
import useSingleEvent from "../../hooks/use-single-event";
const Kind1Notification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => { const IconBox = ({ children }: PropsWithChildren) => (
<Box px="2" pb="2">
{children}
</Box>
);
const ExpandableToggleButton = ({
toggle,
...props
}: { toggle: { isOpen: boolean; onToggle: () => void } } & Omit<IconButtonProps, "icon">) => (
<IconButton
icon={toggle.isOpen ? <ChevronUpIcon boxSize={6} /> : <ChevronDownIcon boxSize={6} />}
variant="ghost"
onClick={toggle.onToggle}
{...props}
/>
);
const ReplyNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const account = useCurrentAccount();
const refs = getReferences(event); const refs = getReferences(event);
const parent = useSingleEvent(refs.replyId);
if (!refs.replyId || (parent && parent.pubkey !== account?.pubkey)) return null;
if (refs.replyId) {
return (
<Card variant="outline" p="2" ref={ref}>
<Flex gap="2" alignItems="center" mb="2" wrap="wrap">
<UserAvatar pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} />
{refs.replyId ? <Text>replied to:</Text> : <Text>mentioned you</Text>}
<NoteLink noteId={event.id} color="current" ml="auto">
<Timestamp timestamp={event.created_at} />
</NoteLink>
</Flex>
<EmbedEventPointer pointer={{ type: "note", data: refs.replyId }} />
<NoteContents event={event} mt="2" />
</Card>
);
}
return ( return (
<Box ref={ref}> <Flex gap="2" ref={ref}>
<Flex gap="2" alignItems="center" mb="1"> <IconBox>
<UserAvatar pubkey={event.pubkey} size="xs" /> <ReplyIcon boxSize={8} />
<UserLink pubkey={event.pubkey} /> </IconBox>
<Text>mentioned you in</Text> <Flex direction="column" w="full" gap="2">
<EmbedEvent event={event} />
</Flex> </Flex>
<EmbedEvent event={event} /> </Flex>
</Box>
); );
}); });
const ShareNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => { const MentionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
return (
<Flex gap="2" ref={ref}>
<IconBox>
<AtIcon boxSize={8} />
</IconBox>
<Flex direction="column" w="full" gap="2">
<EmbedEvent event={event} />
</Flex>
</Flex>
);
});
const RepostNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const account = useCurrentAccount()!; const account = useCurrentAccount()!;
const pointer = nip18.getRepostedEventPointer(event); const pointer = nip18.getRepostedEventPointer(event);
const expanded = useDisclosure({ defaultIsOpen: true });
if (pointer?.author !== account.pubkey) return null; if (pointer?.author !== account.pubkey) return null;
return ( return (
<Box ref={ref}> <Flex gap="2" ref={ref}>
<Flex gap="2" alignItems="center" mb="2"> <IconBox>
<UserAvatar pubkey={event.pubkey} size="xs" /> <RepostIcon boxSize={8} />
<UserLink pubkey={event.pubkey} /> </IconBox>
<Text>shared note:</Text> <Flex direction="column" w="full" gap="2">
<NoteLink noteId={event.id} color="current" ml="auto"> <Flex gap="2" alignItems="center">
<Timestamp timestamp={event.created_at} /> <AvatarGroup size="sm">
</NoteLink> <UserAvatarLink pubkey={event.pubkey} />
</AvatarGroup>
<ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />
</Flex>
{expanded.isOpen && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />}
</Flex> </Flex>
{pointer && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />} </Flex>
</Box>
); );
}); });
const ReactionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => { const ReactionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const account = useCurrentAccount(); const account = useCurrentAccount();
const pointer = nip25.getReactedEventPointer(event); const pointer = nip25.getReactedEventPointer(event);
const expanded = useDisclosure({ defaultIsOpen: true });
if (!pointer || (account?.pubkey && pointer.author !== account.pubkey)) return null; if (!pointer || (account?.pubkey && pointer.author !== account.pubkey)) return null;
return ( return (
<Box ref={ref}> <Flex gap="2" ref={ref}>
<Flex gap="2" alignItems="center" mb="1"> <IconBox>
<UserAvatar pubkey={event.pubkey} size="xs" /> <Heart boxSize={8} />
<UserLink pubkey={event.pubkey} /> </IconBox>
<Text>reacted {event.content} to your post</Text> <Flex direction="column" w="full" gap="2">
<Timestamp timestamp={event.created_at} ml="auto" /> <Flex gap="2" alignItems="center">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={event.pubkey} />
</AvatarGroup>
<Text fontSize="xl">{event.content}</Text>
<ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />
{/* <Timestamp timestamp={event.created_at} ml="auto" /> */}
</Flex>
{expanded.isOpen && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />}
</Flex> </Flex>
<EmbedEventPointer pointer={{ type: "nevent", data: pointer }} /> </Flex>
</Box>
); );
}); });
@ -99,6 +129,7 @@ const ZapNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ eve
const eventId = zap?.request.tags.find(isETag)?.[1]; const eventId = zap?.request.tags.find(isETag)?.[1];
const coordinate = zap?.request.tags.find(isATag)?.[1]; const coordinate = zap?.request.tags.find(isATag)?.[1];
const parsedCoordinate = coordinate ? parseCoordinate(coordinate) : null; const parsedCoordinate = coordinate ? parseCoordinate(coordinate) : null;
const expanded = useDisclosure({ defaultIsOpen: true });
let eventJSX: ReactNode | null = null; let eventJSX: ReactNode | null = null;
if (parsedCoordinate && parsedCoordinate.identifier) { if (parsedCoordinate && parsedCoordinate.identifier) {
@ -119,32 +150,42 @@ const ZapNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ eve
} }
return ( return (
<Card variant="outline" p="2" ref={ref}> <Flex gap="2" ref={ref}>
<Flex direction="row" gap="2" alignItems="center" mb="2"> <IconBox>
<UserAvatar pubkey={zap.request.pubkey} size="xs" /> <LightningIcon boxSize={8} />
<UserLink pubkey={zap.request.pubkey} /> </IconBox>
<Text>zapped {readablizeSats(zap.payment.amount / 1000)} sats</Text> <Flex direction="column" w="full" gap="2">
<Timestamp color="current" ml="auto" timestamp={zap.request.created_at} /> <Flex gap="2" alignItems="center">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={zap.request.pubkey} />
</AvatarGroup>
<Text>{readablizeSats(zap.payment.amount / 1000)} sats</Text>
<ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />
{/* <Timestamp timestamp={event.created_at} ml="auto" /> */}
</Flex>
{expanded.isOpen && eventJSX}
</Flex> </Flex>
{eventJSX} </Flex>
</Card>
); );
}); });
const NotificationItem = ({ event }: { event: NostrEvent }) => { const NotificationItem = ({ event }: { event: NostrEvent }) => {
const account = useCurrentAccount();
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(event)); useRegisterIntersectionEntity(ref, getEventUID(event));
let content: ReactNode | null = null; let content: ReactNode | null = null;
switch (event.kind) { switch (event.kind) {
case Kind.Text: case Kind.Text:
content = <Kind1Notification event={event} ref={ref} />; if (isReply(event)) content = <ReplyNotification event={event} ref={ref} />;
else if (account?.pubkey && isMentionedInContent(event, account.pubkey))
content = <MentionNotification event={event} ref={ref} />;
break; break;
case Kind.Reaction: case Kind.Reaction:
content = <ReactionNotification event={event} ref={ref} />; content = <ReactionNotification event={event} ref={ref} />;
break; break;
case Kind.Repost: case Kind.Repost:
content = <ShareNotification event={event} ref={ref} />; content = <RepostNotification event={event} ref={ref} />;
break; break;
case Kind.Zap: case Kind.Zap:
content = <ZapNotification event={event} ref={ref} />; content = <ZapNotification event={event} ref={ref} />;

View File

@ -0,0 +1,61 @@
import { ButtonGroup, ButtonGroupProps, IconButton, IconButtonProps } from "@chakra-ui/react";
import { AtIcon, LightningIcon, ReplyIcon, RepostIcon } from "../../components/icons";
import Heart from "../../components/icons/heart";
type Disclosure = { isOpen: boolean; onToggle: () => void };
function ToggleIconButton({ toggle, ...props }: IconButtonProps & { toggle: Disclosure }) {
return <IconButton colorScheme={toggle.isOpen ? "primary" : undefined} onClick={toggle.onToggle} {...props} />;
}
type NotificationTypeTogglesPropTypes = Omit<ButtonGroupProps, "children"> & {
showReplies: Disclosure;
showMentions: Disclosure;
showZaps: Disclosure;
showReposts: Disclosure;
showReactions: Disclosure;
};
export default function NotificationTypeToggles({
showReplies,
showMentions,
showZaps,
showReposts,
showReactions,
...props
}: NotificationTypeTogglesPropTypes) {
return (
<ButtonGroup variant="outline" {...props}>
<ToggleIconButton
icon={<ReplyIcon boxSize={5} />}
aria-label="Toggle replies"
title="Toggle replies"
toggle={showReplies}
/>
<ToggleIconButton
icon={<AtIcon boxSize={5} />}
aria-label="Toggle reposts"
title="Toggle reposts"
toggle={showMentions}
/>
<ToggleIconButton
icon={<LightningIcon boxSize={5} />}
aria-label="Toggle zaps"
title="Toggle zaps"
toggle={showZaps}
/>
<ToggleIconButton
icon={<RepostIcon boxSize={5} />}
aria-label="Toggle reposts"
title="Toggle reposts"
toggle={showReposts}
/>
<ToggleIconButton
icon={<Heart boxSize={5} />}
aria-label="Toggle reactions"
title="Toggle reactions"
toggle={showReactions}
/>
</ButtonGroup>
);
}

View File

@ -1,20 +0,0 @@
import { Kind } from "nostr-tools";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import useSubject from "../../hooks/use-subject";
import { useNotificationTimeline } from "../../providers/notification-timeline";
import NotificationItem from "./notification-item";
export default function ZapNotificationsTab() {
const timeline = useNotificationTimeline();
const events = useSubject(timeline?.timeline).filter((e) => e.kind === Kind.Zap) ?? [];
return (
<>
{events.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</>
);
}