mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-10 04:39:19 +02:00
show notifications on timeline
This commit is contained in:
parent
5b52792236
commit
1eb6c498a6
5
.changeset/tame-files-worry.md
Normal file
5
.changeset/tame-files-worry.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Show notifications on launchpad
|
145
src/classes/notifications.ts
Normal file
145
src/classes/notifications.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
@ -28,6 +28,16 @@ export default class Subject<T> {
|
||||
}
|
||||
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> {
|
||||
const child = new Subject(defaultValue);
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { forwardRef } from "react";
|
||||
import { IconProps, useColorMode } from "@chakra-ui/react";
|
||||
import { QuestionIcon, QuestionOutlineIcon } from "@chakra-ui/icons";
|
||||
|
||||
import useDnsIdentity from "../../hooks/use-dns-identity";
|
||||
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 identity = useDnsIdentity(metadata?.nip05);
|
||||
|
||||
@ -34,12 +34,13 @@ export default function UserDnsIdentityIcon({ pubkey, ...props }: { pubkey: stri
|
||||
}
|
||||
|
||||
if (identity === undefined) {
|
||||
return <VerificationFailed color="yellow.500" {...props} />;
|
||||
return <VerificationFailed color="yellow.500" {...props} ref={ref} />;
|
||||
} else if (identity === null) {
|
||||
return <VerificationMissing color="red.500" {...props} />;
|
||||
return <VerificationMissing color="red.500" {...props} ref={ref} />;
|
||||
} else if (pubkey === identity.pubkey) {
|
||||
return <VerifiedIcon color="purple.500" {...props} />;
|
||||
return <VerifiedIcon color="purple.500" {...props} ref={ref} />;
|
||||
} else {
|
||||
return <VerificationFailed color="red.500" {...props} />;
|
||||
return <VerificationFailed color="red.500" {...props} ref={ref} />;
|
||||
}
|
||||
}
|
||||
});
|
||||
export default UserDnsIdentityIcon;
|
||||
|
@ -39,22 +39,22 @@ export function pointerMatchEvent(event: NostrEvent, pointer: AddressPointer | E
|
||||
|
||||
const isReplySymbol = Symbol("isReply");
|
||||
export function isReply(event: NostrEvent | DraftNostrEvent) {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
if (event[isReplySymbol] !== undefined) return event[isReplySymbol] as boolean;
|
||||
|
||||
if (event.kind === kinds.Repost || event.kind === kinds.GenericRepost) return false;
|
||||
const isReply = !!getThreadReferences(event).reply;
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
event[isReplySymbol] = 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);
|
||||
}
|
||||
|
||||
const isRepostSymbol = Symbol("isRepost");
|
||||
export function isRepost(event: NostrEvent | DraftNostrEvent) {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
if (event[isRepostSymbol] !== undefined) return event[isRepostSymbol] as boolean;
|
||||
|
||||
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 isRepost = !!match && match[0].length === event.content.length;
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
event[isRepostSymbol] = isRepost;
|
||||
return isRepost;
|
||||
}
|
||||
@ -165,11 +165,27 @@ export function interpretThreadTags(event: NostrEvent | DraftNostrEvent) {
|
||||
};
|
||||
}
|
||||
|
||||
export type EventReferences = ReturnType<typeof getThreadReferences>;
|
||||
export function getThreadReferences(event: NostrEvent | DraftNostrEvent) {
|
||||
const tags = interpretThreadTags(event);
|
||||
export type ThreadReferences = {
|
||||
root?:
|
||||
| { e: EventPointer; a: undefined }
|
||||
| { e: undefined; a: AddressPointer }
|
||||
| { e: EventPointer; a: AddressPointer };
|
||||
reply?:
|
||||
| { e: EventPointer; a: undefined }
|
||||
| { e: undefined; a: AddressPointer }
|
||||
| { e: EventPointer; a: AddressPointer };
|
||||
};
|
||||
export const threadRefsSymbol = Symbol("threadRefs");
|
||||
export type EventWithThread = (NostrEvent | DraftNostrEvent) & { [threadRefsSymbol]: ThreadReferences };
|
||||
|
||||
return {
|
||||
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),
|
||||
@ -178,16 +194,12 @@ export function getThreadReferences(event: NostrEvent | DraftNostrEvent) {
|
||||
e: tags.reply.e && eTagToEventPointer(tags.reply.e),
|
||||
a: tags.reply.a && aTagToAddressPointer(tags.reply.a),
|
||||
},
|
||||
} as {
|
||||
root?:
|
||||
| { e: EventPointer; a: undefined }
|
||||
| { e: undefined; a: AddressPointer }
|
||||
| { e: EventPointer; a: AddressPointer };
|
||||
reply?:
|
||||
| { e: EventPointer; a: undefined }
|
||||
| { e: undefined; a: AddressPointer }
|
||||
| { e: EventPointer; a: AddressPointer };
|
||||
};
|
||||
} as ThreadReferences;
|
||||
|
||||
// @ts-expect-error
|
||||
event[threadRefsSymbol] = threadRef;
|
||||
|
||||
return threadRef;
|
||||
}
|
||||
|
||||
export function getEventCoordinate(event: NostrEvent) {
|
||||
|
@ -3,7 +3,7 @@ import { isETag, isPTag, NostrEvent } from "../../types/nostr-event";
|
||||
import { ParsedInvoice, parsePaymentRequest } from "../bolt11";
|
||||
|
||||
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
|
||||
export async function getZapEndpoint(metadata: Kind0ParsedContent): Promise<null | string> {
|
||||
@ -52,6 +52,20 @@ export type ParsedZap = {
|
||||
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 {
|
||||
const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1];
|
||||
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];
|
||||
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 payment = parsePaymentRequest(bolt11);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { EventReferences, getThreadReferences } from "./nostr/event";
|
||||
import { ThreadReferences, getThreadReferences } from "./nostr/event";
|
||||
|
||||
export function countReplies(replies: ThreadItem[]): number {
|
||||
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 */
|
||||
replyingTo?: ThreadItem;
|
||||
/** refs from nostr event */
|
||||
refs: EventReferences;
|
||||
refs: ThreadReferences;
|
||||
/** direct child replies */
|
||||
replies: ThreadItem[];
|
||||
};
|
||||
|
@ -4,7 +4,7 @@ import { ChakraProvider, localStorageManager } from "@chakra-ui/react";
|
||||
import { SigningProvider } from "./signing-provider";
|
||||
import buildTheme from "../../theme";
|
||||
import useAppSettings from "../../hooks/use-app-settings";
|
||||
import NotificationTimelineProvider from "./notification-timeline";
|
||||
import NotificationsProvider from "./notifications";
|
||||
import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider";
|
||||
import { AllUserSearchDirectoryProvider } from "./user-directory-provider";
|
||||
import BreakpointProvider from "./breakpoint-provider";
|
||||
@ -26,7 +26,7 @@ export const GlobalProviders = ({ children }: { children: React.ReactNode }) =>
|
||||
<SigningProvider>
|
||||
<PublishProvider>
|
||||
<DecryptionProvider>
|
||||
<NotificationTimelineProvider>
|
||||
<NotificationsProvider>
|
||||
<DMTimelineProvider>
|
||||
<DefaultEmojiProvider>
|
||||
<UserEmojiProvider>
|
||||
@ -34,7 +34,7 @@ export const GlobalProviders = ({ children }: { children: React.ReactNode }) =>
|
||||
</UserEmojiProvider>
|
||||
</DefaultEmojiProvider>
|
||||
</DMTimelineProvider>
|
||||
</NotificationTimelineProvider>
|
||||
</NotificationsProvider>
|
||||
</DecryptionProvider>
|
||||
</PublishProvider>
|
||||
</SigningProvider>
|
||||
|
@ -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 { 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 { TORRENT_COMMENT_KIND } from "../../helpers/nostr/torrents";
|
||||
import { useUserInbox } from "../../hooks/use-user-mailboxes";
|
||||
import AccountNotifications from "../../classes/notifications";
|
||||
|
||||
type NotificationTimelineContextType = {
|
||||
timeline?: TimelineLoader;
|
||||
timeline: TimelineLoader;
|
||||
notifications?: AccountNotifications;
|
||||
};
|
||||
const NotificationTimelineContext = createContext<NotificationTimelineContextType>({});
|
||||
const NotificationTimelineContext = createContext<NotificationTimelineContextType | null>(null);
|
||||
|
||||
export function useNotificationTimeline() {
|
||||
const context = useContext(NotificationTimelineContext);
|
||||
|
||||
if (!context?.timeline) throw new Error("No notification timeline");
|
||||
|
||||
return context.timeline;
|
||||
export function useNotifications() {
|
||||
const ctx = useContext(NotificationTimelineContext);
|
||||
if (!ctx) throw new Error("Missing notifications provider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export default function NotificationTimelineProvider({ children }: PropsWithChildren) {
|
||||
export default function NotificationsProvider({ children }: PropsWithChildren) {
|
||||
const account = useCurrentAccount();
|
||||
const inbox = useUserInbox(account?.pubkey);
|
||||
const readRelays = useReadRelays(inbox);
|
||||
|
||||
const [notifications, setNotifications] = useState<AccountNotifications>();
|
||||
|
||||
const userMuteFilter = useClientSideMuteFilter();
|
||||
const eventFilter = useCallback(
|
||||
(event: NostrEvent) => {
|
||||
@ -57,7 +59,22 @@ export default function NotificationTimelineProvider({ children }: PropsWithChil
|
||||
{ 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>;
|
||||
}
|
@ -15,6 +15,10 @@ class SingleEventService {
|
||||
pending = new Map<string, string[]>();
|
||||
log = logger.extend("SingleEvent");
|
||||
|
||||
getSubject(id: string) {
|
||||
return this.subjects.get(id);
|
||||
}
|
||||
|
||||
requestEvent(id: string, relays: Iterable<string>) {
|
||||
const subject = this.subjects.get(id);
|
||||
if (subject.value) return subject;
|
||||
|
@ -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 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">) {
|
||||
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 (
|
||||
<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">
|
||||
<Link as={RouterLink} to="/notifications">
|
||||
Notifications
|
||||
@ -15,8 +31,13 @@ export default function NotificationsCard({ ...props }: Omit<CardProps, "childre
|
||||
</Heading>
|
||||
<KeyboardShortcut letter="i" requireMeta ml="auto" onPress={() => navigate("/notifications")} />
|
||||
</CardHeader>
|
||||
<CardBody overflowX="auto" overflowY="hidden" pt="0" display="flex" gap="4">
|
||||
<h1>Nothing here yet</h1>
|
||||
<CardBody overflowX="hidden" overflowY="auto" pt="4" display="flex" gap="2" flexDirection="column" maxH="50vh">
|
||||
{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>
|
||||
</Card>
|
||||
);
|
||||
|
@ -7,7 +7,7 @@ import { NostrEvent, isATag, isETag } from "../../../types/nostr-event";
|
||||
import { useRegisterIntersectionEntity } from "../../../providers/local/intersection-observer";
|
||||
import { parseZapEvent } from "../../../helpers/nostr/zaps";
|
||||
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 EmbeddedUnknown from "../../../components/embed-event/event-types/embedded-unknown";
|
||||
import { ErrorBoundary } from "../../../components/error-boundary";
|
||||
@ -23,9 +23,8 @@ import {
|
||||
RepostIcon,
|
||||
} from "../../../components/icons";
|
||||
import useSingleEvent from "../../../hooks/use-single-event";
|
||||
import { TORRENT_COMMENT_KIND } from "../../../helpers/nostr/torrents";
|
||||
import NotificationIconEntry from "./notification-icon-entry";
|
||||
import { getPubkeysMentionedInContent } from "../../../helpers/nostr/post";
|
||||
import { CategorizedEvent, NotificationType, typeSymbol } from "../../../classes/notifications";
|
||||
|
||||
export const ExpandableToggleButton = ({
|
||||
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) => (
|
||||
<NotificationIconEntry ref={ref} icon={<ReplyIcon boxSize={8} color="green.400" />}>
|
||||
<EmbedEvent event={event} />
|
||||
@ -67,11 +51,9 @@ const MentionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({
|
||||
));
|
||||
|
||||
const RepostNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
|
||||
const account = useCurrentAccount()!;
|
||||
const pointer = nip18.getRepostedEventPointer(event);
|
||||
const expanded = useDisclosure({ defaultIsOpen: true });
|
||||
|
||||
if (pointer?.author !== account.pubkey) return null;
|
||||
if (!pointer) return null;
|
||||
|
||||
return (
|
||||
<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);
|
||||
useRegisterIntersectionEntity(ref, getEventUID(event));
|
||||
|
||||
let content: ReactNode | null = null;
|
||||
switch (event.kind) {
|
||||
case kinds.ShortTextNote:
|
||||
case TORRENT_COMMENT_KIND:
|
||||
case kinds.LongFormArticle:
|
||||
content = <NoteNotification event={event} ref={ref} />;
|
||||
switch (event[typeSymbol]) {
|
||||
case NotificationType.Reply:
|
||||
content = <ReplyNotification event={event} ref={ref} />;
|
||||
break;
|
||||
case kinds.Reaction:
|
||||
case NotificationType.Mention:
|
||||
content = <MentionNotification event={event} ref={ref} />;
|
||||
break;
|
||||
case NotificationType.Reaction:
|
||||
content = <ReactionNotification event={event} ref={ref} />;
|
||||
break;
|
||||
case kinds.Repost:
|
||||
case NotificationType.Repost:
|
||||
content = <RepostNotification event={event} ref={ref} />;
|
||||
break;
|
||||
case kinds.Zap:
|
||||
case NotificationType.Zap:
|
||||
content = <ZapNotification event={event} ref={ref} />;
|
||||
break;
|
||||
default:
|
||||
|
@ -12,7 +12,7 @@ import IntersectionObserverProvider, {
|
||||
} from "../../providers/local/intersection-observer";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
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 PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider";
|
||||
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 { ChevronLeftIcon, ChevronRightIcon } from "../../components/icons";
|
||||
import useRouteSearchValue from "../../hooks/use-route-search-value";
|
||||
import { NotificationType, typeSymbol } from "../../classes/notifications";
|
||||
|
||||
const DATE_FORMAT = "YYYY-MM-DD";
|
||||
|
||||
@ -43,7 +44,6 @@ const NotificationDay = memo(({ day, events }: { day: number; events: NostrEvent
|
||||
|
||||
const NotificationsTimeline = memo(
|
||||
({
|
||||
timeline,
|
||||
day,
|
||||
showReplies,
|
||||
showMentions,
|
||||
@ -51,7 +51,6 @@ const NotificationsTimeline = memo(
|
||||
showReposts,
|
||||
showReactions,
|
||||
}: {
|
||||
timeline: TimelineLoader;
|
||||
day: string;
|
||||
showReplies: boolean;
|
||||
showMentions: boolean;
|
||||
@ -59,32 +58,34 @@ const NotificationsTimeline = memo(
|
||||
showReposts: boolean;
|
||||
showReactions: boolean;
|
||||
}) => {
|
||||
const { notifications } = useNotifications();
|
||||
const { people } = usePeopleListContext();
|
||||
const peoplePubkeys = useMemo(() => people?.map((p) => p.pubkey), [people]);
|
||||
const minTimestamp = dayjs(day, DATE_FORMAT).startOf("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(
|
||||
() =>
|
||||
throttledEvents.filter((e) => {
|
||||
events.filter((e) => {
|
||||
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 (!showReplies && isReply(e)) return false;
|
||||
if (!showMentions && !isReply(e)) return false;
|
||||
if (e[typeSymbol] === NotificationType.Zap) {
|
||||
if (!showZaps) 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 (!showZaps && e.kind === kinds.Zap) return false;
|
||||
|
||||
if (!showReplies && e[typeSymbol] === NotificationType.Reply) 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;
|
||||
}),
|
||||
[
|
||||
throttledEvents,
|
||||
events,
|
||||
peoplePubkeys,
|
||||
showReplies,
|
||||
showMentions,
|
||||
@ -123,6 +124,8 @@ const NotificationsTimeline = memo(
|
||||
);
|
||||
|
||||
function NotificationsPage() {
|
||||
const { timeline } = useNotifications();
|
||||
|
||||
const showReplies = useDisclosure({ defaultIsOpen: localStorage.getItem("notifications-show-replies") !== "false" });
|
||||
const showMentions = useDisclosure({
|
||||
defaultIsOpen: localStorage.getItem("notifications-show-mentions") !== "false",
|
||||
@ -134,7 +137,10 @@ function NotificationsPage() {
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
setDay((date) =>
|
||||
@ -143,7 +149,7 @@ function NotificationsPage() {
|
||||
.format(DATE_FORMAT),
|
||||
);
|
||||
};
|
||||
const perviousDay = () => {
|
||||
const previousDay = () => {
|
||||
setDay((date) =>
|
||||
dayjs(date ?? today, DATE_FORMAT)
|
||||
.subtract(1, "day")
|
||||
@ -160,14 +166,13 @@ function NotificationsPage() {
|
||||
localStorage.setItem("notifications-show-reactions", String(showReactions.isOpen));
|
||||
}, [showReplies.isOpen, showMentions.isOpen, showZaps.isOpen, showReposts.isOpen, showReactions.isOpen]);
|
||||
|
||||
const timeline = useNotificationTimeline();
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
<Flex direction={{ base: "column", lg: "row-reverse" }} 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
|
||||
maxW="xs"
|
||||
minW="64"
|
||||
@ -203,7 +208,6 @@ function NotificationsPage() {
|
||||
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<NotificationsTimeline
|
||||
timeline={timeline}
|
||||
day={day}
|
||||
showReplies={showReplies.isOpen}
|
||||
showMentions={showMentions.isOpen}
|
||||
@ -216,8 +220,8 @@ function NotificationsPage() {
|
||||
{/* <TimelineActionAndStatus timeline={timeline} /> */}
|
||||
|
||||
<ButtonGroup mx="auto" mt="4">
|
||||
<Button leftIcon={<ChevronLeftIcon boxSize={6} />} onClick={perviousDay}>
|
||||
Pervious
|
||||
<Button leftIcon={<ChevronLeftIcon boxSize={6} />} onClick={previousDay}>
|
||||
Previous
|
||||
</Button>
|
||||
{day !== today && (
|
||||
<Button rightIcon={<ChevronRightIcon boxSize={6} />} onClick={nextDay}>
|
||||
|
@ -8,7 +8,7 @@ import RequireCurrentAccount from "../../providers/route/require-current-account
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
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 { groupByRoot } from "../../helpers/notification";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
@ -99,7 +99,7 @@ function ThreadsNotificationsPage() {
|
||||
const { people } = usePeopleListContext();
|
||||
const peoplePubkeys = useMemo(() => people?.map((p) => p.pubkey), [people]);
|
||||
|
||||
const timeline = useNotificationTimeline();
|
||||
const { timeline } = useNotifications();
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
const events = useSubject(timeline?.timeline);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user