add tabs to notifications view

This commit is contained in:
hzrd149 2023-09-20 08:53:36 -05:00
parent b512e62fa4
commit 4b5445a7c7
5 changed files with 284 additions and 183 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add tabs to notification view

View File

@ -48,7 +48,7 @@ export function parseStreamEvent(stream: NostrEvent): ParsedStream {
relays.shift();
}
const startTime = starts ? parseInt(starts) : stream.created_at;
const startTime = starts ? parseInt(starts) : undefined;
const endTime = endsTag ? parseInt(endsTag) : undefined;
if (!identifier) throw new Error("missing identifier");

View File

@ -1,219 +1,145 @@
import { ReactNode, forwardRef, memo, useCallback, useMemo, useRef } from "react";
import { Box, Card, Flex, Switch, Text, useDisclosure } from "@chakra-ui/react";
import { Kind, nip18, nip25 } from "nostr-tools";
import { useCallback, useMemo } from "react";
import { Tab, TabList, TabPanel, TabPanelProps, TabPanels, Tabs, useDisclosure } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { NostrEvent, isATag, isETag } from "../../types/nostr-event";
import { NoteLink } from "../../components/note-link";
import { NostrEvent } from "../../types/nostr-event";
import RequireCurrentAccount from "../../providers/require-current-account";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import useSubject from "../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import { useNotificationTimeline } from "../../providers/notification-timeline";
import { parseZapEvent } from "../../helpers/nostr/zaps";
import { readablizeSats } from "../../helpers/bolt11";
import { getEventUID, getReferences, parseCoordinate } from "../../helpers/nostr/events";
import Timestamp from "../../components/timestamp";
import { EmbedEvent, EmbedEventPointer } from "../../components/embed-event";
import EmbeddedUnknown from "../../components/embed-event/event-types/embedded-unknown";
import { NoteContents } from "../../components/note/note-contents";
import { getReferences } from "../../helpers/nostr/events";
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import VerticalPageLayout from "../../components/vertical-page-layout";
import NotificationItem from "./notification-item";
const Kind1Notification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const refs = getReferences(event);
if (refs.replyId) {
return (
<Card variant="outline" p="2" borderColor="blue.400" ref={ref}>
<Flex gap="2" alignItems="center" mb="2">
<UserAvatar pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} />
{refs.replyId ? <Text>replied to post</Text> : <Text>mentioned you</Text>}
<NoteLink noteId={event.id} color="current" ml="auto">
<Timestamp timestamp={event.created_at} />
</NoteLink>
</Flex>
<EmbedEventPointer pointer={{ type: "note", data: refs.replyId }} />
<NoteContents event={event} mt="2" />
</Card>
);
}
return (
<Box ref={ref}>
<Flex gap="2" alignItems="center" mb="1">
<UserAvatar pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} />
<Text>mentioned you in</Text>
</Flex>
<EmbedEvent event={event} />
</Box>
);
});
const ShareNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const account = useCurrentAccount()!;
const pointer = nip18.getRepostedEventPointer(event);
if (pointer?.author !== account.pubkey) return null;
function RepliesNotificationsTab({ events }: { events: NostrEvent[] }) {
const timeline = useNotificationTimeline();
const filtered = events.filter((event) => {
if (event.kind === Kind.Text) {
const refs = getReferences(event);
return !!refs.replyId;
}
return false;
});
return (
<Card variant="outline" p="2" ref={ref}>
<Flex gap="2" alignItems="center" mb="2">
<UserAvatar pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} />
<Text>shared note</Text>
<NoteLink noteId={event.id} color="current" ml="auto">
<Timestamp timestamp={event.created_at} />
</NoteLink>
</Flex>
{pointer && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />}
</Card>
<>
{filtered.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</>
);
});
}
const ReactionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const account = useCurrentAccount();
const pointer = nip25.getReactedEventPointer(event);
if (!pointer || (account?.pubkey && pointer.author !== account.pubkey)) return null;
function MentionsNotificationsTab({ events }: { events: NostrEvent[] }) {
const timeline = useNotificationTimeline();
const filtered = events.filter((event) => {
if (event.kind === Kind.Text) {
const refs = getReferences(event);
return !refs.replyId;
}
return false;
});
return (
<Box ref={ref}>
<Flex gap="2" alignItems="center" mb="1">
<UserAvatar pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} />
<Text>reacted {event.content} to your post</Text>
<Timestamp timestamp={event.created_at} ml="auto" />
</Flex>
<EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />
</Box>
<>
{filtered.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</>
);
});
}
const ZapNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const zap = useMemo(() => {
try {
return parseZapEvent(event);
} catch (e) {}
}, [event]);
if (!zap || !zap.payment.amount) return null;
const eventId = zap?.request.tags.find(isETag)?.[1];
const coordinate = zap?.request.tags.find(isATag)?.[1];
const parsedCoordinate = coordinate ? parseCoordinate(coordinate) : null;
let eventJSX: ReactNode | null = null;
if (parsedCoordinate && parsedCoordinate.identifier) {
eventJSX = (
<EmbedEventPointer
pointer={{
type: "naddr",
data: {
pubkey: parsedCoordinate.pubkey,
identifier: parsedCoordinate.identifier,
kind: parsedCoordinate.kind,
},
}}
/>
);
} else if (eventId) {
eventJSX = <EmbedEventPointer pointer={{ type: "note", data: eventId }} />;
}
function ReactionsNotificationsTab({ events }: { events: NostrEvent[] }) {
const timeline = useNotificationTimeline();
const filtered = events.filter((e) => e.kind === Kind.Reaction);
return (
<Card variant="outline" borderColor="yellow.400" p="2" ref={ref}>
<Flex direction="row" gap="2" alignItems="center" mb="2">
<UserAvatar pubkey={zap.request.pubkey} size="xs" />
<UserLink pubkey={zap.request.pubkey} />
<Text>zapped {readablizeSats(zap.payment.amount / 1000)} sats</Text>
<Timestamp color="current" ml="auto" timestamp={zap.request.created_at} />
</Flex>
{eventJSX}
</Card>
<>
{filtered.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</>
);
});
}
const NotificationItem = memo(({ event }: { event: NostrEvent }) => {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(event));
function SharesNotificationsTab({ events }: { events: NostrEvent[] }) {
const timeline = useNotificationTimeline();
const filtered = events.filter((e) => e.kind === Kind.Repost);
switch (event.kind) {
case Kind.Text:
return <Kind1Notification event={event} ref={ref} />;
case Kind.Reaction:
return <ReactionNotification event={event} ref={ref} />;
case Kind.Repost:
return <ShareNotification event={event} ref={ref} />;
case Kind.Zap:
return <ZapNotification event={event} ref={ref} />;
default:
return <EmbeddedUnknown event={event} />;
}
});
return (
<>
{filtered.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</>
);
}
function ZapNotificationsTab({ events }: { events: NostrEvent[] }) {
const timeline = useNotificationTimeline();
const filtered = events.filter((e) => e.kind === Kind.Zap);
return (
<>
{filtered.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</>
);
}
function NotificationsPage() {
const hideReplies = useDisclosure();
const hideMentions = useDisclosure();
const hideZaps = useDisclosure();
const hideReactions = useDisclosure();
const hideShares = useDisclosure();
const { people } = usePeopleListContext();
const peoplePubkeys = useMemo(() => people?.map((p) => p.pubkey), [people]);
const eventFilter = useCallback(
(event: NostrEvent) => {
if (peoplePubkeys && event.kind !== Kind.Zap && !peoplePubkeys.includes(event.pubkey)) return false;
if (hideZaps.isOpen && event.kind === Kind.Zap) return false;
if (hideReactions.isOpen && event.kind === Kind.Reaction) return false;
if (hideShares.isOpen && event.kind === Kind.Repost) return false;
if (event.kind === Kind.Text) {
const refs = getReferences(event);
if (hideReplies.isOpen && refs.replyId) return false;
if (hideMentions.isOpen && !refs.replyId) return false;
}
return true;
},
[hideMentions.isOpen, hideReplies.isOpen, hideZaps.isOpen, hideReactions.isOpen, hideShares.isOpen, peoplePubkeys],
);
const timeline = useNotificationTimeline();
const events = useSubject(timeline?.timeline).filter(eventFilter) ?? [];
const callback = useTimelineCurserIntersectionCallback(timeline);
const events = useSubject(timeline?.timeline).filter((e) => {
if (peoplePubkeys && e.kind !== Kind.Zap && !peoplePubkeys.includes(e.pubkey)) return false;
return true;
});
const tabPanelProps: TabPanelProps = { px: "0", pt: "2", display: "flex", flexDirection: "column", gap: "2" };
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<Flex gap="2" alignItems="center" wrap="wrap">
<PeopleListSelection />
<Switch isChecked={!hideReplies.isOpen} onChange={hideReplies.onToggle}>
Replies
</Switch>
<Switch isChecked={!hideMentions.isOpen} onChange={hideMentions.onToggle}>
Mentions
</Switch>
<Switch isChecked={!hideReactions.isOpen} onChange={hideReactions.onToggle}>
Reactions
</Switch>
<Switch isChecked={!hideShares.isOpen} onChange={hideShares.onToggle}>
Shares
</Switch>
<Switch isChecked={!hideZaps.isOpen} onChange={hideZaps.onToggle}>
Zaps
</Switch>
</Flex>
{events.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
<Tabs isLazy colorScheme="brand">
<TabList overflowX="auto" overflowY="hidden">
<Tab>Replies</Tab>
<Tab>Mentions</Tab>
<Tab>Reactions</Tab>
<Tab>Shares</Tab>
<Tab>Zaps</Tab>
<PeopleListSelection ml="auto" flexShrink={0} />
</TabList>
<TabPanels>
<TabPanel {...tabPanelProps}>
<RepliesNotificationsTab events={events} />
</TabPanel>
<TabPanel {...tabPanelProps}>
<MentionsNotificationsTab events={events} />
</TabPanel>
<TabPanel {...tabPanelProps}>
<ReactionsNotificationsTab events={events} />
</TabPanel>
<TabPanel {...tabPanelProps}>
<SharesNotificationsTab events={events} />
</TabPanel>
<TabPanel {...tabPanelProps}>
<ZapNotificationsTab events={events} />
</TabPanel>
</TabPanels>
</Tabs>
</VerticalPageLayout>
</IntersectionObserverProvider>
);

View File

@ -0,0 +1,150 @@
import { ReactNode, forwardRef, memo, useMemo, useRef } from "react";
import { Box, Card, Flex, Text } from "@chakra-ui/react";
import { Kind, nip18, nip25 } from "nostr-tools";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { NostrEvent, isATag, isETag } from "../../types/nostr-event";
import { NoteLink } from "../../components/note-link";
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { parseZapEvent } from "../../helpers/nostr/zaps";
import { readablizeSats } from "../../helpers/bolt11";
import { getEventUID, getReferences, parseCoordinate } from "../../helpers/nostr/events";
import Timestamp from "../../components/timestamp";
import { EmbedEvent, EmbedEventPointer } from "../../components/embed-event";
import EmbeddedUnknown from "../../components/embed-event/event-types/embedded-unknown";
import { NoteContents } from "../../components/note/note-contents";
const Kind1Notification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const refs = getReferences(event);
if (refs.replyId) {
return (
<Card variant="outline" p="2" borderColor="blue.400" ref={ref}>
<Flex gap="2" alignItems="center" mb="2" wrap="wrap">
<UserAvatar pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} />
{refs.replyId ? <Text>replied to:</Text> : <Text>mentioned you</Text>}
<NoteLink noteId={event.id} color="current" ml="auto">
<Timestamp timestamp={event.created_at} />
</NoteLink>
</Flex>
<EmbedEventPointer pointer={{ type: "note", data: refs.replyId }} />
<NoteContents event={event} mt="2" />
</Card>
);
}
return (
<Box ref={ref}>
<Flex gap="2" alignItems="center" mb="1">
<UserAvatar pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} />
<Text>mentioned you in</Text>
</Flex>
<EmbedEvent event={event} />
</Box>
);
});
const ShareNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const account = useCurrentAccount()!;
const pointer = nip18.getRepostedEventPointer(event);
if (pointer?.author !== account.pubkey) return null;
return (
<Box ref={ref}>
<Flex gap="2" alignItems="center" mb="2">
<UserAvatar pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} />
<Text>shared note:</Text>
<NoteLink noteId={event.id} color="current" ml="auto">
<Timestamp timestamp={event.created_at} />
</NoteLink>
</Flex>
{pointer && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />}
</Box>
);
});
const ReactionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const account = useCurrentAccount();
const pointer = nip25.getReactedEventPointer(event);
if (!pointer || (account?.pubkey && pointer.author !== account.pubkey)) return null;
return (
<Box ref={ref}>
<Flex gap="2" alignItems="center" mb="1">
<UserAvatar pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} />
<Text>reacted {event.content} to your post</Text>
<Timestamp timestamp={event.created_at} ml="auto" />
</Flex>
<EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />
</Box>
);
});
const ZapNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const zap = useMemo(() => {
try {
return parseZapEvent(event);
} catch (e) {}
}, [event]);
if (!zap || !zap.payment.amount) return null;
const eventId = zap?.request.tags.find(isETag)?.[1];
const coordinate = zap?.request.tags.find(isATag)?.[1];
const parsedCoordinate = coordinate ? parseCoordinate(coordinate) : null;
let eventJSX: ReactNode | null = null;
if (parsedCoordinate && parsedCoordinate.identifier) {
eventJSX = (
<EmbedEventPointer
pointer={{
type: "naddr",
data: {
pubkey: parsedCoordinate.pubkey,
identifier: parsedCoordinate.identifier,
kind: parsedCoordinate.kind,
},
}}
/>
);
} else if (eventId) {
eventJSX = <EmbedEventPointer pointer={{ type: "note", data: eventId }} />;
}
return (
<Card variant="outline" borderColor="yellow.400" p="2" ref={ref}>
<Flex direction="row" gap="2" alignItems="center" mb="2">
<UserAvatar pubkey={zap.request.pubkey} size="xs" />
<UserLink pubkey={zap.request.pubkey} />
<Text>zapped {readablizeSats(zap.payment.amount / 1000)} sats</Text>
<Timestamp color="current" ml="auto" timestamp={zap.request.created_at} />
</Flex>
{eventJSX}
</Card>
);
});
const NotificationItem = ({ event }: { event: NostrEvent }) => {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(event));
switch (event.kind) {
case Kind.Text:
return <Kind1Notification event={event} ref={ref} />;
case Kind.Reaction:
return <ReactionNotification event={event} ref={ref} />;
case Kind.Repost:
return <ShareNotification event={event} ref={ref} />;
case Kind.Zap:
return <ZapNotification event={event} ref={ref} />;
default:
return <EmbeddedUnknown event={event} />;
}
};
export default memo(NotificationItem);

View File

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