Add threads notifications view

This commit is contained in:
hzrd149 2023-12-12 15:27:53 -06:00
parent 1f77a48494
commit 9fa2ae472b
11 changed files with 308 additions and 124 deletions

View File

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

View File

@ -75,6 +75,7 @@ import DMFeedView from "./views/tools/dm-feed";
import ContentDiscoveryView from "./views/tools/content-discovery";
import ContentDiscoveryDVMView from "./views/tools/content-discovery/dvm";
import LoginNostrConnectView from "./views/signin/nostr-connect";
import ThreadsNotificationsView from "./views/notifications/threads";
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const ToolsHomeView = lazy(() => import("./views/tools"));
@ -232,7 +233,13 @@ const router = createHashRouter([
],
},
{ path: "r/:relay", element: <RelayView /> },
{ path: "notifications", element: <NotificationsView /> },
{
path: "notifications",
children: [
{ path: "threads", element: <ThreadsNotificationsView /> },
{ path: "", element: <NotificationsView /> },
],
},
{ path: "search", element: <SearchView /> },
{
path: "dm",

View File

@ -98,14 +98,11 @@ export function filterTagsByContentRefs(content: string, tags: Tag[], referenced
export type EventReferences = ReturnType<typeof getReferences>;
export function getReferences(event: NostrEvent | DraftNostrEvent) {
const eTags = event.tags.filter(isETag);
const pTags = event.tags.filter(isPTag);
const events = eTags.map((t) => t[1]);
const contentTagRefs = getContentTagRefs(event.content, event.tags);
const replyTag = eTags.find((t) => t[3] === "reply");
const rootTag = eTags.find((t) => t[3] === "root");
const replyTag = event.tags.find((t) => t[3] === "reply");
const rootTag = event.tags.find((t) => t[3] === "root");
const mentionTags = event.tags.find((t) => t[3] === "mention");
let replyId = replyTag?.[1];
let replyRelay = replyTag?.[2];
@ -123,8 +120,8 @@ export function getReferences(event: NostrEvent | DraftNostrEvent) {
// legacy behavior
// https://github.com/nostr-protocol/nips/blob/master/10.md#positional-e-tags-deprecated
const legacyTags = eTags.filter((t, i) => {
// ignore it if there is a third piece of data
const legacyTags = event.tags.filter(isETag).filter((t, i) => {
// ignore it if there is a type
if (t[3]) return false;
const tagIndex = event.tags.indexOf(t);
if (contentTagRefs.includes(tagIndex)) return false;
@ -140,11 +137,15 @@ export function getReferences(event: NostrEvent | DraftNostrEvent) {
}
return {
events,
replyTag,
rootTag,
mentionTags,
rootId,
rootRelay,
replyId,
replyRelay,
contentTagRefs,
};
}

View File

@ -0,0 +1,27 @@
import dayjs from "dayjs";
import SuperMap from "../classes/super-map";
import { NostrEvent } from "../types/nostr-event";
import { getReferences } from "./nostr/events";
export function groupByDay(events: NostrEvent[]) {
const grouped = new SuperMap<number, NostrEvent[]>(() => []);
for (const event of events) {
const day = dayjs.unix(event.created_at).startOf("day").unix();
grouped.get(day).push(event);
}
return Array.from(grouped.entries()).sort((a, b) => b[0] - a[0]);
}
export function groupByRoot(events: NostrEvent[]) {
const grouped = new SuperMap<string, NostrEvent[]>(() => []);
for (const event of events) {
const refs = getReferences(event);
if (refs.rootId) grouped.get(refs.rootId).push(event);
}
for (const [_, groupedEvents] of grouped) {
groupedEvents.sort((a, b) => b.created_at - a.created_at);
}
return Array.from(grouped.entries()).sort((a, b) => b[1][0].created_at - a[1][0].created_at);
}

View File

@ -24,7 +24,7 @@ export function useNotificationTimeline() {
export default function NotificationTimelineProvider({ children }: PropsWithChildren) {
const account = useCurrentAccount();
const readRelays = useReadRelayUrls();
const inbox = useReadRelayUrls();
const userMuteFilter = useClientSideMuteFilter();
const eventFilter = useCallback(
@ -37,7 +37,7 @@ export default function NotificationTimelineProvider({ children }: PropsWithChil
const timeline = useTimelineLoader(
`${account?.pubkey ?? "anon"}-notification`,
readRelays,
inbox,
account?.pubkey
? {
"#p": [account.pubkey],

View File

@ -125,7 +125,7 @@ export default function MessageBubble({
{(plaintext) => (
<MessageContent event={message} text={plaintext} display="inline">
{!hasReactions && (
<ButtonGroup size="xs" variant="ghost" float="right">
<ButtonGroup size="xs" variant="ghost" float="right" ml="2">
{actionPosition === "inline" && actions}
<Timestamp timestamp={message.created_at} ml="2" />
</ButtonGroup>

View File

@ -0,0 +1,38 @@
import { MutableRefObject, PropsWithChildren, forwardRef } from "react";
import { Divider, Flex, Heading, useDisclosure } from "@chakra-ui/react";
import dayjs from "dayjs";
import { ExpandableToggleButton } from "../notification-item";
const specialNames = {
[dayjs().startOf("day").unix()]: "Today",
[dayjs().subtract(1, "day").startOf("day").unix()]: "Yesterday",
};
const DayGroup = forwardRef<HTMLDivElement, PropsWithChildren<{ day: number; hideRefOnClose?: boolean }>>(
({ day, children, hideRefOnClose = false }, ref) => {
const expanded = useDisclosure({ defaultIsOpen: true });
const now = dayjs();
const date = dayjs.unix(day);
let title = specialNames[day] || date.fromNow();
if (now.diff(date, "week") > 2) {
title = date.format("L");
}
return (
<>
<Flex gap="4" alignItems="center" mt="4" ref={hideRefOnClose && !expanded ? undefined : ref}>
<Divider w="10" flexShrink={0} />
<Heading size="lg" whiteSpace="nowrap">
{title}
</Heading>
<Divider />
<ExpandableToggleButton toggle={expanded} aria-label="Toggle day" title="Toggle day" />
</Flex>
{expanded.isOpen && children}
</>
);
},
);
export default DayGroup;

View File

@ -0,0 +1,19 @@
import { Box, Flex } from "@chakra-ui/react";
import { PropsWithChildren, ReactNode, forwardRef } from "react";
const NotificationIconEntry = forwardRef<HTMLDivElement, PropsWithChildren<{ icon: ReactNode }>>(
({ children, icon }, ref) => {
return (
<Flex gap="2" ref={ref}>
<Box px="2" pb="2">
{icon}
</Box>
<Flex direction="column" w="full" gap="2" overflow="hidden">
{children}
</Flex>
</Flex>
);
},
);
export default NotificationIconEntry;

View File

@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef } from "react";
import { Divider, Flex, Heading, useDisclosure } from "@chakra-ui/react";
import { Button, ButtonGroup, Flex, useDisclosure } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import { Link as RouterLink } from "react-router-dom";
import RequireCurrentAccount from "../../providers/require-current-account";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
@ -12,41 +13,22 @@ import { getEventUID, isReply } 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, { ExpandableToggleButton } from "./notification-item";
import NotificationItem from "./notification-item";
import NotificationTypeToggles from "./notification-type-toggles";
import { NostrEvent } from "../../types/nostr-event";
import dayjs from "dayjs";
import SuperMap from "../../classes/super-map";
const specialNames = {
[dayjs().startOf("day").unix()]: "Today",
[dayjs().subtract(1, "day").startOf("day").unix()]: "Yesterday",
};
import { groupByDay } from "../../helpers/notification";
import DayGroup from "./components/day-group";
function NotificationDay({ day, events }: { day: number; events: NostrEvent[] }) {
const expanded = useDisclosure({ defaultIsOpen: true });
const now = dayjs();
const date = dayjs.unix(day);
let title = specialNames[day] || date.fromNow();
if (now.diff(date, "week") > 2) {
title = date.format("L");
}
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, expanded.isOpen ? undefined : getEventUID(events[events.length - 1]));
useRegisterIntersectionEntity(ref, getEventUID(events[events.length - 1]));
return (
<>
<Flex gap="4" alignItems="center" mt="4" ref={ref}>
<Divider w="10" flexShrink={0} />
<Heading size="lg" whiteSpace="nowrap">
{title}
</Heading>
<Divider />
<ExpandableToggleButton toggle={expanded} aria-label="Toggle day" title="Toggle day" />
</Flex>
{expanded.isOpen && events.map((event) => <NotificationItem key={event.id} event={event} />)}
</>
<DayGroup day={day} ref={ref} hideRefOnClose>
{events.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
</DayGroup>
);
}
@ -89,21 +71,12 @@ function NotificationsPage() {
return true;
});
const grouped = useMemo(() => {
const map = new SuperMap<number, NostrEvent[]>(() => []);
for (const event of events) {
const day = dayjs.unix(event.created_at).startOf("day").unix();
map.get(day).push(event);
}
return map;
}, [events]);
const sortedDays = Array.from(grouped.entries()).sort((a, b) => b[0] - a[0]);
const sortedDays = useMemo(() => groupByDay(events), [events]);
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<Flex gap="2">
<Flex gap="2" wrap="wrap">
<NotificationTypeToggles
showReplies={showReplies}
showMentions={showMentions}
@ -111,7 +84,12 @@ function NotificationsPage() {
showReactions={showReactions}
showReposts={showReposts}
/>
<PeopleListSelection flexShrink={0} />
<ButtonGroup>
<PeopleListSelection flexShrink={0} />
<Button as={RouterLink} to="/notifications/threads">
Threads
</Button>
</ButtonGroup>
</Flex>
{sortedDays.map(([day, events]) => (

View File

@ -1,5 +1,5 @@
import { PropsWithChildren, ReactNode, forwardRef, memo, useMemo, useRef } from "react";
import { AvatarGroup, Box, Flex, IconButton, IconButtonProps, Text, useDisclosure } from "@chakra-ui/react";
import { ReactNode, forwardRef, memo, useMemo, useRef } from "react";
import { AvatarGroup, Flex, IconButton, IconButtonProps, Text, useDisclosure } from "@chakra-ui/react";
import { Kind, nip18, nip25 } from "nostr-tools";
import useCurrentAccount from "../../hooks/use-current-account";
@ -17,12 +17,8 @@ import UserAvatarLink from "../../components/user-avatar-link";
import { AtIcon, ChevronDownIcon, ChevronUpIcon, LightningIcon, ReplyIcon, RepostIcon } from "../../components/icons";
import useSingleEvent from "../../hooks/use-single-event";
import { TORRENT_COMMENT_KIND } from "../../helpers/nostr/torrents";
import NotificationIconEntry from "./components/notification-icon-entry";
const IconBox = ({ children }: PropsWithChildren) => (
<Box px="2" pb="2">
{children}
</Box>
);
export const ExpandableToggleButton = ({
toggle,
...props
@ -48,28 +44,16 @@ const NoteNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ ev
else return null;
});
const ReplyNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => (
<Flex gap="2" ref={ref}>
<IconBox>
<ReplyIcon boxSize={8} color="green.400" />
</IconBox>
<Flex direction="column" w="full" gap="2">
<EmbedEvent event={event} />
</Flex>
</Flex>
<NotificationIconEntry ref={ref} icon={<ReplyIcon boxSize={8} color="green.400" />}>
<EmbedEvent event={event} />
</NotificationIconEntry>
));
const MentionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
return (
<Flex gap="2" ref={ref}>
<IconBox>
<AtIcon boxSize={8} color="purple.400" />
</IconBox>
<Flex direction="column" w="full" gap="2">
<EmbedEvent event={event} />
</Flex>
</Flex>
);
});
const MentionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => (
<NotificationIconEntry ref={ref} icon={<AtIcon boxSize={8} color="purple.400" />}>
<EmbedEvent event={event} />
</NotificationIconEntry>
));
const RepostNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const account = useCurrentAccount()!;
@ -79,20 +63,15 @@ const RepostNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({
if (pointer?.author !== account.pubkey) return null;
return (
<Flex gap="2" ref={ref}>
<IconBox>
<RepostIcon boxSize={8} color="blue.400" />
</IconBox>
<Flex direction="column" w="full" gap="2">
<Flex gap="2" alignItems="center">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={event.pubkey} />
</AvatarGroup>
<ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />
</Flex>
{expanded.isOpen && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />}
<NotificationIconEntry ref={ref} icon={<RepostIcon boxSize={8} color="blue.400" />}>
<Flex gap="2" alignItems="center">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={event.pubkey} />
</AvatarGroup>
<ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />
</Flex>
</Flex>
{expanded.isOpen && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />}
</NotificationIconEntry>
);
});
@ -106,22 +85,17 @@ const ReactionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>((
if (reactedEvent?.kind === Kind.EncryptedDirectMessage) return null;
return (
<Flex gap="2" ref={ref}>
<IconBox>
<Heart boxSize={8} color="red.400" />
</IconBox>
<Flex direction="column" w="full" gap="2">
<Flex gap="2" alignItems="center">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={event.pubkey} />
</AvatarGroup>
<Text fontSize="xl">{event.content}</Text>
<ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />
{/* <Timestamp timestamp={event.created_at} ml="auto" /> */}
</Flex>
{expanded.isOpen && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />}
<NotificationIconEntry ref={ref} icon={<Heart boxSize={8} color="red.400" />}>
<Flex gap="2" alignItems="center">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={event.pubkey} />
</AvatarGroup>
<Text fontSize="xl">{event.content}</Text>
<ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />
{/* <Timestamp timestamp={event.created_at} ml="auto" /> */}
</Flex>
</Flex>
{expanded.isOpen && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />}
</NotificationIconEntry>
);
});
@ -158,22 +132,17 @@ const ZapNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ eve
}
return (
<Flex gap="2" ref={ref}>
<IconBox>
<LightningIcon boxSize={8} color="yellow.400" />
</IconBox>
<Flex direction="column" w="full" gap="2">
<Flex gap="2" alignItems="center">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={zap.request.pubkey} />
</AvatarGroup>
<Text>{readablizeSats(zap.payment.amount / 1000)} sats</Text>
{zap.request.content && <Text>{zap.request.content}</Text>}
{eventJSX !== null && <ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />}
</Flex>
{expanded.isOpen && eventJSX}
<NotificationIconEntry ref={ref} icon={<LightningIcon boxSize={8} color="yellow.400" />}>
<Flex gap="2" alignItems="center">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={zap.request.pubkey} />
</AvatarGroup>
<Text>{readablizeSats(zap.payment.amount / 1000)} sats</Text>
{zap.request.content && <Text>{zap.request.content}</Text>}
{eventJSX !== null && <ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />}
</Flex>
</Flex>
{expanded.isOpen && eventJSX}
</NotificationIconEntry>
);
});

View File

@ -0,0 +1,140 @@
import { MouseEventHandler, useCallback, useMemo, useRef } from "react";
import { Kind } from "nostr-tools";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import useCurrentAccount from "../../hooks/use-current-account";
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
import RequireCurrentAccount from "../../providers/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/notification-timeline";
import { TORRENT_COMMENT_KIND } from "../../helpers/nostr/torrents";
import { groupByRoot } from "../../helpers/notification";
import { NostrEvent } from "../../types/nostr-event";
import NotificationIconEntry from "./components/notification-icon-entry";
import { ChevronLeftIcon, ReplyIcon } from "../../components/icons";
import { AvatarGroup, Box, Button, ButtonGroup, Flex, LinkBox, Text, useDisclosure } from "@chakra-ui/react";
import UserAvatarLink from "../../components/user-avatar-link";
import useSingleEvent from "../../hooks/use-single-event";
import UserLink from "../../components/user-link";
import { CompactNoteContent } from "../../components/compact-note-content";
import Timestamp from "../../components/timestamp";
import HoverLinkOverlay from "../../components/hover-link-overlay";
import { getSharableEventAddress } from "../../helpers/nip19";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { getEventUID } from "../../helpers/nostr/events";
import { useNavigateInDrawer } from "../../providers/drawer-sub-view-provider";
const THREAD_KINDS = [Kind.Text, TORRENT_COMMENT_KIND];
function ReplyEntry({ event }: { event: NostrEvent }) {
const navigate = useNavigateInDrawer();
const onClick = useCallback<MouseEventHandler>(
(e) => {
e.preventDefault();
e.stopPropagation();
navigate(`/n/${getSharableEventAddress(event)}`);
},
[navigate],
);
return (
<LinkBox>
<Flex gap="2">
<UserLink pubkey={event.pubkey} fontWeight="bold" />
<Timestamp timestamp={event.created_at} />
</Flex>
<CompactNoteContent event={event} maxLength={100} />
<HoverLinkOverlay as={RouterLink} to={`/n/${getSharableEventAddress(event)}`} onClick={onClick} />
</LinkBox>
);
}
function ThreadGroup({ rootId, events }: { rootId: string; events: NostrEvent[] }) {
const pubkeys = events.reduce<string[]>((arr, e) => {
if (!arr.includes(e.pubkey)) arr.push(e.pubkey);
return arr;
}, []);
const showAll = useDisclosure();
const rootEvent = useSingleEvent(rootId);
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(events[events.length - 1]));
return (
<NotificationIconEntry icon={<ReplyIcon boxSize={8} />}>
<AvatarGroup size="sm">
{pubkeys.map((pubkey) => (
<UserAvatarLink key={pubkey} pubkey={pubkey} />
))}
</AvatarGroup>
<Box>
<Text fontWeight="bold">
{pubkeys.length > 1 ? pubkeys.length + " people" : pubkeys.length + " person"} replied in thread:
</Text>
{rootEvent && <CompactNoteContent event={rootEvent} maxLength={100} color="GrayText" />}
</Box>
{(events.length > 3 && !showAll.isOpen ? events.slice(0, 3) : events).map((event) => (
<ReplyEntry key={event.id} event={event} />
))}
{!showAll.isOpen && events.length > 3 && (
<ButtonGroup>
<Button variant="link" py="2" onClick={showAll.onOpen} colorScheme="primary" fontWeight="bold">
+{events.length - 3} more
</Button>
</ButtonGroup>
)}
</NotificationIconEntry>
);
}
function ThreadsNotificationsPage() {
const navigate = useNavigate();
const account = useCurrentAccount();
const { people } = usePeopleListContext();
const peoplePubkeys = useMemo(() => people?.map((p) => p.pubkey), [people]);
const timeline = useNotificationTimeline();
const callback = useTimelineCurserIntersectionCallback(timeline);
const events = useSubject(timeline?.timeline);
const filteredEvents = useMemo(
() =>
events.filter((e) => {
if (!THREAD_KINDS.includes(e.kind)) return false;
if (peoplePubkeys && !peoplePubkeys.includes(e.pubkey)) return false;
return true;
}),
[events],
);
const threads = useMemo(() => groupByRoot(filteredEvents), [filteredEvents]);
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<Flex gap="2">
<Button leftIcon={<ChevronLeftIcon />} onClick={() => navigate(-1)}>
Back
</Button>
<PeopleListSelection />
</Flex>
{threads.map((thread) => (
<ThreadGroup key={thread[0]} rootId={thread[0]} events={thread[1]} />
))}
</VerticalPageLayout>
</IntersectionObserverProvider>
);
}
export default function ThreadsNotificationsView() {
return (
<RequireCurrentAccount>
<PeopleListProvider initList="global">
<ThreadsNotificationsPage />
</PeopleListProvider>
</RequireCurrentAccount>
);
}