mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-26 17:52:18 +01:00
Add support for threads in DMs
This commit is contained in:
parent
81bb4e3a18
commit
98b4bef45e
5
.changeset/eleven-cows-fail.md
Normal file
5
.changeset/eleven-cows-fail.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for threads in DMs
|
@ -1,21 +1,19 @@
|
||||
import { Card, CardBody, CardHeader, CardProps, LinkBox, Spacer, Text } from "@chakra-ui/react";
|
||||
import { Card, CardBody, CardHeader, CardProps, LinkBox, Text } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { TrustProvider } from "../../../providers/trust";
|
||||
import UserAvatarLink from "../../user-avatar-link";
|
||||
import UserLink from "../../user-link";
|
||||
import Timestamp from "../../timestamp";
|
||||
import DecryptPlaceholder from "../../../views/dms/decrypt-placeholder";
|
||||
import { MessageContent } from "../../../views/dms/message-block";
|
||||
import { getMessageRecipient } from "../../../services/direct-messages";
|
||||
import DecryptPlaceholder from "../../../views/dms/components/decrypt-placeholder";
|
||||
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||
import { getDMRecipient, getDMSender } from "../../../helpers/nostr/dms";
|
||||
import { MessageContent } from "../../../views/dms/components/message-bubble";
|
||||
|
||||
export default function EmbeddedDM({ dm, ...props }: Omit<CardProps, "children"> & { dm: NostrEvent }) {
|
||||
const account = useCurrentAccount();
|
||||
const isOwnMessage = account?.pubkey === dm.pubkey;
|
||||
|
||||
const sender = dm.pubkey;
|
||||
const receiver = getMessageRecipient(dm);
|
||||
const sender = getDMSender(dm);
|
||||
const receiver = getDMRecipient(dm);
|
||||
|
||||
if (!receiver) return "Broken DM";
|
||||
|
||||
@ -30,11 +28,13 @@ export default function EmbeddedDM({ dm, ...props }: Omit<CardProps, "children">
|
||||
<UserLink pubkey={receiver} fontWeight="bold" isTruncated fontSize="lg" />
|
||||
<Timestamp timestamp={dm.created_at} />
|
||||
</CardHeader>
|
||||
<CardBody px="2" pt="0" pb="2">
|
||||
<DecryptPlaceholder data={dm.content} pubkey={isOwnMessage ? getMessageRecipient(dm) ?? "" : dm.pubkey}>
|
||||
{(text) => <MessageContent event={dm} text={text} />}
|
||||
</DecryptPlaceholder>
|
||||
</CardBody>
|
||||
{(sender === account?.pubkey || receiver === account?.pubkey) && (
|
||||
<CardBody px="2" pt="0" pb="2">
|
||||
<DecryptPlaceholder message={dm}>
|
||||
{(plaintext) => <MessageContent event={dm} text={plaintext} />}
|
||||
</DecryptPlaceholder>
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
</TrustProvider>
|
||||
);
|
||||
|
@ -5,7 +5,6 @@ import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerProps,
|
||||
Flex,
|
||||
|
35
src/helpers/nostr/dms.ts
Normal file
35
src/helpers/nostr/dms.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import dayjs from "dayjs";
|
||||
import { NostrEvent, isPTag } from "../../types/nostr-event";
|
||||
|
||||
export function getDMSender(event: NostrEvent) {
|
||||
return event.pubkey;
|
||||
}
|
||||
export function getDMRecipient(event: NostrEvent) {
|
||||
const pubkey = event.tags.find(isPTag)?.[1];
|
||||
if (!pubkey) throw new Error("Missing recipient pubkey");
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
export function groupMessages(messages: NostrEvent[], minutes = 5, ascending = false) {
|
||||
const sorted = messages.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
const groups: { id: string; pubkey: string; events: NostrEvent[] }[] = [];
|
||||
for (const message of sorted) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.pubkey === message.pubkey) {
|
||||
const lastEvent = last.events[last.events.length - 1];
|
||||
if (lastEvent && dayjs.unix(lastEvent.created_at).diff(dayjs.unix(message.created_at), "minute") < minutes) {
|
||||
last.events.push(message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const group = { id: message.id, pubkey: message.pubkey, events: [message] };
|
||||
groups.push(group);
|
||||
}
|
||||
|
||||
if (ascending) {
|
||||
for (const group of groups) group.events.reverse();
|
||||
return groups.reverse();
|
||||
} else return groups;
|
||||
}
|
@ -112,6 +112,22 @@ export function useNavigateInDrawer() {
|
||||
|
||||
const log = logger.extend("DrawerRouter");
|
||||
|
||||
export function useRouterMarker(router: Router) {
|
||||
const index = useRef<number | null>(null);
|
||||
const set = useCallback((v=0) => (index.current = v), []);
|
||||
const reset = useCallback(() => (index.current = null), []);
|
||||
|
||||
useEffect(() => {
|
||||
return router.subscribe((event) => {
|
||||
if (index.current === null) return;
|
||||
if (event.historyAction === "PUSH") index.current++;
|
||||
else if (event.historyAction === "POP") index.current--;
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
return useMemo(() => ({ index, set, reset }), [index, set, reset]);
|
||||
}
|
||||
|
||||
export default function DrawerSubViewProvider({
|
||||
children,
|
||||
parentRouter,
|
||||
@ -121,16 +137,13 @@ export default function DrawerSubViewProvider({
|
||||
const openInParent = useCallback((to: To) => parentRouter.navigate(to), [parentRouter]);
|
||||
|
||||
const direction = useRef<"up" | "down">();
|
||||
const marker = useRef<number>(0);
|
||||
const marker = useRouterMarker(parentRouter);
|
||||
|
||||
useEffect(() => {
|
||||
return parentRouter.subscribe((event) => {
|
||||
const location = event.location as Location<{ subRouterPath?: To | null } | null>;
|
||||
const subRoute = location.state?.subRouterPath;
|
||||
|
||||
if (event.historyAction === "PUSH") marker.current++;
|
||||
else if (event.historyAction === "POP") marker.current--;
|
||||
|
||||
if (subRoute) {
|
||||
if (router) {
|
||||
if (router.state.location.pathname !== subRoute && direction.current !== "up") {
|
||||
@ -175,7 +188,7 @@ export default function DrawerSubViewProvider({
|
||||
|
||||
const openDrawer = useCallback(
|
||||
(to: To) => {
|
||||
marker.current = 0;
|
||||
marker.set();
|
||||
parentRouter.navigate(parentRouter.state.location, {
|
||||
preventScrollReset: true,
|
||||
state: { ...parentRouter.state.location.state, subRouterPath: to },
|
||||
@ -185,8 +198,8 @@ export default function DrawerSubViewProvider({
|
||||
);
|
||||
|
||||
const closeDrawer = useCallback(() => {
|
||||
const i = marker.current;
|
||||
if (i > 0) {
|
||||
const i = marker.index.current;
|
||||
if (i !== null && i > 0) {
|
||||
log(`Navigating back ${i} entries to the point the drawer was opened`);
|
||||
parentRouter.navigate(-i);
|
||||
} else {
|
||||
@ -198,7 +211,7 @@ export default function DrawerSubViewProvider({
|
||||
}
|
||||
|
||||
// reset marker
|
||||
marker.current = 0;
|
||||
marker.reset();
|
||||
}, [parentRouter]);
|
||||
|
||||
const context = useMemo(
|
||||
|
@ -9,7 +9,7 @@ import { PersistentSubject } from "../classes/subject";
|
||||
import accountService from "./account";
|
||||
import { createSimpleQueryMap } from "../helpers/nostr/filter";
|
||||
|
||||
export function getMessageRecipient(event: NostrEvent): string | undefined {
|
||||
function getMessageRecipient(event: NostrEvent): string | undefined {
|
||||
return event.tags.find(isPTag)?.[1];
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Card, Flex, IconButton } from "@chakra-ui/react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Button, ButtonGroup, Card, Flex, IconButton, useDisclosure } from "@chakra-ui/react";
|
||||
import { Kind, nip19 } from "nostr-tools";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { UNSAFE_DataRouterContext, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { ChevronLeftIcon } from "../../components/icons";
|
||||
import UserAvatar from "../../components/user-avatar";
|
||||
@ -9,7 +9,7 @@ import UserLink from "../../components/user-link";
|
||||
import { isHexKey } from "../../helpers/nip19";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||
import MessageBlock from "./message-block";
|
||||
import MessageBlock from "./components/message-block";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
@ -19,13 +19,25 @@ import TimelineActionAndStatus from "../../components/timeline-page/timeline-act
|
||||
import { LightboxProvider } from "../../components/lightbox-provider";
|
||||
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
|
||||
import { useDecryptionContext } from "../../providers/dycryption-provider";
|
||||
import SendMessageForm from "./send-message-form";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import dayjs from "dayjs";
|
||||
import SendMessageForm from "./components/send-message-form";
|
||||
import { groupMessages } from "../../helpers/nostr/dms";
|
||||
import ThreadDrawer from "./components/thread-drawer";
|
||||
import MessageChatSquare from "../../components/icons/message-chat-square";
|
||||
import ThreadsProvider from "./components/thread-provider";
|
||||
import { useRouterMarker } from "../../providers/drawer-sub-view-provider";
|
||||
|
||||
const GROUP_MESSAGES_LESS_THAN_MIN = 5;
|
||||
function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { router } = useContext(UNSAFE_DataRouterContext)!;
|
||||
const marker = useRouterMarker(router);
|
||||
useEffect(() => {
|
||||
if (location.state?.thread && marker.index.current === null) {
|
||||
// the drawer just open, set the marker
|
||||
marker.set(1);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const account = useCurrentAccount()!;
|
||||
const { getOrCreateContainer, addToQueue, startQueue } = useDecryptionContext();
|
||||
|
||||
@ -45,23 +57,7 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
]);
|
||||
|
||||
const messages = useSubject(timeline.timeline).filter((e) => !e.tags.some((t) => t[0] === "e" && t[3] === "root"));
|
||||
|
||||
const grouped: { id: string; events: NostrEvent[] }[] = [];
|
||||
for (const message of messages) {
|
||||
const last = grouped[grouped.length - 1];
|
||||
if (last && last.events[0]?.pubkey === message.pubkey) {
|
||||
const lastEvent = last.events[last.events.length - 1];
|
||||
if (
|
||||
lastEvent &&
|
||||
dayjs.unix(lastEvent.created_at).diff(dayjs.unix(message.created_at), "minute") < GROUP_MESSAGES_LESS_THAN_MIN
|
||||
) {
|
||||
last.events.push(message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
grouped.push({ id: message.id, events: [message] });
|
||||
}
|
||||
const grouped = groupMessages(messages);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const decryptAll = async () => {
|
||||
@ -81,7 +77,7 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<LightboxProvider>
|
||||
<ThreadsProvider timeline={timeline}>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<Card size="sm" flexShrink={0} p="2" flexDirection="row">
|
||||
<Flex gap="2" alignItems="center">
|
||||
@ -96,19 +92,44 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
<UserLink pubkey={pubkey} fontWeight="bold" />
|
||||
<UserDnsIdentityIcon pubkey={pubkey} onlyIcon />
|
||||
</Flex>
|
||||
<Button onClick={decryptAll} isLoading={loading} ml="auto">
|
||||
Decrypt All
|
||||
</Button>
|
||||
<ButtonGroup ml="auto">
|
||||
<Button onClick={decryptAll} isLoading={loading}>
|
||||
Decrypt All
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label="Threads"
|
||||
title="Threads"
|
||||
icon={<MessageChatSquare boxSize={5} />}
|
||||
onClick={() => {
|
||||
marker.set(0);
|
||||
navigate(".", { state: { thread: "list" } });
|
||||
}}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</Card>
|
||||
<Flex h="0" flex={1} overflowX="hidden" overflowY="scroll" direction="column-reverse" gap="2" py="4" px="2">
|
||||
{grouped.map((group) => (
|
||||
<MessageBlock key={group.id} events={group.events} />
|
||||
))}
|
||||
<LightboxProvider>
|
||||
{grouped.map((group) => (
|
||||
<MessageBlock key={group.id} messages={group.events} reverse />
|
||||
))}
|
||||
</LightboxProvider>
|
||||
<TimelineActionAndStatus timeline={timeline} />
|
||||
</Flex>
|
||||
<SendMessageForm flexShrink={0} pubkey={pubkey} />
|
||||
{location.state?.thread && (
|
||||
<ThreadDrawer
|
||||
isOpen
|
||||
onClose={() => {
|
||||
if (marker.index.current !== null && marker.index.current > 0) {
|
||||
navigate(-marker.index.current);
|
||||
} else navigate(".", { state: { thread: undefined } });
|
||||
}}
|
||||
threadId={location.state.thread}
|
||||
pubkey={pubkey}
|
||||
/>
|
||||
)}
|
||||
</IntersectionObserverProvider>
|
||||
</LightboxProvider>
|
||||
</ThreadsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,21 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription, AlertIcon, Button, ButtonProps } from "@chakra-ui/react";
|
||||
|
||||
import { UnlockIcon } from "../../components/icons";
|
||||
import { useDecryptionContainer } from "../../providers/dycryption-provider";
|
||||
import { UnlockIcon } from "../../../components/icons";
|
||||
import { useDecryptionContainer } from "../../../providers/dycryption-provider";
|
||||
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||
import { getDMRecipient, getDMSender } from "../../../helpers/nostr/dms";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
|
||||
export default function DecryptPlaceholder({
|
||||
children,
|
||||
data,
|
||||
pubkey,
|
||||
message,
|
||||
...props
|
||||
}: {
|
||||
children: (decrypted: string) => JSX.Element;
|
||||
data: string;
|
||||
pubkey: string;
|
||||
message: NostrEvent;
|
||||
} & Omit<ButtonProps, "children">): JSX.Element {
|
||||
const account = useCurrentAccount();
|
||||
const isOwn = account?.pubkey === message.pubkey;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { requestDecrypt, plaintext, error } = useDecryptionContainer(pubkey, data);
|
||||
const { requestDecrypt, plaintext, error } = useDecryptionContainer(
|
||||
isOwn ? getDMRecipient(message) : getDMSender(message),
|
||||
message.content,
|
||||
);
|
||||
|
||||
const decrypt = async () => {
|
||||
setLoading(true);
|
61
src/views/dms/components/message-block.tsx
Normal file
61
src/views/dms/components/message-block.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { CardProps, Flex } from "@chakra-ui/react";
|
||||
|
||||
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import UserAvatar from "../../../components/user-avatar";
|
||||
import MessageBubble, { MessageBubbleProps } from "./message-bubble";
|
||||
import { useThreadsContext } from "./thread-provider";
|
||||
import ThreadButton from "./thread-button";
|
||||
|
||||
function MessageBubbleWithThread({ message, ...props }: MessageBubbleProps) {
|
||||
const { threads } = useThreadsContext();
|
||||
const thread = threads[message.id];
|
||||
|
||||
return (
|
||||
<>
|
||||
{thread && <ThreadButton thread={thread} />}
|
||||
<MessageBubble message={message} {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MessageBlock({
|
||||
messages,
|
||||
showThreadButtons = true,
|
||||
reverse = false,
|
||||
}: { messages: NostrEvent[]; showThreadButtons?: boolean; reverse?: boolean } & Omit<CardProps, "children">) {
|
||||
const lastEvent = messages[messages.length - 1];
|
||||
const account = useCurrentAccount()!;
|
||||
const isOwn = account.pubkey === lastEvent.pubkey;
|
||||
|
||||
const avatar = <UserAvatar pubkey={lastEvent.pubkey} size="sm" my="1" />;
|
||||
|
||||
const MessageBubbleComponent = showThreadButtons ? MessageBubbleWithThread : MessageBubble;
|
||||
|
||||
return (
|
||||
<Flex direction="row" gap="2" alignItems="flex-end">
|
||||
{!isOwn && avatar}
|
||||
<Flex
|
||||
direction={reverse ? "column-reverse" : "column"}
|
||||
gap="1"
|
||||
ml={isOwn ? "auto" : 0}
|
||||
mr={isOwn ? 0 : "auto"}
|
||||
maxW="2xl"
|
||||
alignItems={isOwn ? "flex-end" : "flex-start"}
|
||||
overflowX="hidden"
|
||||
overflowY="visible"
|
||||
>
|
||||
{messages.map((message, i, arr) => (
|
||||
<MessageBubbleComponent
|
||||
key={message.id}
|
||||
message={message}
|
||||
showHeader={reverse ? i === arr.length - 1 : i === 0}
|
||||
minW={{ base: 0, sm: "sm", md: "md" }}
|
||||
overflow="hidden"
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
{isOwn && avatar}
|
||||
</Flex>
|
||||
);
|
||||
}
|
91
src/views/dms/components/message-bubble.tsx
Normal file
91
src/views/dms/components/message-bubble.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useRef } from "react";
|
||||
import { Box, BoxProps, Card, CardBody, CardFooter, CardHeader, CardProps, Flex } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import DecryptPlaceholder from "./decrypt-placeholder";
|
||||
import { EmbedableContent, embedUrls } from "../../../helpers/embeds";
|
||||
import {
|
||||
embedCashuTokens,
|
||||
embedNostrLinks,
|
||||
renderGenericUrl,
|
||||
renderImageUrl,
|
||||
renderVideoUrl,
|
||||
} from "../../../components/embed-types";
|
||||
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
|
||||
import { getEventUID } from "../../../helpers/nostr/events";
|
||||
import Timestamp from "../../../components/timestamp";
|
||||
import NoteZapButton from "../../../components/note/note-zap-button";
|
||||
import UserLink from "../../../components/user-link";
|
||||
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
|
||||
import useEventReactions from "../../../hooks/use-event-reactions";
|
||||
import AddReactionButton from "../../../components/note/components/add-reaction-button";
|
||||
import { TrustProvider } from "../../../providers/trust";
|
||||
import NoteReactions from "../../../components/note/components/note-reactions";
|
||||
|
||||
export function MessageContent({ event, text, children, ...props }: { event: NostrEvent; text: string } & BoxProps) {
|
||||
let content: EmbedableContent = [text];
|
||||
|
||||
content = embedNostrLinks(content);
|
||||
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
|
||||
|
||||
// cashu
|
||||
content = embedCashuTokens(content);
|
||||
|
||||
return (
|
||||
<TrustProvider event={event}>
|
||||
<Box whiteSpace="pre-wrap" {...props}>
|
||||
{content}
|
||||
{children}
|
||||
</Box>
|
||||
</TrustProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageBubbleProps = { message: NostrEvent; showHeader?: boolean } & Omit<CardProps, "children">;
|
||||
|
||||
export default function MessageBubble({ message, showHeader = true, ...props }: MessageBubbleProps) {
|
||||
const reactions = useEventReactions(message.id) ?? [];
|
||||
const hasReactions = reactions.length > 0;
|
||||
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, getEventUID(message));
|
||||
|
||||
return (
|
||||
<Card {...props} borderRadius="lg" ref={ref}>
|
||||
{showHeader && (
|
||||
<CardHeader px="2" pt="2" pb="0" gap="2" display="flex" alignItems="center">
|
||||
<UserLink pubkey={message.pubkey} fontWeight="bold" />
|
||||
<UserDnsIdentityIcon pubkey={message.pubkey} onlyIcon />
|
||||
<NoteZapButton event={message} size="xs" ml="auto" variant="ghost" />
|
||||
<AddReactionButton event={message} size="xs" variant="ghost" />
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardBody px="2" py="2">
|
||||
<DecryptPlaceholder message={message} variant="link" py="4" px="6rem">
|
||||
{(plaintext) => (
|
||||
<MessageContent event={message} text={plaintext} display="inline">
|
||||
{!hasReactions && (
|
||||
<Flex float="right">
|
||||
{!showHeader && (
|
||||
<>
|
||||
<NoteZapButton event={message} size="xs" ml="2" variant="ghost" />
|
||||
<AddReactionButton event={message} size="xs" variant="ghost" ml="1" />
|
||||
</>
|
||||
)}
|
||||
<Timestamp timestamp={message.created_at} ml="2" />
|
||||
</Flex>
|
||||
)}
|
||||
</MessageContent>
|
||||
)}
|
||||
</DecryptPlaceholder>
|
||||
</CardBody>
|
||||
{hasReactions && (
|
||||
<CardFooter alignItems="center" display="flex" gap="2" px="2" pt="0" pb="2">
|
||||
<NoteReactions event={message} size="xs" variant="ghost" />
|
||||
<NoteZapButton event={message} size="xs" mr="auto" variant="ghost" />
|
||||
<Timestamp ml="auto" timestamp={message.created_at} />
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -4,18 +4,22 @@ import dayjs from "dayjs";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
import { Button, Flex, FlexProps, Heading, useToast } from "@chakra-ui/react";
|
||||
import { useSigningContext } from "../../providers/signing-provider";
|
||||
import MagicTextArea, { RefType } from "../../components/magic-textarea";
|
||||
import { useTextAreaUploadFileWithForm } from "../../hooks/use-textarea-upload-file";
|
||||
import clientRelaysService from "../../services/client-relays";
|
||||
import { unique } from "../../helpers/array";
|
||||
import { DraftNostrEvent } from "../../types/nostr-event";
|
||||
import NostrPublishAction from "../../classes/nostr-publish-action";
|
||||
import { useUserRelays } from "../../hooks/use-user-relays";
|
||||
import { RelayMode } from "../../classes/relay";
|
||||
import { useDecryptionContext } from "../../providers/dycryption-provider";
|
||||
import { useSigningContext } from "../../../providers/signing-provider";
|
||||
import MagicTextArea, { RefType } from "../../../components/magic-textarea";
|
||||
import { useTextAreaUploadFileWithForm } from "../../../hooks/use-textarea-upload-file";
|
||||
import clientRelaysService from "../../../services/client-relays";
|
||||
import { unique } from "../../../helpers/array";
|
||||
import { DraftNostrEvent } from "../../../types/nostr-event";
|
||||
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||
import { useUserRelays } from "../../../hooks/use-user-relays";
|
||||
import { RelayMode } from "../../../classes/relay";
|
||||
import { useDecryptionContext } from "../../../providers/dycryption-provider";
|
||||
|
||||
export default function SendMessageForm({ pubkey, ...props }: { pubkey: string } & Omit<FlexProps, "children">) {
|
||||
export default function SendMessageForm({
|
||||
pubkey,
|
||||
rootId,
|
||||
...props
|
||||
}: { pubkey: string; rootId?: string } & Omit<FlexProps, "children">) {
|
||||
const toast = useToast();
|
||||
const { requestEncrypt, requestSignature } = useSigningContext();
|
||||
const { getOrCreateContainer } = useDecryptionContext();
|
||||
@ -48,6 +52,10 @@ export default function SendMessageForm({ pubkey, ...props }: { pubkey: string }
|
||||
created_at: dayjs().unix(),
|
||||
};
|
||||
|
||||
if (rootId) {
|
||||
event.tags.push(["e", rootId, "", "root"]);
|
||||
}
|
||||
|
||||
setLoadingMessage("Signing...");
|
||||
const signed = await requestSignature(event);
|
||||
const writeRelays = clientRelaysService.getWriteUrls();
|
27
src/views/dms/components/thread-button.tsx
Normal file
27
src/views/dms/components/thread-button.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Button } from "@chakra-ui/react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import UserAvatar from "../../../components/user-avatar";
|
||||
import { Thread } from "./thread-provider";
|
||||
import { ChevronRightIcon } from "../../../components/icons";
|
||||
|
||||
export default function ThreadButton({ thread }: { thread: Thread }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const onClick = () => {
|
||||
navigate(`.`, { state: { ...location.state, thread: thread.rootId } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<UserAvatar pubkey={thread.messages[thread.messages.length - 1].pubkey} size="xs" />}
|
||||
rightIcon={<ChevronRightIcon />}
|
||||
onClick={onClick}
|
||||
>
|
||||
{thread.messages.length} replies
|
||||
</Button>
|
||||
);
|
||||
}
|
154
src/views/dms/components/thread-drawer.tsx
Normal file
154
src/views/dms/components/thread-drawer.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerProps,
|
||||
Flex,
|
||||
Spinner,
|
||||
Text,
|
||||
TextProps,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import UserAvatar from "../../../components/user-avatar";
|
||||
import UserLink from "../../../components/user-link";
|
||||
import DecryptPlaceholder from "./decrypt-placeholder";
|
||||
import Timestamp from "../../../components/timestamp";
|
||||
import { Thread, useThreadsContext } from "./thread-provider";
|
||||
import ThreadButton from "./thread-button";
|
||||
import MessageBlock from "./message-block";
|
||||
import { LightboxProvider } from "../../../components/lightbox-provider";
|
||||
import TimelineActionAndStatus from "../../../components/timeline-page/timeline-action-and-status";
|
||||
import SendMessageForm from "./send-message-form";
|
||||
import { groupMessages } from "../../../helpers/nostr/dms";
|
||||
import { useDecryptionContext } from "../../../providers/dycryption-provider";
|
||||
import { useState } from "react";
|
||||
|
||||
function MessagePreview({ message, ...props }: { message: NostrEvent } & Omit<TextProps, "children">) {
|
||||
return (
|
||||
<DecryptPlaceholder message={message} variant="link" py="4" px="6rem" zIndex={1}>
|
||||
{(plaintext) => <Text isTruncated>{plaintext}</Text>}
|
||||
</DecryptPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadCard({ thread }: { thread: Thread }) {
|
||||
const latestMessage = thread.messages[thread.messages.length - 1];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{thread.root && (
|
||||
<CardHeader px="2" pt="2" pb="1" gap="2" display="flex">
|
||||
<UserAvatar pubkey={thread.root.pubkey} size="xs" />
|
||||
<UserLink fontWeight="bold" pubkey={thread.root.pubkey} />
|
||||
<Timestamp timestamp={latestMessage.created_at} ml="auto" />
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardBody px="2" py="1">
|
||||
{thread.root ? <MessagePreview message={thread.root} /> : <Spinner />}
|
||||
</CardBody>
|
||||
<CardFooter px="2" pb="2" pt="0">
|
||||
<ThreadButton thread={thread} />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ListThreads() {
|
||||
const { threads } = useThreadsContext();
|
||||
|
||||
const latestThreads = Object.values(threads).sort(
|
||||
(a, b) => b.messages[b.messages.length - 1].created_at - a.messages[a.messages.length - 1].created_at,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{latestThreads.map((thread) => (
|
||||
<ThreadCard key={thread.rootId} thread={thread} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadMessages({ thread, pubkey }: { thread: Thread; pubkey: string }) {
|
||||
const grouped = groupMessages(thread.messages, 5, true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex h="0" flex={1} overflowX="hidden" overflowY="scroll" direction="column" gap="2">
|
||||
{thread.root && <MessageBlock messages={[thread.root]} showThreadButtons={false} />}
|
||||
<LightboxProvider>
|
||||
{grouped.map((group) => (
|
||||
<MessageBlock key={group.id} messages={group.events} showThreadButtons={false} />
|
||||
))}
|
||||
</LightboxProvider>
|
||||
</Flex>
|
||||
<SendMessageForm flexShrink={0} pubkey={pubkey} rootId={thread.rootId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ThreadDrawer({
|
||||
threadId,
|
||||
pubkey,
|
||||
...props
|
||||
}: Omit<DrawerProps, "children"> & { threadId: string; pubkey: string }) {
|
||||
const { threads } = useThreadsContext();
|
||||
const { startQueue, getOrCreateContainer, addToQueue } = useDecryptionContext();
|
||||
|
||||
const thread = threads[threadId];
|
||||
const [loading, setLoading] = useState(false);
|
||||
const decryptAll = async () => {
|
||||
if (!thread) return <Spinner />;
|
||||
|
||||
const promises = thread.messages
|
||||
.map((message) => {
|
||||
const container = getOrCreateContainer(pubkey, message.content);
|
||||
if (container.plaintext.value === undefined) return addToQueue(container);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (thread.root) {
|
||||
const rootContainer = getOrCreateContainer(pubkey, thread.root.content);
|
||||
if (rootContainer.plaintext.value === undefined) addToQueue(rootContainer);
|
||||
}
|
||||
|
||||
startQueue();
|
||||
|
||||
setLoading(true);
|
||||
Promise.all(promises).finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (threadId === "list") return <ListThreads />;
|
||||
if (!thread) return <Spinner />;
|
||||
return <ThreadMessages thread={thread} pubkey={pubkey} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer placement="right" size="lg" {...props}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent bgColor="var(--chakra-colors-chakra-body-bg)">
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader p="2" display="flex" gap="4">
|
||||
<Text>Threads</Text>
|
||||
<Button size="sm" onClick={decryptAll} isLoading={loading}>
|
||||
Decrypt All
|
||||
</Button>
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody px="2" pt="0" pb="2" gap="2" display="flex" flexDirection="column">
|
||||
{renderContent()}
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
43
src/views/dms/components/thread-provider.tsx
Normal file
43
src/views/dms/components/thread-provider.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { PropsWithChildren, createContext, useContext } from "react";
|
||||
|
||||
import TimelineLoader from "../../../classes/timeline-loader";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import useSubject from "../../../hooks/use-subject";
|
||||
|
||||
export type Thread = {
|
||||
root?: NostrEvent;
|
||||
rootId: string;
|
||||
messages: NostrEvent[];
|
||||
};
|
||||
type ThreadsContextType = {
|
||||
threads: Record<string, Thread>;
|
||||
};
|
||||
const ThreadsContext = createContext<ThreadsContextType>({ threads: {} });
|
||||
|
||||
export function useThreadsContext() {
|
||||
return useContext(ThreadsContext);
|
||||
}
|
||||
|
||||
export default function ThreadsProvider({ timeline, children }: { timeline: TimelineLoader } & PropsWithChildren) {
|
||||
const messages = useSubject(timeline.timeline);
|
||||
|
||||
const groupedByRoot: Record<string, NostrEvent[]> = {};
|
||||
for (const message of messages) {
|
||||
const rootId = message.tags.find((t) => t[0] === "e" && t[3] === "root")?.[1];
|
||||
if (rootId) {
|
||||
if (!groupedByRoot[rootId]) groupedByRoot[rootId] = [];
|
||||
groupedByRoot[rootId].push(message);
|
||||
}
|
||||
}
|
||||
|
||||
const threads: Record<string, Thread> = {};
|
||||
for (const [rootId, messages] of Object.entries(groupedByRoot)) {
|
||||
threads[rootId] = {
|
||||
messages,
|
||||
rootId,
|
||||
root: timeline.events.getEvent(rootId),
|
||||
};
|
||||
}
|
||||
|
||||
return <ThreadsContext.Provider value={{ threads }}>{children}</ThreadsContext.Provider>;
|
||||
}
|
@ -1,20 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChatIcon } from "@chakra-ui/icons";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
Flex,
|
||||
Input,
|
||||
Link,
|
||||
LinkBox,
|
||||
LinkOverlay,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { Button, Card, CardBody, Flex, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
|
||||
import dayjs from "dayjs";
|
||||
import { Outlet, Link as RouterLink, useLocation, useParams } from "react-router-dom";
|
||||
import { nip19 } from "nostr-tools";
|
||||
@ -24,10 +9,8 @@ import { getUserDisplayName } from "../../helpers/user-metadata";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
import directMessagesService from "../../services/direct-messages";
|
||||
import { ExternalLinkIcon } from "../../components/icons";
|
||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||
import Timestamp from "../../components/timestamp";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
|
||||
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
|
||||
|
||||
@ -36,6 +19,7 @@ function ContactCard({ pubkey }: { pubkey: string }) {
|
||||
const messages = useSubject(subject);
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
const location = useLocation();
|
||||
const latestMessage = messages[0];
|
||||
|
||||
return (
|
||||
<LinkBox as={Card} size="sm">
|
||||
@ -43,7 +27,7 @@ function ContactCard({ pubkey }: { pubkey: string }) {
|
||||
<UserAvatar pubkey={pubkey} />
|
||||
<Flex direction="column" gap="1" overflow="hidden" flex={1}>
|
||||
<Text flex={1}>{getUserDisplayName(metadata, pubkey)}</Text>
|
||||
{messages[0] && <Timestamp flexShrink={0} timestamp={messages[0].created_at} />}
|
||||
{latestMessage && <Timestamp flexShrink={0} timestamp={latestMessage.created_at} />}
|
||||
</Flex>
|
||||
</CardBody>
|
||||
<LinkOverlay as={RouterLink} to={`/dm/${nip19.npubEncode(pubkey)}` + location.search} />
|
||||
|
@ -1,139 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { Box, BoxProps, Card, CardBody, CardFooter, CardHeader, CardProps, Flex } from "@chakra-ui/react";
|
||||
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import { getMessageRecipient } from "../../services/direct-messages";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import DecryptPlaceholder from "./decrypt-placeholder";
|
||||
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
|
||||
import {
|
||||
embedCashuTokens,
|
||||
embedNostrLinks,
|
||||
renderGenericUrl,
|
||||
renderImageUrl,
|
||||
renderVideoUrl,
|
||||
} from "../../components/embed-types";
|
||||
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
|
||||
import UserAvatar from "../../components/user-avatar";
|
||||
import { getEventUID } from "../../helpers/nostr/events";
|
||||
import Timestamp from "../../components/timestamp";
|
||||
import NoteZapButton from "../../components/note/note-zap-button";
|
||||
import UserLink from "../../components/user-link";
|
||||
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
|
||||
import useEventReactions from "../../hooks/use-event-reactions";
|
||||
import AddReactionButton from "../../components/note/components/add-reaction-button";
|
||||
import { TrustProvider } from "../../providers/trust";
|
||||
import NoteReactions from "../../components/note/components/note-reactions";
|
||||
|
||||
export function MessageContent({ event, text, children, ...props }: { event: NostrEvent; text: string } & BoxProps) {
|
||||
let content: EmbedableContent = [text];
|
||||
|
||||
content = embedNostrLinks(content);
|
||||
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
|
||||
|
||||
// cashu
|
||||
content = embedCashuTokens(content);
|
||||
|
||||
return (
|
||||
<TrustProvider event={event}>
|
||||
<Box whiteSpace="pre-wrap" {...props}>
|
||||
{content}
|
||||
{children}
|
||||
</Box>
|
||||
</TrustProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
event,
|
||||
showHeader = true,
|
||||
...props
|
||||
}: { event: NostrEvent; showHeader?: boolean } & Omit<CardProps, "children">) {
|
||||
const account = useCurrentAccount()!;
|
||||
const isOwn = account.pubkey === event.pubkey;
|
||||
const reactions = useEventReactions(event.id) ?? [];
|
||||
const hasReactions = reactions.length > 0;
|
||||
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, getEventUID(event));
|
||||
|
||||
return (
|
||||
<Card {...props} borderRadius="lg" ref={ref}>
|
||||
{showHeader && (
|
||||
<CardHeader px="2" pt="2" pb="0" gap="2" display="flex" alignItems="center">
|
||||
<UserLink pubkey={event.pubkey} fontWeight="bold" />
|
||||
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
|
||||
<NoteZapButton event={event} size="xs" ml="auto" variant="ghost" />
|
||||
<AddReactionButton event={event} size="xs" variant="ghost" />
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardBody px="2" py="2">
|
||||
<DecryptPlaceholder
|
||||
data={event.content}
|
||||
pubkey={isOwn ? getMessageRecipient(event) ?? "" : event.pubkey}
|
||||
variant="link"
|
||||
py="4"
|
||||
px="6rem"
|
||||
>
|
||||
{(text) => (
|
||||
<MessageContent event={event} text={text} display="inline">
|
||||
{!hasReactions && (
|
||||
<Flex float="right">
|
||||
{!showHeader && (
|
||||
<>
|
||||
<NoteZapButton event={event} size="xs" ml="auto" variant="ghost" />
|
||||
<AddReactionButton event={event} size="xs" variant="ghost" ml="1" />
|
||||
</>
|
||||
)}
|
||||
<Timestamp timestamp={event.created_at} ml="2" />
|
||||
</Flex>
|
||||
)}
|
||||
</MessageContent>
|
||||
)}
|
||||
</DecryptPlaceholder>
|
||||
</CardBody>
|
||||
{hasReactions && (
|
||||
<CardFooter alignItems="center" display="flex" gap="2" px="2" pt="0" pb="2">
|
||||
<NoteReactions event={event} size="xs" variant="ghost" />
|
||||
<NoteZapButton event={event} size="xs" mr="auto" variant="ghost" />
|
||||
<Timestamp ml="auto" timestamp={event.created_at} />
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MessageBlock({ events }: { events: NostrEvent[] } & Omit<CardProps, "children">) {
|
||||
const lastEvent = events[events.length - 1];
|
||||
const account = useCurrentAccount()!;
|
||||
const isOwn = account.pubkey === lastEvent.pubkey;
|
||||
|
||||
const avatar = <UserAvatar pubkey={lastEvent.pubkey} size="sm" my="1" />;
|
||||
|
||||
return (
|
||||
<Flex direction="row" gap="2" alignItems="flex-end">
|
||||
{!isOwn && avatar}
|
||||
<Flex
|
||||
direction="column-reverse"
|
||||
gap="1"
|
||||
ml={isOwn ? "auto" : 0}
|
||||
mr={isOwn ? 0 : "auto"}
|
||||
maxW="2xl"
|
||||
alignItems={isOwn ? "flex-end" : "flex-start"}
|
||||
overflowX="hidden"
|
||||
overflowY="visible"
|
||||
>
|
||||
{events.map((event, i, arr) => (
|
||||
<MessageBubble
|
||||
key={event.id}
|
||||
event={event}
|
||||
showHeader={i === arr.length - 1}
|
||||
minW={{ base: 0, sm: "sm", md: "md" }}
|
||||
overflow="hidden"
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
{isOwn && avatar}
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -102,6 +102,9 @@ const ReactionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>((
|
||||
const expanded = useDisclosure({ defaultIsOpen: true });
|
||||
if (!pointer || (account?.pubkey && pointer.author !== account.pubkey)) return null;
|
||||
|
||||
const reactedEvent = useSingleEvent(pointer.id, pointer.relays);
|
||||
if (reactedEvent?.kind === Kind.EncryptedDirectMessage) return null;
|
||||
|
||||
return (
|
||||
<Flex gap="2" ref={ref}>
|
||||
<IconBox>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Button, Flex } from "@chakra-ui/react";
|
||||
import { memo, useRef } from "react";
|
||||
import { memo, useCallback, useRef } from "react";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
@ -14,6 +14,7 @@ import EmbeddedDM from "../../components/embed-event/event-types/embedded-dm";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { ChevronLeftIcon } from "../../components/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
|
||||
|
||||
const DirectMessage = memo(({ dm }: { dm: NostrEvent }) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
@ -30,6 +31,14 @@ export function DMFeedPage() {
|
||||
const navigate = useNavigate();
|
||||
const { listId, filter } = usePeopleListContext();
|
||||
|
||||
const clientMuteFilter = useClientSideMuteFilter();
|
||||
const eventFilter = useCallback(
|
||||
(e: NostrEvent) => {
|
||||
if (clientMuteFilter(e)) return false;
|
||||
return true;
|
||||
},
|
||||
[clientMuteFilter],
|
||||
);
|
||||
const readRelays = useReadRelayUrls();
|
||||
const timeline = useTimelineLoader(
|
||||
`${listId ?? "global"}-dm-feed`,
|
||||
@ -43,6 +52,7 @@ export function DMFeedPage() {
|
||||
{ "#p": filter.authors, kinds: [Kind.EncryptedDirectMessage] },
|
||||
]
|
||||
: { kinds: [Kind.EncryptedDirectMessage] },
|
||||
{ eventFilter },
|
||||
);
|
||||
|
||||
const dms = useSubject(timeline.timeline);
|
||||
|
Loading…
x
Reference in New Issue
Block a user