show notifications on timeline

This commit is contained in:
hzrd149 2024-03-21 11:14:24 -05:00
parent 5b52792236
commit 1eb6c498a6
14 changed files with 318 additions and 104 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show notifications on launchpad

View File

@ -0,0 +1,145 @@
import { NostrEvent, kinds, nip18, nip25 } from "nostr-tools";
import _throttle from "lodash.throttle";
import EventStore from "./event-store";
import { PersistentSubject } from "./subject";
import { getThreadReferences, isPTagMentionedInContent, isReply, isRepost } from "../helpers/nostr/event";
import { getParsedZap } from "../helpers/nostr/zaps";
import singleEventService from "../services/single-event";
import RelaySet from "./relay-set";
import clientRelaysService from "../services/client-relays";
import { getPubkeysMentionedInContent } from "../helpers/nostr/post";
import { TORRENT_COMMENT_KIND } from "../helpers/nostr/torrents";
import { STREAM_CHAT_MESSAGE_KIND } from "../helpers/nostr/stream";
export const typeSymbol = Symbol("notificationType");
export enum NotificationType {
Reply = "reply",
Repost = "repost",
Zap = "zap",
Reaction = "reaction",
Mention = "mention",
}
export type CategorizedEvent = NostrEvent & { [typeSymbol]?: NotificationType };
export default class AccountNotifications {
store: EventStore;
pubkey: string;
private subs: ZenObservable.Subscription[] = [];
timeline = new PersistentSubject<CategorizedEvent[]>([]);
constructor(pubkey: string, store: EventStore) {
this.store = store;
this.pubkey = pubkey;
this.subs.push(store.onEvent.subscribe(this.handleEvent.bind(this)));
for (const [_, event] of store.events) this.handleEvent(event);
}
private categorizeEvent(event: NostrEvent): CategorizedEvent {
const e = event as CategorizedEvent;
if (event.kind === kinds.Zap) {
e[typeSymbol] = NotificationType.Zap;
} else if (event.kind === kinds.Reaction) {
e[typeSymbol] = NotificationType.Reaction;
} else if (isRepost(event)) {
e[typeSymbol] = NotificationType.Repost;
} else if (
event.kind === kinds.ShortTextNote ||
event.kind === TORRENT_COMMENT_KIND ||
event.kind === STREAM_CHAT_MESSAGE_KIND ||
event.kind === kinds.LongFormArticle
) {
// is the "p" tag directly mentioned in the content
const isMentioned = isPTagMentionedInContent(event, this.pubkey);
// is the pubkey mentioned in any way in the content
const isQuoted = !isMentioned && getPubkeysMentionedInContent(event.content).includes(this.pubkey);
if (isMentioned || isQuoted) e[typeSymbol] = NotificationType.Mention;
if (isReply(event)) e[typeSymbol] = NotificationType.Reply;
}
return e;
}
handleEvent(event: NostrEvent) {
const e = this.categorizeEvent(event);
const getAndSubscribe = (eventId: string, relays?: string[]) => {
const subject = singleEventService.requestEvent(
eventId,
RelaySet.from(clientRelaysService.readRelays.value, relays),
);
subject.once(this.throttleUpdateTimeline);
return subject.value;
};
switch (e[typeSymbol]) {
case NotificationType.Reply:
const refs = getThreadReferences(e);
if (refs.reply?.e?.id) getAndSubscribe(refs.reply.e.id, refs.reply.e.relays);
break;
case NotificationType.Reaction: {
const pointer = nip25.getReactedEventPointer(e);
if (pointer?.id) getAndSubscribe(pointer.id, pointer.relays);
break;
}
}
}
throttleUpdateTimeline = _throttle(this.updateTimeline.bind(this), 200);
updateTimeline() {
const sorted = this.store.getSortedEvents();
const timeline: CategorizedEvent[] = [];
for (const event of sorted) {
if (!Object.hasOwn(event, typeSymbol)) continue;
const e = event as CategorizedEvent;
switch (e[typeSymbol]) {
case NotificationType.Reply:
const refs = getThreadReferences(e);
if (!refs.reply?.e?.id) break;
if (refs.reply?.e?.author && refs.reply?.e?.author !== this.pubkey) break;
const parent = singleEventService.getSubject(refs.reply.e.id).value;
if (!parent || parent.pubkey !== this.pubkey) break;
timeline.push(e);
break;
case NotificationType.Mention:
timeline.push(e);
break;
case NotificationType.Repost: {
const pointer = nip18.getRepostedEventPointer(e);
if (pointer?.author !== this.pubkey) break;
timeline.push(e);
break;
}
case NotificationType.Reaction: {
const pointer = nip25.getReactedEventPointer(e);
if (!pointer) break;
if (pointer.author !== this.pubkey) break;
if (pointer.kind === kinds.EncryptedDirectMessage) break;
const parent = singleEventService.getSubject(pointer.id).value;
if (parent && parent.kind === kinds.EncryptedDirectMessage) break;
timeline.push(e);
break;
}
case NotificationType.Zap:
const parsed = getParsedZap(e);
if (parsed instanceof Error) break;
if (!parsed.payment.amount) break;
timeline.push(e);
break;
}
}
this.timeline.next(timeline);
}
destroy() {
for (const sub of this.subs) sub.unsubscribe();
this.subs = [];
}
}

