diff --git a/.changeset/eleven-cows-fail.md b/.changeset/eleven-cows-fail.md new file mode 100644 index 000000000..13e0050c1 --- /dev/null +++ b/.changeset/eleven-cows-fail.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add support for threads in DMs diff --git a/src/components/embed-event/event-types/embedded-dm.tsx b/src/components/embed-event/event-types/embedded-dm.tsx index 66aea9259..7c85f901a 100644 --- a/src/components/embed-event/event-types/embedded-dm.tsx +++ b/src/components/embed-event/event-types/embedded-dm.tsx @@ -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 & { 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 - - - {(text) => } - - + {(sender === account?.pubkey || receiver === account?.pubkey) && ( + + + {(plaintext) => } + + + )} ); diff --git a/src/components/layout/mobile-side-drawer.tsx b/src/components/layout/mobile-side-drawer.tsx index 9cde0b524..6bd7d40d8 100644 --- a/src/components/layout/mobile-side-drawer.tsx +++ b/src/components/layout/mobile-side-drawer.tsx @@ -5,7 +5,6 @@ import { Drawer, DrawerBody, DrawerContent, - DrawerHeader, DrawerOverlay, DrawerProps, Flex, diff --git a/src/helpers/nostr/dms.ts b/src/helpers/nostr/dms.ts new file mode 100644 index 000000000..4a4c45f3f --- /dev/null +++ b/src/helpers/nostr/dms.ts @@ -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; +} diff --git a/src/providers/drawer-sub-view-provider.tsx b/src/providers/drawer-sub-view-provider.tsx index d6793923f..1d0111c7c 100644 --- a/src/providers/drawer-sub-view-provider.tsx +++ b/src/providers/drawer-sub-view-provider.tsx @@ -112,6 +112,22 @@ export function useNavigateInDrawer() { const log = logger.extend("DrawerRouter"); +export function useRouterMarker(router: Router) { + const index = useRef(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(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( diff --git a/src/services/direct-messages.ts b/src/services/direct-messages.ts index eeac7b7c2..8ab732738 100644 --- a/src/services/direct-messages.ts +++ b/src/services/direct-messages.ts @@ -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]; } diff --git a/src/views/dms/chat.tsx b/src/views/dms/chat.tsx index 2e87d8939..37bcb262f 100644 --- a/src/views/dms/chat.tsx +++ b/src/views/dms/chat.tsx @@ -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 ( - + @@ -96,19 +92,44 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) { - + + + } + onClick={() => { + marker.set(0); + navigate(".", { state: { thread: "list" } }); + }} + /> + - {grouped.map((group) => ( - - ))} + + {grouped.map((group) => ( + + ))} + + {location.state?.thread && ( + { + if (marker.index.current !== null && marker.index.current > 0) { + navigate(-marker.index.current); + } else navigate(".", { state: { thread: undefined } }); + }} + threadId={location.state.thread} + pubkey={pubkey} + /> + )} - + ); } diff --git a/src/views/dms/decrypt-placeholder.tsx b/src/views/dms/components/decrypt-placeholder.tsx similarity index 65% rename from src/views/dms/decrypt-placeholder.tsx rename to src/views/dms/components/decrypt-placeholder.tsx index f5043f71e..1f9880e08 100644 --- a/src/views/dms/decrypt-placeholder.tsx +++ b/src/views/dms/components/decrypt-placeholder.tsx @@ -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): 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); diff --git a/src/views/dms/components/message-block.tsx b/src/views/dms/components/message-block.tsx new file mode 100644 index 000000000..35d3566a6 --- /dev/null +++ b/src/views/dms/components/message-block.tsx @@ -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 && } + + + ); +} + +export default function MessageBlock({ + messages, + showThreadButtons = true, + reverse = false, +}: { messages: NostrEvent[]; showThreadButtons?: boolean; reverse?: boolean } & Omit) { + const lastEvent = messages[messages.length - 1]; + const account = useCurrentAccount()!; + const isOwn = account.pubkey === lastEvent.pubkey; + + const avatar = ; + + const MessageBubbleComponent = showThreadButtons ? MessageBubbleWithThread : MessageBubble; + + return ( + + {!isOwn && avatar} + + {messages.map((message, i, arr) => ( + + ))} + + {isOwn && avatar} + + ); +} diff --git a/src/views/dms/components/message-bubble.tsx b/src/views/dms/components/message-bubble.tsx new file mode 100644 index 000000000..1c8dfeb7f --- /dev/null +++ b/src/views/dms/components/message-bubble.tsx @@ -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 ( + + + {content} + {children} + + + ); +} + +export type MessageBubbleProps = { message: NostrEvent; showHeader?: boolean } & Omit; + +export default function MessageBubble({ message, showHeader = true, ...props }: MessageBubbleProps) { + const reactions = useEventReactions(message.id) ?? []; + const hasReactions = reactions.length > 0; + + const ref = useRef(null); + useRegisterIntersectionEntity(ref, getEventUID(message)); + + return ( + + {showHeader && ( + + + + + + + )} + + + {(plaintext) => ( + + {!hasReactions && ( + + {!showHeader && ( + <> + + + + )} + + + )} + + )} + + + {hasReactions && ( + + + + + + )} + + ); +} diff --git a/src/views/dms/send-message-form.tsx b/src/views/dms/components/send-message-form.tsx similarity index 74% rename from src/views/dms/send-message-form.tsx rename to src/views/dms/components/send-message-form.tsx index 9651bafb5..1762d3d34 100644 --- a/src/views/dms/send-message-form.tsx +++ b/src/views/dms/components/send-message-form.tsx @@ -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) { +export default function SendMessageForm({ + pubkey, + rootId, + ...props +}: { pubkey: string; rootId?: string } & Omit) { 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(); diff --git a/src/views/dms/components/thread-button.tsx b/src/views/dms/components/thread-button.tsx new file mode 100644 index 000000000..ece941713 --- /dev/null +++ b/src/views/dms/components/thread-button.tsx @@ -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 ( + + ); +} diff --git a/src/views/dms/components/thread-drawer.tsx b/src/views/dms/components/thread-drawer.tsx new file mode 100644 index 000000000..5033c8107 --- /dev/null +++ b/src/views/dms/components/thread-drawer.tsx @@ -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) { + return ( + + {(plaintext) => {plaintext}} + + ); +} + +function ThreadCard({ thread }: { thread: Thread }) { + const latestMessage = thread.messages[thread.messages.length - 1]; + + return ( + + {thread.root && ( + + + + + + )} + + {thread.root ? : } + + + + + + ); +} + +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) => ( + + ))} + + ); +} + +function ThreadMessages({ thread, pubkey }: { thread: Thread; pubkey: string }) { + const grouped = groupMessages(thread.messages, 5, true); + + return ( + <> + + {thread.root && } + + {grouped.map((group) => ( + + ))} + + + + + ); +} + +export default function ThreadDrawer({ + threadId, + pubkey, + ...props +}: Omit & { 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 ; + + 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 ; + if (!thread) return ; + return ; + }; + + return ( + + + + + + Threads + + + + + {renderContent()} + + + + ); +} diff --git a/src/views/dms/components/thread-provider.tsx b/src/views/dms/components/thread-provider.tsx new file mode 100644 index 000000000..50387dd92 --- /dev/null +++ b/src/views/dms/components/thread-provider.tsx @@ -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; +}; +const ThreadsContext = createContext({ threads: {} }); + +export function useThreadsContext() { + return useContext(ThreadsContext); +} + +export default function ThreadsProvider({ timeline, children }: { timeline: TimelineLoader } & PropsWithChildren) { + const messages = useSubject(timeline.timeline); + + const groupedByRoot: Record = {}; + 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 = {}; + for (const [rootId, messages] of Object.entries(groupedByRoot)) { + threads[rootId] = { + messages, + rootId, + root: timeline.events.getEvent(rootId), + }; + } + + return {children}; +} diff --git a/src/views/dms/index.tsx b/src/views/dms/index.tsx index a1391fbc4..d91270fb2 100644 --- a/src/views/dms/index.tsx +++ b/src/views/dms/index.tsx @@ -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 ( @@ -43,7 +27,7 @@ function ContactCard({ pubkey }: { pubkey: string }) { {getUserDisplayName(metadata, pubkey)} - {messages[0] && } + {latestMessage && } diff --git a/src/views/dms/message-block.tsx b/src/views/dms/message-block.tsx deleted file mode 100644 index 0ca75a12e..000000000 --- a/src/views/dms/message-block.tsx +++ /dev/null @@ -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 ( - - - {content} - {children} - - - ); -} - -function MessageBubble({ - event, - showHeader = true, - ...props -}: { event: NostrEvent; showHeader?: boolean } & Omit) { - const account = useCurrentAccount()!; - const isOwn = account.pubkey === event.pubkey; - const reactions = useEventReactions(event.id) ?? []; - const hasReactions = reactions.length > 0; - - const ref = useRef(null); - useRegisterIntersectionEntity(ref, getEventUID(event)); - - return ( - - {showHeader && ( - - - - - - - )} - - - {(text) => ( - - {!hasReactions && ( - - {!showHeader && ( - <> - - - - )} - - - )} - - )} - - - {hasReactions && ( - - - - - - )} - - ); -} - -export default function MessageBlock({ events }: { events: NostrEvent[] } & Omit) { - const lastEvent = events[events.length - 1]; - const account = useCurrentAccount()!; - const isOwn = account.pubkey === lastEvent.pubkey; - - const avatar = ; - - return ( - - {!isOwn && avatar} - - {events.map((event, i, arr) => ( - - ))} - - {isOwn && avatar} - - ); -} diff --git a/src/views/notifications/notification-item.tsx b/src/views/notifications/notification-item.tsx index 38ed182fc..544d3ae6f 100644 --- a/src/views/notifications/notification-item.tsx +++ b/src/views/notifications/notification-item.tsx @@ -102,6 +102,9 @@ const ReactionNotification = forwardRef(( 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 ( diff --git a/src/views/tools/dm-feed.tsx b/src/views/tools/dm-feed.tsx index 24b7f2a46..3f2babd11 100644 --- a/src/views/tools/dm-feed.tsx +++ b/src/views/tools/dm-feed.tsx @@ -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(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);