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"];
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);

View File

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

View File

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

View File

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

View File

@ -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[];
};

View File

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

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 { 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>;
}

View File

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

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

View File

@ -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:

View File

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

View File

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