View File

@ -28,6 +28,16 @@ export default class Subject<T> {
} }
subscribe: Observable<T>["subscribe"]; subscribe: Observable<T>["subscribe"];
once(next: (value: T) => void) {
const sub = this.subscribe((v) => {
if (v !== undefined) {
next(v);
sub.unsubscribe();
}
});
return sub;
}
map<R>(callback: (value: T) => R, defaultValue?: R): Subject<R> { map<R>(callback: (value: T) => R, defaultValue?: R): Subject<R> {
const child = new Subject(defaultValue); const child = new Subject(defaultValue);

View File

@ -1,5 +1,5 @@
import { forwardRef } from "react";
import { IconProps, useColorMode } from "@chakra-ui/react"; import { IconProps, useColorMode } from "@chakra-ui/react";
import { QuestionIcon, QuestionOutlineIcon } from "@chakra-ui/icons";
import useDnsIdentity from "../../hooks/use-dns-identity"; import useDnsIdentity from "../../hooks/use-dns-identity";
import useUserMetadata from "../../hooks/use-user-metadata"; import useUserMetadata from "../../hooks/use-user-metadata";
@ -25,7 +25,7 @@ export function useDnsIdentityColor(pubkey: string) {
} }
} }
export default function UserDnsIdentityIcon({ pubkey, ...props }: { pubkey: string } & IconProps) { const UserDnsIdentityIcon = forwardRef<SVGSVGElement, { pubkey: string } & IconProps>(({ pubkey, ...props }, ref) => {
const metadata = useUserMetadata(pubkey); const metadata = useUserMetadata(pubkey);
const identity = useDnsIdentity(metadata?.nip05); const identity = useDnsIdentity(metadata?.nip05);
@ -34,12 +34,13 @@ export default function UserDnsIdentityIcon({ pubkey, ...props }: { pubkey: stri
} }
if (identity === undefined) { if (identity === undefined) {
return <VerificationFailed color="yellow.500" {...props} />; return <VerificationFailed color="yellow.500" {...props} ref={ref} />;
} else if (identity === null) { } else if (identity === null) {
return <VerificationMissing color="red.500" {...props} />; return <VerificationMissing color="red.500" {...props} ref={ref} />;
} else if (pubkey === identity.pubkey) { } else if (pubkey === identity.pubkey) {
return <VerifiedIcon color="purple.500" {...props} />; return <VerifiedIcon color="purple.500" {...props} ref={ref} />;
} else { } else {
return <VerificationFailed color="red.500" {...props} />; return <VerificationFailed color="red.500" {...props} ref={ref} />;
} }
} });
export default UserDnsIdentityIcon;

View File

