mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-09 20:29:17 +02:00
Add threads notifications view
This commit is contained in:
parent
1f77a48494
commit
9fa2ae472b
5
.changeset/mighty-hairs-boil.md
Normal file
5
.changeset/mighty-hairs-boil.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add threads notifications view
|
@ -75,6 +75,7 @@ import DMFeedView from "./views/tools/dm-feed";
|
|||||||
import ContentDiscoveryView from "./views/tools/content-discovery";
|
import ContentDiscoveryView from "./views/tools/content-discovery";
|
||||||
import ContentDiscoveryDVMView from "./views/tools/content-discovery/dvm";
|
import ContentDiscoveryDVMView from "./views/tools/content-discovery/dvm";
|
||||||
import LoginNostrConnectView from "./views/signin/nostr-connect";
|
import LoginNostrConnectView from "./views/signin/nostr-connect";
|
||||||
|
import ThreadsNotificationsView from "./views/notifications/threads";
|
||||||
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
||||||
|
|
||||||
const ToolsHomeView = lazy(() => import("./views/tools"));
|
const ToolsHomeView = lazy(() => import("./views/tools"));
|
||||||
@ -232,7 +233,13 @@ const router = createHashRouter([
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: "r/:relay", element: <RelayView /> },
|
{ path: "r/:relay", element: <RelayView /> },
|
||||||
{ path: "notifications", element: <NotificationsView /> },
|
{
|
||||||
|
path: "notifications",
|
||||||
|
children: [
|
||||||
|
{ path: "threads", element: <ThreadsNotificationsView /> },
|
||||||
|
{ path: "", element: <NotificationsView /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ path: "search", element: <SearchView /> },
|
{ path: "search", element: <SearchView /> },
|
||||||
{
|
{
|
||||||
path: "dm",
|
path: "dm",
|
||||||
|
@ -98,14 +98,11 @@ export function filterTagsByContentRefs(content: string, tags: Tag[], referenced
|
|||||||
|
|
||||||
export type EventReferences = ReturnType<typeof getReferences>;
|
export type EventReferences = ReturnType<typeof getReferences>;
|
||||||
export function getReferences(event: NostrEvent | DraftNostrEvent) {
|
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 contentTagRefs = getContentTagRefs(event.content, event.tags);
|
||||||
|
|
||||||
const replyTag = eTags.find((t) => t[3] === "reply");
|
const replyTag = event.tags.find((t) => t[3] === "reply");
|
||||||
const rootTag = eTags.find((t) => t[3] === "root");
|
const rootTag = event.tags.find((t) => t[3] === "root");
|
||||||
|
const mentionTags = event.tags.find((t) => t[3] === "mention");
|
||||||
|
|
||||||
let replyId = replyTag?.[1];
|
let replyId = replyTag?.[1];
|
||||||
let replyRelay = replyTag?.[2];
|
let replyRelay = replyTag?.[2];
|
||||||
@ -123,8 +120,8 @@ export function getReferences(event: NostrEvent | DraftNostrEvent) {
|
|||||||
|
|
||||||
// legacy behavior
|
// legacy behavior
|
||||||
// https://github.com/nostr-protocol/nips/blob/master/10.md#positional-e-tags-deprecated
|
// https://github.com/nostr-protocol/nips/blob/master/10.md#positional-e-tags-deprecated
|
||||||
const legacyTags = eTags.filter((t, i) => {
|
const legacyTags = event.tags.filter(isETag).filter((t, i) => {
|
||||||
// ignore it if there is a third piece of data
|
// ignore it if there is a type
|
||||||
if (t[3]) return false;
|
if (t[3]) return false;
|
||||||
const tagIndex = event.tags.indexOf(t);
|
const tagIndex = event.tags.indexOf(t);
|
||||||
if (contentTagRefs.includes(tagIndex)) return false;
|
if (contentTagRefs.includes(tagIndex)) return false;
|
||||||
@ -140,11 +137,15 @@ export function getReferences(event: NostrEvent | DraftNostrEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
events,
|
replyTag,
|
||||||
|
rootTag,
|
||||||
|
mentionTags,
|
||||||
|
|
||||||
rootId,
|
rootId,
|
||||||
rootRelay,
|
rootRelay,
|
||||||
replyId,
|
replyId,
|
||||||
replyRelay,
|
replyRelay,
|
||||||
|
|
||||||
contentTagRefs,
|
contentTagRefs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
27
src/helpers/notification.ts
Normal file
27
src/helpers/notification.ts
Normal 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);
|
||||||
|
}
|
@ -24,7 +24,7 @@ export function useNotificationTimeline() {
|
|||||||
|
|
||||||
export default function NotificationTimelineProvider({ children }: PropsWithChildren) {
|
export default function NotificationTimelineProvider({ children }: PropsWithChildren) {
|
||||||
const account = useCurrentAccount();
|
const account = useCurrentAccount();
|
||||||
const readRelays = useReadRelayUrls();
|
const inbox = useReadRelayUrls();
|
||||||
|
|
||||||
const userMuteFilter = useClientSideMuteFilter();
|
const userMuteFilter = useClientSideMuteFilter();
|
||||||
const eventFilter = useCallback(
|
const eventFilter = useCallback(
|
||||||
@ -37,7 +37,7 @@ export default function NotificationTimelineProvider({ children }: PropsWithChil
|
|||||||
|
|
||||||
const timeline = useTimelineLoader(
|
const timeline = useTimelineLoader(
|
||||||
`${account?.pubkey ?? "anon"}-notification`,
|
`${account?.pubkey ?? "anon"}-notification`,
|
||||||
readRelays,
|
inbox,
|
||||||
account?.pubkey
|
account?.pubkey
|
||||||
? {
|
? {
|
||||||
"#p": [account.pubkey],
|
"#p": [account.pubkey],
|
||||||
|
@ -125,7 +125,7 @@ export default function MessageBubble({
|
|||||||
{(plaintext) => (
|
{(plaintext) => (
|
||||||
<MessageContent event={message} text={plaintext} display="inline">
|
<MessageContent event={message} text={plaintext} display="inline">
|
||||||
{!hasReactions && (
|
{!hasReactions && (
|
||||||
<ButtonGroup size="xs" variant="ghost" float="right">
|
<ButtonGroup size="xs" variant="ghost" float="right" ml="2">
|
||||||
{actionPosition === "inline" && actions}
|
{actionPosition === "inline" && actions}
|
||||||
<Timestamp timestamp={message.created_at} ml="2" />
|
<Timestamp timestamp={message.created_at} ml="2" />
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
38
src/views/notifications/components/day-group.tsx
Normal file
38
src/views/notifications/components/day-group.tsx
Normal 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;
|
@ -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;
|
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
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 { Kind } from "nostr-tools";
|
||||||
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
|
||||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||||
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
|
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 PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
|
||||||
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
|
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
|
||||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
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 NotificationTypeToggles from "./notification-type-toggles";
|
||||||
import { NostrEvent } from "../../types/nostr-event";
|
import { NostrEvent } from "../../types/nostr-event";
|
||||||
import dayjs from "dayjs";
|
import { groupByDay } from "../../helpers/notification";
|
||||||
import SuperMap from "../../classes/super-map";
|
import DayGroup from "./components/day-group";
|
||||||
|
|
||||||
const specialNames = {
|
|
||||||
[dayjs().startOf("day").unix()]: "Today",
|
|
||||||
[dayjs().subtract(1, "day").startOf("day").unix()]: "Yesterday",
|
|
||||||
};
|
|
||||||
|
|
||||||
function NotificationDay({ day, events }: { day: number; events: NostrEvent[] }) {
|
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);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useRegisterIntersectionEntity(ref, expanded.isOpen ? undefined : getEventUID(events[events.length - 1]));
|
useRegisterIntersectionEntity(ref, getEventUID(events[events.length - 1]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<DayGroup day={day} ref={ref} hideRefOnClose>
|
||||||
<Flex gap="4" alignItems="center" mt="4" ref={ref}>
|
{events.map((event) => (
|
||||||
<Divider w="10" flexShrink={0} />
|
<NotificationItem key={event.id} event={event} />
|
||||||
<Heading size="lg" whiteSpace="nowrap">
|
))}
|
||||||
{title}
|
</DayGroup>
|
||||||
</Heading>
|
|
||||||
<Divider />
|
|
||||||
<ExpandableToggleButton toggle={expanded} aria-label="Toggle day" title="Toggle day" />
|
|
||||||
</Flex>
|
|
||||||
{expanded.isOpen && events.map((event) => <NotificationItem key={event.id} event={event} />)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,21 +71,12 @@ function NotificationsPage() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const sortedDays = useMemo(() => groupByDay(events), [events]);
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntersectionObserverProvider callback={callback}>
|
<IntersectionObserverProvider callback={callback}>
|
||||||
<VerticalPageLayout>
|
<VerticalPageLayout>
|
||||||
<Flex gap="2">
|
<Flex gap="2" wrap="wrap">
|
||||||
<NotificationTypeToggles
|
<NotificationTypeToggles
|
||||||
showReplies={showReplies}
|
showReplies={showReplies}
|
||||||
showMentions={showMentions}
|
showMentions={showMentions}
|
||||||
@ -111,7 +84,12 @@ function NotificationsPage() {
|
|||||||
showReactions={showReactions}
|
showReactions={showReactions}
|
||||||
showReposts={showReposts}
|
showReposts={showReposts}
|
||||||
/>
|
/>
|
||||||
<PeopleListSelection flexShrink={0} />
|
<ButtonGroup>
|
||||||
|
<PeopleListSelection flexShrink={0} />
|
||||||
|
<Button as={RouterLink} to="/notifications/threads">
|
||||||
|
Threads
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{sortedDays.map(([day, events]) => (
|
{sortedDays.map(([day, events]) => (
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { PropsWithChildren, ReactNode, forwardRef, memo, useMemo, useRef } from "react";
|
import { ReactNode, forwardRef, memo, useMemo, useRef } from "react";
|
||||||
import { AvatarGroup, Box, Flex, IconButton, IconButtonProps, Text, useDisclosure } from "@chakra-ui/react";
|
import { AvatarGroup, Flex, IconButton, IconButtonProps, Text, useDisclosure } from "@chakra-ui/react";
|
||||||
import { Kind, nip18, nip25 } from "nostr-tools";
|
import { Kind, nip18, nip25 } from "nostr-tools";
|
||||||
|
|
||||||
import useCurrentAccount from "../../hooks/use-current-account";
|
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 { AtIcon, ChevronDownIcon, ChevronUpIcon, LightningIcon, ReplyIcon, RepostIcon } from "../../components/icons";
|
||||||
import useSingleEvent from "../../hooks/use-single-event";
|
import useSingleEvent from "../../hooks/use-single-event";
|
||||||
import { TORRENT_COMMENT_KIND } from "../../helpers/nostr/torrents";
|
import { 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 = ({
|
export const ExpandableToggleButton = ({
|
||||||
toggle,
|
toggle,
|
||||||
...props
|
...props
|
||||||
@ -48,28 +44,16 @@ const NoteNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ ev
|
|||||||
else return null;
|
else return null;
|
||||||
});
|
});
|
||||||
const ReplyNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => (
|
const ReplyNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => (
|
||||||
<Flex gap="2" ref={ref}>
|
<NotificationIconEntry ref={ref} icon={<ReplyIcon boxSize={8} color="green.400" />}>
|
||||||
<IconBox>
|
<EmbedEvent event={event} />
|
||||||
<ReplyIcon boxSize={8} color="green.400" />
|
</NotificationIconEntry>
|
||||||
</IconBox>
|
|
||||||
<Flex direction="column" w="full" gap="2">
|
|
||||||
<EmbedEvent event={event} />
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
));
|
));
|
||||||
|
|
||||||
const MentionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
|
const MentionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => (
|
||||||
return (
|
<NotificationIconEntry ref={ref} icon={<AtIcon boxSize={8} color="purple.400" />}>
|
||||||
<Flex gap="2" ref={ref}>
|
<EmbedEvent event={event} />
|
||||||
<IconBox>
|
</NotificationIconEntry>
|
||||||
<AtIcon boxSize={8} color="purple.400" />
|
));
|
||||||
</IconBox>
|
|
||||||
<Flex direction="column" w="full" gap="2">
|
|
||||||
<EmbedEvent event={event} />
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const RepostNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
|
const RepostNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
|
||||||
const account = useCurrentAccount()!;
|
const account = useCurrentAccount()!;
|
||||||
@ -79,20 +63,15 @@ const RepostNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({
|
|||||||
if (pointer?.author !== account.pubkey) return null;
|
if (pointer?.author !== account.pubkey) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex gap="2" ref={ref}>
|
<NotificationIconEntry ref={ref} icon={<RepostIcon boxSize={8} color="blue.400" />}>
|
||||||
<IconBox>
|
<Flex gap="2" alignItems="center">
|
||||||
<RepostIcon boxSize={8} color="blue.400" />
|
<AvatarGroup size="sm">
|
||||||
</IconBox>
|
<UserAvatarLink pubkey={event.pubkey} />
|
||||||
<Flex direction="column" w="full" gap="2">
|
</AvatarGroup>
|
||||||
<Flex gap="2" alignItems="center">
|
<ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />
|
||||||
<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 }} />}
|
|
||||||
</Flex>
|
</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;
|
if (reactedEvent?.kind === Kind.EncryptedDirectMessage) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex gap="2" ref={ref}>
|
<NotificationIconEntry ref={ref} icon={<Heart boxSize={8} color="red.400" />}>
|
||||||
<IconBox>
|
<Flex gap="2" alignItems="center">
|
||||||
<Heart boxSize={8} color="red.400" />
|
<AvatarGroup size="sm">
|
||||||
</IconBox>
|
<UserAvatarLink pubkey={event.pubkey} />
|
||||||
<Flex direction="column" w="full" gap="2">
|
</AvatarGroup>
|
||||||
<Flex gap="2" alignItems="center">
|
<Text fontSize="xl">{event.content}</Text>
|
||||||
<AvatarGroup size="sm">
|
<ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />
|
||||||
<UserAvatarLink pubkey={event.pubkey} />
|
{/* <Timestamp timestamp={event.created_at} ml="auto" /> */}
|
||||||
</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 }} />}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
{expanded.isOpen && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />}
|
||||||
|
</NotificationIconEntry>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -158,22 +132,17 @@ const ZapNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ eve
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex gap="2" ref={ref}>
|
<NotificationIconEntry ref={ref} icon={<LightningIcon boxSize={8} color="yellow.400" />}>
|
||||||
<IconBox>
|
<Flex gap="2" alignItems="center">
|
||||||
<LightningIcon boxSize={8} color="yellow.400" />
|
<AvatarGroup size="sm">
|
||||||
</IconBox>
|
<UserAvatarLink pubkey={zap.request.pubkey} />
|
||||||
<Flex direction="column" w="full" gap="2">
|
</AvatarGroup>
|
||||||
<Flex gap="2" alignItems="center">
|
<Text>{readablizeSats(zap.payment.amount / 1000)} sats</Text>
|
||||||
<AvatarGroup size="sm">
|
{zap.request.content && <Text>{zap.request.content}</Text>}
|
||||||
<UserAvatarLink pubkey={zap.request.pubkey} />
|
{eventJSX !== null && <ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />}
|
||||||
</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}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
{expanded.isOpen && eventJSX}
|
||||||
|
</NotificationIconEntry>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
140
src/views/notifications/threads.tsx
Normal file
140
src/views/notifications/threads.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user