From ca4d6df8ef8e7a3614955b9b53489b46b88af16c Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Fri, 8 Dec 2023 10:46:56 -0600 Subject: [PATCH] NIP-31 add thread buttons to DMs --- .changeset/tall-trains-kick.md | 5 + .../event-types/embedded-unknown.tsx | 21 +++- src/components/icons.tsx | 2 + .../note/components/add-reaction-button.tsx | 23 +++-- src/views/dms/chat.tsx | 71 ++++++++------ src/views/dms/components/message-block.tsx | 19 ++-- src/views/dms/components/message-bubble.tsx | 97 +++++++++++++++---- src/views/dms/components/thread-button.tsx | 6 +- src/views/dms/components/thread-drawer.tsx | 21 ++-- src/views/dms/components/thread-provider.tsx | 51 ++++++---- src/views/signin/start.tsx | 8 +- 11 files changed, 219 insertions(+), 105 deletions(-) create mode 100644 .changeset/tall-trains-kick.md diff --git a/.changeset/tall-trains-kick.md b/.changeset/tall-trains-kick.md new file mode 100644 index 000000000..456509d6d --- /dev/null +++ b/.changeset/tall-trains-kick.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Support NIP-31 on unknown event kinds diff --git a/src/components/embed-event/event-types/embedded-unknown.tsx b/src/components/embed-event/event-types/embedded-unknown.tsx index d115ca9f7..61d179b3f 100644 --- a/src/components/embed-event/event-types/embedded-unknown.tsx +++ b/src/components/embed-event/event-types/embedded-unknown.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { Box, Button, Card, CardBody, CardHeader, CardProps, Flex, Link, Text, useDisclosure } from "@chakra-ui/react"; import { getSharableEventAddress } from "../../../helpers/nip19"; @@ -7,9 +8,16 @@ import UserLink from "../../user-link"; import { truncatedId } from "../../../helpers/nostr/events"; import { buildAppSelectUrl } from "../../../helpers/nostr/apps"; import { UserDnsIdentityIcon } from "../../user-dns-identity-icon"; -import { useMemo } from "react"; -import { embedEmoji, embedNostrHashtags, embedNostrLinks, embedNostrMentions } from "../../embed-types"; -import { EmbedableContent } from "../../../helpers/embeds"; +import { + embedEmoji, + embedNostrHashtags, + embedNostrLinks, + embedNostrMentions, + renderGenericUrl, + renderImageUrl, + renderVideoUrl, +} from "../../embed-types"; +import { EmbedableContent, embedUrls } from "../../../helpers/embeds"; import Timestamp from "../../timestamp"; import { CodeIcon } from "../../icons"; import NoteDebugModal from "../../debug-modals/note-debug-modal"; @@ -18,15 +26,18 @@ export default function EmbeddedUnknown({ event, ...props }: Omit t[0] === "alt")?.[1]; const content = useMemo(() => { - let jsx: EmbedableContent = [event.content]; + let jsx: EmbedableContent = [alt || event.content]; jsx = embedNostrLinks(jsx); jsx = embedNostrMentions(jsx, event); jsx = embedNostrHashtags(jsx, event); jsx = embedEmoji(jsx, event); + jsx = embedUrls(jsx, [renderImageUrl, renderVideoUrl, renderGenericUrl]); + return jsx; - }, [event.content]); + }, [event.content, alt]); return ( <> diff --git a/src/components/icons.tsx b/src/components/icons.tsx index baace6a70..2dc14f50e 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -234,3 +234,5 @@ export const DownloadIcon = Download01; export const TranslateIcon = Translate01; export const ChannelsIcon = MessageChatSquare; + +export const ThreadIcon = MessageChatSquare; diff --git a/src/components/note/components/add-reaction-button.tsx b/src/components/note/components/add-reaction-button.tsx index 3d22da0d7..7c8b4ee2c 100644 --- a/src/components/note/components/add-reaction-button.tsx +++ b/src/components/note/components/add-reaction-button.tsx @@ -6,6 +6,7 @@ import { PopoverBody, PopoverContent, PopoverTrigger, + Portal, useBoolean, useToast, } from "@chakra-ui/react"; @@ -22,7 +23,11 @@ import { draftEventReaction } from "../../../helpers/nostr/reactions"; import { getEventUID } from "../../../helpers/nostr/events"; import { useState } from "react"; -export default function AddReactionButton({ event, ...props }: { event: NostrEvent } & Omit) { +export default function AddReactionButton({ + event, + portal = false, + ...props +}: { event: NostrEvent; portal?: boolean } & Omit) { const toast = useToast(); const { requestSignature } = useSigningContext(); const reactions = useEventReactions(getEventUID(event)) ?? []; @@ -47,6 +52,15 @@ export default function AddReactionButton({ event, ...props }: { event: NostrEve setLoading(false); }; + const content = ( + + + + + + + ); + return ( @@ -60,12 +74,7 @@ export default function AddReactionButton({ event, ...props }: { event: NostrEve {reactions?.length ?? 0} - - - - - - + {portal ? {content} : content} ); } diff --git a/src/views/dms/chat.tsx b/src/views/dms/chat.tsx index 37bcb262f..84e1af1c9 100644 --- a/src/views/dms/chat.tsx +++ b/src/views/dms/chat.tsx @@ -1,9 +1,9 @@ -import { useContext, useEffect, useState } from "react"; +import { memo, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { Button, ButtonGroup, Card, Flex, IconButton, useDisclosure } from "@chakra-ui/react"; import { Kind, nip19 } from "nostr-tools"; import { UNSAFE_DataRouterContext, useLocation, useNavigate, useParams } from "react-router-dom"; -import { ChevronLeftIcon } from "../../components/icons"; +import { ChevronLeftIcon, ThreadIcon } from "../../components/icons"; import UserAvatar from "../../components/user-avatar"; import UserLink from "../../components/user-link"; import { isHexKey } from "../../helpers/nip19"; @@ -16,19 +16,39 @@ import { useReadRelayUrls } from "../../hooks/use-client-relays"; import IntersectionObserverProvider from "../../providers/intersection-observer"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status"; -import { LightboxProvider } from "../../components/lightbox-provider"; import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon"; import { useDecryptionContext } from "../../providers/dycryption-provider"; 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"; +import TimelineLoader from "../../classes/timeline-loader"; + +/** This is broken out from DirectMessageChatPage for performance reasons. Don't use outside of file */ +const ChatLog = memo(({ timeline }: { timeline: TimelineLoader }) => { + const messages = useSubject(timeline.timeline); + const filteredMessages = useMemo( + () => messages.filter((e) => !e.tags.some((t) => t[0] === "e" && t[3] === "root")), + [messages.length], + ); + const grouped = useMemo(() => groupMessages(filteredMessages), [filteredMessages]); + + return ( + <> + {grouped.map((group) => ( + + ))} + + ); +}); function DirectMessageChatPage({ pubkey }: { pubkey: string }) { + const account = useCurrentAccount()!; const navigate = useNavigate(); const location = useLocation(); + const { getOrCreateContainer, addToQueue, startQueue } = useDecryptionContext(); + const { router } = useContext(UNSAFE_DataRouterContext)!; const marker = useRouterMarker(router); useEffect(() => { @@ -38,11 +58,19 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) { } }, [location]); - const account = useCurrentAccount()!; - const { getOrCreateContainer, addToQueue, startQueue } = useDecryptionContext(); + const openDrawerList = useCallback(() => { + marker.set(0); + navigate(".", { state: { thread: "list" } }); + }, [marker, navigate]); + + const closeDrawer = useCallback(() => { + if (marker.index.current !== null && marker.index.current > 0) { + navigate(-marker.index.current); + } else navigate(".", { state: { thread: undefined } }); + marker.reset(); + }, [marker, navigate]); const myInbox = useReadRelayUrls(); - const timeline = useTimelineLoader(`${pubkey}-${account.pubkey}-messages`, myInbox, [ { kinds: [Kind.EncryptedDirectMessage], @@ -56,12 +84,9 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) { }, ]); - const messages = useSubject(timeline.timeline).filter((e) => !e.tags.some((t) => t[0] === "e" && t[3] === "root")); - const grouped = groupMessages(messages); - const [loading, setLoading] = useState(false); const decryptAll = async () => { - const promises = messages + const promises = timeline.timeline.value .map((message) => { const container = getOrCreateContainer(pubkey, message.content); if (container.plaintext.value === undefined) return addToQueue(container); @@ -99,34 +124,18 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) { } - onClick={() => { - marker.set(0); - navigate(".", { state: { thread: "list" } }); - }} + icon={} + onClick={openDrawerList} /> - - {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/components/message-block.tsx b/src/views/dms/components/message-block.tsx index 35d3566a6..6a613de2d 100644 --- a/src/views/dms/components/message-block.tsx +++ b/src/views/dms/components/message-block.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { CardProps, Flex } from "@chakra-ui/react"; import useCurrentAccount from "../../../hooks/use-current-account"; @@ -7,30 +8,30 @@ import MessageBubble, { MessageBubbleProps } from "./message-bubble"; import { useThreadsContext } from "./thread-provider"; import ThreadButton from "./thread-button"; -function MessageBubbleWithThread({ message, ...props }: MessageBubbleProps) { +function MessageBubbleWithThread({ message, showThreadButton = true, ...props }: MessageBubbleProps) { const { threads } = useThreadsContext(); const thread = threads[message.id]; return ( <> - {thread && } - + {showThreadButton && !!thread && } + ); } -export default function MessageBlock({ +function MessageBlock({ messages, - showThreadButtons = true, + showThreadButton = true, reverse = false, -}: { messages: NostrEvent[]; showThreadButtons?: boolean; reverse?: boolean } & Omit) { +}: { messages: NostrEvent[]; showThreadButton?: 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; + const MessageBubbleComponent = showThreadButton ? MessageBubbleWithThread : MessageBubble; return ( @@ -51,7 +52,9 @@ export default function MessageBlock({ message={message} showHeader={reverse ? i === arr.length - 1 : i === 0} minW={{ base: 0, sm: "sm", md: "md" }} + maxW="full" overflow="hidden" + showThreadButton={showThreadButton} /> ))} @@ -59,3 +62,5 @@ export default function MessageBlock({ ); } + +export default memo(MessageBlock); diff --git a/src/views/dms/components/message-bubble.tsx b/src/views/dms/components/message-bubble.tsx index 1c8dfeb7f..1ba5cb454 100644 --- a/src/views/dms/components/message-bubble.tsx +++ b/src/views/dms/components/message-bubble.tsx @@ -1,5 +1,16 @@ import { useRef } from "react"; -import { Box, BoxProps, Card, CardBody, CardFooter, CardHeader, CardProps, Flex } from "@chakra-ui/react"; +import { + Box, + BoxProps, + ButtonGroup, + Card, + CardBody, + CardFooter, + CardHeader, + CardProps, + IconButton, + IconButtonProps, +} from "@chakra-ui/react"; import { NostrEvent } from "../../../types/nostr-event"; import DecryptPlaceholder from "./decrypt-placeholder"; @@ -20,7 +31,32 @@ 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"; +import { useLocation, useNavigate } from "react-router-dom"; +import { ThreadIcon } from "../../../components/icons"; +import EventReactionButtons from "../../../components/event-reactions/event-reactions"; +import { LightboxProvider } from "../../../components/lightbox-provider"; + +export function IconThreadButton({ + event, + ...props +}: { event: NostrEvent } & Omit) { + const navigate = useNavigate(); + const location = useLocation(); + + const onClick = () => { + navigate(`.`, { state: { ...location.state, thread: event.id } }); + }; + + return ( + } + onClick={onClick} + aria-label="Reply in thread" + title="Reply in thread" + {...props} + /> + ); +} export function MessageContent({ event, text, children, ...props }: { event: NostrEvent; text: string } & BoxProps) { let content: EmbedableContent = [text]; @@ -33,31 +69,55 @@ export function MessageContent({ event, text, children, ...props }: { event: Nos return ( - - {content} - {children} - + + + {content} + {children} + + ); } -export type MessageBubbleProps = { message: NostrEvent; showHeader?: boolean } & Omit; +export type MessageBubbleProps = { message: NostrEvent; showHeader?: boolean; showThreadButton?: boolean } & Omit< + CardProps, + "children" +>; -export default function MessageBubble({ message, showHeader = true, ...props }: MessageBubbleProps) { +export default function MessageBubble({ + message, + showHeader = true, + showThreadButton = true, + ...props +}: MessageBubbleProps) { const reactions = useEventReactions(message.id) ?? []; const hasReactions = reactions.length > 0; + let actionPosition = showHeader ? "header" : "inline"; + if (hasReactions && actionPosition === "inline") actionPosition = "footer"; + const ref = useRef(null); useRegisterIntersectionEntity(ref, getEventUID(message)); + const actions = ( + <> + + + {showThreadButton && } + + ); + return ( {showHeader && ( - - + {actionPosition === "header" && ( + + {actions} + + )} )} @@ -65,15 +125,10 @@ export default function MessageBubble({ message, showHeader = true, ...props }: {(plaintext) => ( {!hasReactions && ( - - {!showHeader && ( - <> - - - - )} + + {actionPosition === "inline" && actions} - + )} )} @@ -81,8 +136,10 @@ export default function MessageBubble({ message, showHeader = true, ...props }: {hasReactions && ( - - + + {actionPosition === "footer" ? actions : } + + )} diff --git a/src/views/dms/components/thread-button.tsx b/src/views/dms/components/thread-button.tsx index ece941713..ad18e3a2d 100644 --- a/src/views/dms/components/thread-button.tsx +++ b/src/views/dms/components/thread-button.tsx @@ -1,9 +1,11 @@ -import { Button } from "@chakra-ui/react"; +import { Button, IconButton } 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"; +import { ChevronRightIcon, ThreadIcon } from "../../../components/icons"; +import { IconButtonProps } from "yet-another-react-lightbox"; +import { NostrEvent } from "../../../types/nostr-event"; export default function ThreadButton({ thread }: { thread: Thread }) { const navigate = useNavigate(); diff --git a/src/views/dms/components/thread-drawer.tsx b/src/views/dms/components/thread-drawer.tsx index 5033c8107..968c2a118 100644 --- a/src/views/dms/components/thread-drawer.tsx +++ b/src/views/dms/components/thread-drawer.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Button, Card, @@ -25,12 +26,9 @@ 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 ( @@ -84,12 +82,10 @@ function ThreadMessages({ thread, pubkey }: { thread: Thread; pubkey: string }) return ( <> - {thread.root && } - - {grouped.map((group) => ( - - ))} - + {thread.root && } + {grouped.map((group) => ( + + ))} @@ -101,7 +97,7 @@ export default function ThreadDrawer({ pubkey, ...props }: Omit & { threadId: string; pubkey: string }) { - const { threads } = useThreadsContext(); + const { threads, getRoot } = useThreadsContext(); const { startQueue, getOrCreateContainer, addToQueue } = useDecryptionContext(); const thread = threads[threadId]; @@ -129,8 +125,9 @@ export default function ThreadDrawer({ const renderContent = () => { if (threadId === "list") return ; - if (!thread) return ; - return ; + if (!thread) { + return ; + } else return ; }; return ( diff --git a/src/views/dms/components/thread-provider.tsx b/src/views/dms/components/thread-provider.tsx index 50387dd92..e720295ec 100644 --- a/src/views/dms/components/thread-provider.tsx +++ b/src/views/dms/components/thread-provider.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren, createContext, useContext } from "react"; +import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from "react"; import TimelineLoader from "../../../classes/timeline-loader"; import { NostrEvent } from "../../../types/nostr-event"; @@ -11,8 +11,14 @@ export type Thread = { }; type ThreadsContextType = { threads: Record; + getRoot: (id: string) => NostrEvent | undefined; }; -const ThreadsContext = createContext({ threads: {} }); +const ThreadsContext = createContext({ + threads: {}, + getRoot: (id: string) => { + return undefined; + }, +}); export function useThreadsContext() { return useContext(ThreadsContext); @@ -21,23 +27,32 @@ export function useThreadsContext() { 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 = useMemo(() => { + const grouped: Record = {}; + for (const message of messages) { + const rootId = message.tags.find((t) => t[0] === "e" && t[3] === "root")?.[1]; + if (rootId) { + if (!grouped[rootId]) { + grouped[rootId] = { + messages: [], + rootId, + root: timeline.events.getEvent(rootId), + }; + } + grouped[rootId].messages.push(message); + } } - } + return grouped; + }, [messages.length, timeline.events]); - const threads: Record = {}; - for (const [rootId, messages] of Object.entries(groupedByRoot)) { - threads[rootId] = { - messages, - rootId, - root: timeline.events.getEvent(rootId), - }; - } + const getRoot = useCallback( + (id: string) => { + return timeline.events.getEvent(id); + }, + [timeline.events], + ); - return {children}; + const context = useMemo(() => ({ threads, getRoot }), [threads, getRoot]); + + return {children}; } diff --git a/src/views/signin/start.tsx b/src/views/signin/start.tsx index fff71a6c1..6c3e2a375 100644 --- a/src/views/signin/start.tsx +++ b/src/views/signin/start.tsx @@ -110,9 +110,11 @@ export default function LoginStartView() { return ( - + {window.nostr && ( + + )}