@ -39,22 +39,22 @@ export function pointerMatchEvent(event: NostrEvent, pointer: AddressPointer | E
const isReplySymbol = Symbol("isReply"); const isReplySymbol = Symbol("isReply");
export function isReply(event: NostrEvent | DraftNostrEvent) { export function isReply(event: NostrEvent | DraftNostrEvent) {
// @ts-ignore // @ts-expect-error
if (event[isReplySymbol] !== undefined) return event[isReplySymbol] as boolean; if (event[isReplySymbol] !== undefined) return event[isReplySymbol] as boolean;
if (event.kind === kinds.Repost || event.kind === kinds.GenericRepost) return false; if (event.kind === kinds.Repost || event.kind === kinds.GenericRepost) return false;
const isReply = !!getThreadReferences(event).reply; const isReply = !!getThreadReferences(event).reply;
// @ts-ignore // @ts-expect-error
event[isReplySymbol] = isReply; event[isReplySymbol] = isReply;
return isReply; return isReply;
} }
export function isMentionedInContent(event: NostrEvent | DraftNostrEvent, pubkey: string) { export function isPTagMentionedInContent(event: NostrEvent | DraftNostrEvent, pubkey: string) {
return filterTagsByContentRefs(event.content, event.tags).some((t) => t[1] === pubkey); return filterTagsByContentRefs(event.content, event.tags).some((t) => t[1] === pubkey);
} }
const isRepostSymbol = Symbol("isRepost"); const isRepostSymbol = Symbol("isRepost");
export function isRepost(event: NostrEvent | DraftNostrEvent) { export function isRepost(event: NostrEvent | DraftNostrEvent) {
// @ts-ignore // @ts-expect-error
if (event[isRepostSymbol] !== undefined) return event[isRepostSymbol] as boolean; if (event[isRepostSymbol] !== undefined) return event[isRepostSymbol] as boolean;
if (event.kind === kinds.Repost || event.kind === kinds.GenericRepost) return true; if (event.kind === kinds.Repost || event.kind === kinds.GenericRepost) return true;
@ -62,7 +62,7 @@ export function isRepost(event: NostrEvent | DraftNostrEvent) {
const match = event.content.match(getMatchNostrLink()); const match = event.content.match(getMatchNostrLink());
const isRepost = !!match && match[0].length === event.content.length; const isRepost = !!match && match[0].length === event.content.length;
// @ts-ignore // @ts-expect-error
event[isRepostSymbol] = isRepost; event[isRepostSymbol] = isRepost;
return isRepost; return isRepost;
} }
@ -165,20 +165,7 @@ export function interpretThreadTags(event: NostrEvent | DraftNostrEvent) {
}; };
} }
export type EventReferences = ReturnType<typeof getThreadReferences>; export type ThreadReferences = {
export function getThreadReferences(event: NostrEvent | DraftNostrEvent) {
const tags = interpretThreadTags(event);
return {
root: tags.root && {
e: tags.root.e && eTagToEventPointer(tags.root.e),
a: tags.root.a && aTagToAddressPointer(tags.root.a),
},
reply: tags.reply && {
e: tags.reply.e && eTagToEventPointer(tags.reply.e),
a: tags.reply.a && aTagToAddressPointer(tags.reply.a),
},
} as {
root?: root?:
| { e: EventPointer; a: undefined } | { e: EventPointer; a: undefined }
| { e: undefined; a: AddressPointer } | { e: undefined; a: AddressPointer }
@ -187,7 +174,32 @@ export function getThreadReferences(event: NostrEvent | DraftNostrEvent) {
| { e: EventPointer; a: undefined } | { e: EventPointer; a: undefined }
| { e: undefined; a: AddressPointer } | { e: undefined; a: AddressPointer }
| { e: EventPointer; a: AddressPointer }; | { e: EventPointer; a: AddressPointer };
}; };
export const threadRefsSymbol = Symbol("threadRefs");
export type EventWithThread = (NostrEvent | DraftNostrEvent) & { [threadRefsSymbol]: ThreadReferences };
export function getThreadReferences(event: NostrEvent | DraftNostrEvent): ThreadReferences {
// @ts-expect-error
if (Object.hasOwn(event, threadRefsSymbol)) return event[threadRefsSymbol];
const e = event as EventWithThread;
const tags = interpretThreadTags(e);
const threadRef = {
root: tags.root && {
e: tags.root.e && eTagToEventPointer(tags.root.e),
a: tags.root.a && aTagToAddressPointer(tags.root.a),
},
reply: tags.reply && {
e: tags.reply.e && eTagToEventPointer(tags.reply.e),
a: tags.reply.a && aTagToAddressPointer(tags.reply.a),
},
} as ThreadReferences;
// @ts-expect-error
event[threadRefsSymbol] = threadRef;
return threadRef;
} }
export function getEventCoordinate(event: NostrEvent) { export function getEventCoordinate(event: NostrEvent) {

View File

@ -3,7 +3,7 @@ import { isETag, isPTag, NostrEvent } from "../../types/nostr-event";
import { ParsedInvoice, parsePaymentRequest } from "../bolt11"; import { ParsedInvoice, parsePaymentRequest } from "../bolt11";
import { Kind0ParsedContent } from "./user-metadata"; import { Kind0ParsedContent } from "./user-metadata";
import { utils } from "nostr-tools"; import { nip57, utils } from "nostr-tools";
// based on https://github.com/nbd-wtf/nostr-tools/blob/master/nip57.ts // based on https://github.com/nbd-wtf/nostr-tools/blob/master/nip57.ts
export async function getZapEndpoint(metadata: Kind0ParsedContent): Promise<null | string> { export async function getZapEndpoint(metadata: Kind0ParsedContent): Promise<null | string> {
@ -52,6 +52,20 @@ export type ParsedZap = {
eventId?: string; eventId?: string;
}; };
const parsedZapSymbol = Symbol("parsedZap");
type ParsedZapEvent = NostrEvent & { [parsedZapSymbol]: ParsedZap | Error };
export function getParsedZap(event: NostrEvent) {
const e = event as ParsedZapEvent;
if (Object.hasOwn(e, parsedZapSymbol)) return e[parsedZapSymbol];
try {
return (e[parsedZapSymbol] = parseZapEvent(e));
} catch (error) {
if (error instanceof Error) return (e[parsedZapSymbol] = error);
else throw error;
}
}
export function parseZapEvent(event: NostrEvent): ParsedZap { export function parseZapEvent(event: NostrEvent): ParsedZap {
const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1]; const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1];
if (!zapRequestStr) throw new Error("no description tag"); if (!zapRequestStr) throw new Error("no description tag");
@ -59,10 +73,8 @@ export function parseZapEvent(event: NostrEvent): ParsedZap {
const bolt11 = event.tags.find((t) => t[0] === "bolt11")?.[1]; const bolt11 = event.tags.find((t) => t[0] === "bolt11")?.[1];
if (!bolt11) throw new Error("missing bolt11 invoice"); if (!bolt11) throw new Error("missing bolt11 invoice");
// TODO: disabled until signature verification can be offloaded to a web worker const error = nip57.validateZapRequest(zapRequestStr);
if (error) throw new Error(error);
// const error = nip57.validateZapRequest(zapRequestStr);
// if (error) throw new Error(error);
const request = JSON.parse(zapRequestStr) as NostrEvent; const request = JSON.parse(zapRequestStr) as NostrEvent;
const payment = parsePaymentRequest(bolt11); const payment = parsePaymentRequest(bolt11);

View File

@ -1,5 +1,5 @@
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import { EventReferences, getThreadReferences } from "./nostr/event"; import { ThreadReferences, getThreadReferences } from "./nostr/event";
export function countReplies(replies: ThreadItem[]): number { export function countReplies(replies: ThreadItem[]): number {
return replies.reduce((c, item) => c + countReplies(item.replies), 0) + replies.length; return replies.reduce((c, item) => c + countReplies(item.replies), 0) + replies.length;
@ -13,7 +13,7 @@ export type ThreadItem = {
/** the parent event this is replying to */ /** the parent event this is replying to */
replyingTo?: ThreadItem; replyingTo?: ThreadItem;
/** refs from nostr event */ /** refs from nostr event */
refs: EventReferences; refs: ThreadReferences;
/** direct child replies */ /** direct child replies */
replies: ThreadItem[]; replies: ThreadItem[];
}; };

View File

@ -4,7 +4,7 @@ import { ChakraProvider, localStorageManager } from "@chakra-ui/react";
import { SigningProvider } from "./signing-provider"; import { SigningProvider } from "./signing-provider";
import buildTheme from "../../theme"; import buildTheme from "../../theme";
import useAppSettings from "../../hooks/use-app-settings"; import useAppSettings from "../../hooks/use-app-settings";
import NotificationTimelineProvider from "./notification-timeline"; import NotificationsProvider from "./notifications";
import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider"; import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider";
import { AllUserSearchDirectoryProvider } from "./user-directory-provider"; import { AllUserSearchDirectoryProvider } from "./user-directory-provider";
import BreakpointProvider from "./breakpoint-provider"; import BreakpointProvider from "./breakpoint-provider";
@ -26,7 +26,7 @@ export const GlobalProviders = ({ children }: { children: React.ReactNode }) =>
<SigningProvider> <SigningProvider>
<PublishProvider> <PublishProvider>
<DecryptionProvider> <DecryptionProvider>
<NotificationTimelineProvider> <NotificationsProvider>
<DMTimelineProvider> <DMTimelineProvider>
<DefaultEmojiProvider> <DefaultEmojiProvider>
<UserEmojiProvider> <UserEmojiProvider>
@ -34,7 +34,7 @@ export const GlobalProviders = ({ children }: { children: React.ReactNode }) =>
</UserEmojiProvider> </UserEmojiProvider>
</DefaultEmojiProvider> </DefaultEmojiProvider>
</DMTimelineProvider> </DMTimelineProvider>
</NotificationTimelineProvider> </NotificationsProvider>
</DecryptionProvider> </DecryptionProvider>
</PublishProvider> </PublishProvider>
</SigningProvider> </SigningProvider>

View File

@ -1,4 +1,4 @@
import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from "react"; import { PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { kinds } from "nostr-tools"; import { kinds } from "nostr-tools";
import { useReadRelays } from "../../hooks/use-client-relays"; import { useReadRelays } from "../../hooks/use-client-relays";
@ -9,25 +9,27 @@ import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
import useTimelineLoader from "../../hooks/use-timeline-loader"; import useTimelineLoader from "../../hooks/use-timeline-loader";
import { TORRENT_COMMENT_KIND } from "../../helpers/nostr/torrents"; import { TORRENT_COMMENT_KIND } from "../../helpers/nostr/torrents";
import { useUserInbox } from "../../hooks/use-user-mailboxes"; import { useUserInbox } from "../../hooks/use-user-mailboxes";
import AccountNotifications from "../../classes/notifications";
type NotificationTimelineContextType = { type NotificationTimelineContextType = {
timeline?: TimelineLoader; timeline: TimelineLoader;
notifications?: AccountNotifications;
}; };
const NotificationTimelineContext = createContext<NotificationTimelineContextType>({}); const NotificationTimelineContext = createContext<NotificationTimelineContextType | null>(null);
export function useNotificationTimeline() { export function useNotifications() {
const context = useContext(NotificationTimelineContext); const ctx = useContext(NotificationTimelineContext);
if (!ctx) throw new Error("Missing notifications provider");
if (!context?.timeline) throw new Error("No notification timeline"); return ctx;
return context.timeline;
} }
export default function NotificationTimelineProvider({ children }: PropsWithChildren) { export default function NotificationsProvider({ children }: PropsWithChildren) {
const account = useCurrentAccount(); const account = useCurrentAccount();
const inbox = useUserInbox(account?.pubkey); const inbox = useUserInbox(account?.pubkey);
const readRelays = useReadRelays(inbox); const readRelays = useReadRelays(inbox);
const [notifications, setNotifications] = useState<AccountNotifications>();
const userMuteFilter = useClientSideMuteFilter(); const userMuteFilter = useClientSideMuteFilter();
const eventFilter = useCallback( const eventFilter = useCallback(
(event: NostrEvent) => { (event: NostrEvent) => {
@ -57,7 +59,22 @@ export default function NotificationTimelineProvider({ children }: PropsWithChil
{ eventFilter }, { eventFilter },
); );
const context = useMemo(() => ({ timeline }), [timeline]); useEffect(() => {
if (!account?.pubkey) return;
const n = new AccountNotifications(account.pubkey, timeline.events);
setNotifications(n);
if (import.meta.env.DEV) {
// @ts-expect-error
window.accountNotifications = n;
}
return () => {
n.destroy();
setNotifications(undefined);
};
}, [account?.pubkey, timeline.events]);
const context = useMemo(() => ({ timeline, notifications }), [timeline, notifications]);
return <NotificationTimelineContext.Provider value={context}>{children}</NotificationTimelineContext.Provider>; return <NotificationTimelineContext.Provider value={context}>{children}</NotificationTimelineContext.Provider>;
} }

View File

@ -15,6 +15,10 @@ class SingleEventService {
pending = new Map<string, string[]>(); pending = new Map<string, string[]>();
log = logger.extend("SingleEvent"); log = logger.extend("SingleEvent");
getSubject(id: string) {
return this.subjects.get(id);
}
requestEvent(id: string, relays: Iterable<string>) { requestEvent(id: string, relays: Iterable<string>) {
const subject = this.subjects.get(id); const subject = this.subjects.get(id);
if (subject.value) return subject; if (subject.value) return subject;

View File

@ -1,13 +1,29 @@
import { Card, CardBody, CardHeader, CardProps, Heading, Link } from "@chakra-ui/react"; import { Button, Card, CardBody, CardHeader, CardProps, Heading, Link } from "@chakra-ui/react";
import { Link as RouterLink, useNavigate } from "react-router-dom"; import { Link as RouterLink, useNavigate } from "react-router-dom";
import KeyboardShortcut from "../../../components/keyboard-shortcut"; import KeyboardShortcut from "../../../components/keyboard-shortcut";
import { useNotifications } from "../../../providers/global/notifications";
import useSubject from "../../../hooks/use-subject";
import { NotificationType, typeSymbol } from "../../../classes/notifications";
import NotificationItem from "../../notifications/components/notification-item";
export default function NotificationsCard({ ...props }: Omit<CardProps, "children">) { export default function NotificationsCard({ ...props }: Omit<CardProps, "children">) {
const navigate = useNavigate(); const navigate = useNavigate();
const { notifications } = useNotifications();
const events =
useSubject(notifications?.timeline)?.filter(
(event) =>
event[typeSymbol] === NotificationType.Mention ||
event[typeSymbol] === NotificationType.Reply ||
event[typeSymbol] === NotificationType.Zap,
) ?? [];
const limit = events.length > 20 ? events.slice(0, 20) : events;
return ( return (
<Card variant="outline" {...props}> <Card variant="outline" {...props}>
<CardHeader display="flex" justifyContent="space-between" alignItems="center"> <CardHeader display="flex" justifyContent="space-between" alignItems="center" pb="2">
<Heading size="lg"> <Heading size="lg">
<Link as={RouterLink} to="/notifications"> <Link as={RouterLink} to="/notifications">
Notifications Notifications
@ -15,8 +31,13 @@ export default function NotificationsCard({ ...props }: Omit<CardProps, "childre
</Heading> </Heading>
<KeyboardShortcut letter="i" requireMeta ml="auto" onPress={() => navigate("/notifications")} /> <KeyboardShortcut letter="i" requireMeta ml="auto" onPress={() => navigate("/notifications")} />
</CardHeader> </CardHeader>
<CardBody overflowX="auto" overflowY="hidden" pt="0" display="flex" gap="4"> <CardBody overflowX="hidden" overflowY="auto" pt="4" display="flex" gap="2" flexDirection="column" maxH="50vh">
<h1>Nothing here yet</h1> {limit.map((event) => (
<NotificationItem event={event} key={event.id} />
))}
<Button as={RouterLink} to="/notifications" flexShrink={0} variant="link" size="lg" py="6">
View More
</Button>
</CardBody> </CardBody>
</Card> </Card>
); );

View File

@ -7,7 +7,7 @@ import { NostrEvent, isATag, isETag } from "../../../types/nostr-event";
import { useRegisterIntersectionEntity } from "../../../providers/local/intersection-observer"; import { useRegisterIntersectionEntity } from "../../../providers/local/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, getThreadReferences, isMentionedInContent, parseCoordinate } from "../../../helpers/nostr/event"; import { getEventUID, parseCoordinate } from "../../../helpers/nostr/event";
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 { ErrorBoundary } from "../../../components/error-boundary"; import { ErrorBoundary } from "../../../components/error-boundary";
@ -23,9 +23,8 @@ import {
RepostIcon, RepostIcon,
} from "../../../components/icons"; } from "../../../components/icons";
import useSingleEvent from "../../../hooks/use-single-event"; import useSingleEvent from "../../../hooks/use-single-event";
import { TORRENT_COMMENT_KIND } from "../../../helpers/nostr/torrents";
import NotificationIconEntry from "./notification-icon-entry"; import NotificationIconEntry from "./notification-icon-entry";
import { getPubkeysMentionedInContent } from "../../../helpers/nostr/post"; import { CategorizedEvent, NotificationType, typeSymbol } from "../../../classes/notifications";
export const ExpandableToggleButton = ({ export const ExpandableToggleButton = ({
toggle, toggle,
@ -39,21 +38,6 @@ export const ExpandableToggleButton = ({
/> />
); );
const NoteNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const account = useCurrentAccount()!;
const refs = getThreadReferences(event);
const parent = useSingleEvent(refs.reply?.e?.id);
const isReplyingToMe = !!refs.reply?.e?.id && (parent ? parent.pubkey === account.pubkey : true);
// is the "p" tag directly mentioned in the content
const isMentioned = isMentionedInContent(event, account.pubkey);
// is the pubkey mentioned in any way in the content
const isQuoted = !isMentioned && getPubkeysMentionedInContent(event.content).includes(account.pubkey);
if (isReplyingToMe) return <ReplyNotification event={event} ref={ref} />;
else if (isMentioned || isQuoted) return <MentionNotification event={event} ref={ref} />;
else return null;
});
const ReplyNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => ( const ReplyNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => (
<NotificationIconEntry ref={ref} icon={<ReplyIcon boxSize={8} color="green.400" />}> <NotificationIconEntry ref={ref} icon={<ReplyIcon boxSize={8} color="green.400" />}>
<EmbedEvent event={event} /> <EmbedEvent event={event} />
@ -67,11 +51,9 @@ const MentionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({
)); ));
const RepostNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => { const RepostNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const account = useCurrentAccount()!;
const pointer = nip18.getRepostedEventPointer(event); const pointer = nip18.getRepostedEventPointer(event);
const expanded = useDisclosure({ defaultIsOpen: true }); const expanded = useDisclosure({ defaultIsOpen: true });
if (!pointer) return null;
if (pointer?.author !== account.pubkey) return null;
return ( return (
<NotificationIconEntry ref={ref} icon={<RepostIcon boxSize={8} color="blue.400" />}> <NotificationIconEntry ref={ref} icon={<RepostIcon boxSize={8} color="blue.400" />}>
@ -157,24 +139,25 @@ const ZapNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ eve
); );
}); });
const NotificationItem = ({ event }: { event: NostrEvent }) => { const NotificationItem = ({ event }: { event: CategorizedEvent }) => {
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[typeSymbol]) {
case kinds.ShortTextNote: case NotificationType.Reply:
case TORRENT_COMMENT_KIND: content = <ReplyNotification event={event} ref={ref} />;
case kinds.LongFormArticle:
content = <NoteNotification event={event} ref={ref} />;
break; break;
case kinds.Reaction: case NotificationType.Mention:
content = <MentionNotification event={event} ref={ref} />;
break;
case NotificationType.Reaction:
content = <ReactionNotification event={event} ref={ref} />; content = <ReactionNotification event={event} ref={ref} />;
break; break;
case kinds.Repost: case NotificationType.Repost:
content = <RepostNotification event={event} ref={ref} />; content = <RepostNotification event={event} ref={ref} />;
break; break;
case kinds.Zap: case NotificationType.Zap:
content = <ZapNotification event={event} ref={ref} />; content = <ZapNotification event={event} ref={ref} />;
break; break;
default: default:

View File

@ -12,7 +12,7 @@ import IntersectionObserverProvider, {
} from "../../providers/local/intersection-observer"; } from "../../providers/local/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/global/notification-timeline"; import { useNotifications } from "../../providers/global/notifications";
import { getEventUID, isReply } from "../../helpers/nostr/event"; import { getEventUID, isReply } from "../../helpers/nostr/event";
import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider"; import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection"; import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
@ -25,6 +25,7 @@ import DayGroup from "./components/day-group";
import TimelineLoader from "../../classes/timeline-loader"; import TimelineLoader from "../../classes/timeline-loader";
import { ChevronLeftIcon, ChevronRightIcon } from "../../components/icons"; import { ChevronLeftIcon, ChevronRightIcon } from "../../components/icons";
import useRouteSearchValue from "../../hooks/use-route-search-value"; import useRouteSearchValue from "../../hooks/use-route-search-value";
import { NotificationType, typeSymbol } from "../../classes/notifications";
const DATE_FORMAT = "YYYY-MM-DD"; const DATE_FORMAT = "YYYY-MM-DD";
@ -43,7 +44,6 @@ const NotificationDay = memo(({ day, events }: { day: number; events: NostrEvent
const NotificationsTimeline = memo( const NotificationsTimeline = memo(
({ ({
timeline,
day, day,
showReplies, showReplies,
showMentions, showMentions,
@ -51,7 +51,6 @@ const NotificationsTimeline = memo(
showReposts, showReposts,
showReactions, showReactions,
}: { }: {
timeline: TimelineLoader;
day: string; day: string;
showReplies: boolean; showReplies: boolean;
showMentions: boolean; showMentions: boolean;
@ -59,32 +58,34 @@ const NotificationsTimeline = memo(
showReposts: boolean; showReposts: boolean;
showReactions: boolean; showReactions: boolean;
}) => { }) => {
const { notifications } = useNotifications();
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 minTimestamp = dayjs(day, DATE_FORMAT).startOf("day").unix(); const minTimestamp = dayjs(day, DATE_FORMAT).startOf("day").unix();
const maxTimestamp = dayjs(day, DATE_FORMAT).endOf("day").unix(); const maxTimestamp = dayjs(day, DATE_FORMAT).endOf("day").unix();
const events = useSubject(timeline.timeline); const events = useSubject(notifications?.timeline) ?? [];
const throttledEvents = useThrottle(events, 500);
const filteredEvents = useMemo( const filteredEvents = useMemo(
() => () =>
throttledEvents.filter((e) => { events.filter((e) => {
if (e.created_at < minTimestamp || e.created_at > maxTimestamp) return false; if (e.created_at < minTimestamp || e.created_at > maxTimestamp) return false;
if (peoplePubkeys && e.kind !== kinds.Zap && !peoplePubkeys.includes(e.pubkey)) return false;
if (e.kind === kinds.ShortTextNote) { if (e[typeSymbol] === NotificationType.Zap) {
if (!showReplies && isReply(e)) return false; if (!showZaps) return false;
if (!showMentions && !isReply(e)) return false; if (peoplePubkeys && !peoplePubkeys.includes(e.pubkey)) return false;
} }
if (!showReactions && e.kind === kinds.Reaction) return false;
if (!showReposts && (e.kind === kinds.Repost || e.kind === kinds.GenericRepost)) return false; if (!showReplies && e[typeSymbol] === NotificationType.Reply) return false;
if (!showZaps && e.kind === kinds.Zap) return false; if (!showMentions && e[typeSymbol] === NotificationType.Mention) return false;
if (!showReactions && e[typeSymbol] === NotificationType.Reaction) return false;
if (!showReposts && e[typeSymbol] === NotificationType.Repost) return false;
if (!showZaps && e[typeSymbol] === NotificationType.Zap) return false;
return true; return true;
}), }),
[ [
throttledEvents, events,
peoplePubkeys, peoplePubkeys,
showReplies, showReplies,
showMentions, showMentions,
@ -123,6 +124,8 @@ const NotificationsTimeline = memo(
); );
function NotificationsPage() { function NotificationsPage() {
const { timeline } = useNotifications();
const showReplies = useDisclosure({ defaultIsOpen: localStorage.getItem("notifications-show-replies") !== "false" }); const showReplies = useDisclosure({ defaultIsOpen: localStorage.getItem("notifications-show-replies") !== "false" });
const showMentions = useDisclosure({ const showMentions = useDisclosure({
defaultIsOpen: localStorage.getItem("notifications-show-mentions") !== "false", defaultIsOpen: localStorage.getItem("notifications-show-mentions") !== "false",
@ -134,7 +137,10 @@ function NotificationsPage() {
}); });
const today = dayjs().format(DATE_FORMAT); const today = dayjs().format(DATE_FORMAT);
const { value: day, setValue: setDay } = useRouteSearchValue("date", dayjs().format(DATE_FORMAT)); const { value: day, setValue: setDay } = useRouteSearchValue(
"date",
timeline.timeline.value[0] ? dayjs.unix(timeline.timeline.value[0].created_at).format(DATE_FORMAT) : today,
);
const nextDay = () => { const nextDay = () => {
setDay((date) => setDay((date) =>
@ -143,7 +149,7 @@ function NotificationsPage() {
.format(DATE_FORMAT), .format(DATE_FORMAT),
); );
}; };
const perviousDay = () => { const previousDay = () => {
setDay((date) => setDay((date) =>
dayjs(date ?? today, DATE_FORMAT) dayjs(date ?? today, DATE_FORMAT)
.subtract(1, "day") .subtract(1, "day")
@ -160,14 +166,13 @@ function NotificationsPage() {
localStorage.setItem("notifications-show-reactions", String(showReactions.isOpen)); localStorage.setItem("notifications-show-reactions", String(showReactions.isOpen));
}, [showReplies.isOpen, showMentions.isOpen, showZaps.isOpen, showReposts.isOpen, showReactions.isOpen]); }, [showReplies.isOpen, showMentions.isOpen, showZaps.isOpen, showReposts.isOpen, showReactions.isOpen]);
const timeline = useNotificationTimeline();
const callback = useTimelineCurserIntersectionCallback(timeline); const callback = useTimelineCurserIntersectionCallback(timeline);
return ( return (
<VerticalPageLayout> <VerticalPageLayout>
<Flex direction={{ base: "column", lg: "row-reverse" }} gap="2" justifyContent="space-between"> <Flex direction={{ base: "column", lg: "row-reverse" }} gap="2" justifyContent="space-between">
<Flex gap="2" justifyContent="space-between"> <Flex gap="2" justifyContent="space-between">
<IconButton aria-label="Pervious" icon={<ChevronLeftIcon boxSize={6} />} onClick={perviousDay} /> <IconButton aria-label="Previous" icon={<ChevronLeftIcon boxSize={6} />} onClick={previousDay} />
<Input <Input
maxW="xs" maxW="xs"
minW="64" minW="64"
@ -203,7 +208,6 @@ function NotificationsPage() {
<IntersectionObserverProvider callback={callback}> <IntersectionObserverProvider callback={callback}>
<NotificationsTimeline <NotificationsTimeline
timeline={timeline}
day={day} day={day}
showReplies={showReplies.isOpen} showReplies={showReplies.isOpen}
showMentions={showMentions.isOpen} showMentions={showMentions.isOpen}
@ -216,8 +220,8 @@ function NotificationsPage() {
{/* <TimelineActionAndStatus timeline={timeline} /> */} {/* <TimelineActionAndStatus timeline={timeline} /> */}
<ButtonGroup mx="auto" mt="4"> <ButtonGroup mx="auto" mt="4">
<Button leftIcon={<ChevronLeftIcon boxSize={6} />} onClick={perviousDay}> <Button leftIcon={<ChevronLeftIcon boxSize={6} />} onClick={previousDay}>
Pervious Previous
</Button> </Button>
{day !== today && ( {day !== today && (
<Button rightIcon={<ChevronRightIcon boxSize={6} />} onClick={nextDay}> <Button rightIcon={<ChevronRightIcon boxSize={6} />} onClick={nextDay}>

View File

@ -8,7 +8,7 @@ import RequireCurrentAccount from "../../providers/route/require-current-account
import VerticalPageLayout from "../../components/vertical-page-layout"; import VerticalPageLayout from "../../components/vertical-page-layout";
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/global/notification-timeline"; import { useNotifications } from "../../providers/global/notifications";
import { TORRENT_COMMENT_KIND } from "../../helpers/nostr/torrents"; import { TORRENT_COMMENT_KIND } from "../../helpers/nostr/torrents";
import { groupByRoot } from "../../helpers/notification"; import { groupByRoot } from "../../helpers/notification";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
@ -99,7 +99,7 @@ function ThreadsNotificationsPage() {
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 timeline = useNotificationTimeline(); const { timeline } = useNotifications();
const callback = useTimelineCurserIntersectionCallback(timeline); const callback = useTimelineCurserIntersectionCallback(timeline);
const events = useSubject(timeline?.timeline); const events = useSubject(timeline?.timeline);