add thread buttons to DMs
This commit is contained in:
hzrd149 2023-12-08 10:46:56 -06:00
parent 98b4bef45e
commit ca4d6df8ef
11 changed files with 219 additions and 105 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Support NIP-31 on unknown event kinds

View File

@ -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<CardProps, "ch
const debugModal = useDisclosure();
const address = getSharableEventAddress(event);
const alt = event.tags.find((t) => 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 (
<>

View File

@ -234,3 +234,5 @@ export const DownloadIcon = Download01;
export const TranslateIcon = Translate01;
export const ChannelsIcon = MessageChatSquare;
export const ThreadIcon = MessageChatSquare;

View File

@ -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<ButtonProps, "children">) {
export default function AddReactionButton({
event,
portal = false,
...props
}: { event: NostrEvent; portal?: boolean } & Omit<ButtonProps, "children">) {
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 = (
<PopoverContent>
<PopoverArrow />
<PopoverBody>
<ReactionPicker onSelect={addReaction} />
</PopoverBody>
</PopoverContent>
);
return (
<Popover isLazy isOpen={popover} onOpen={setPopover.on} onClose={setPopover.off}>
<PopoverTrigger>
@ -60,12 +74,7 @@ export default function AddReactionButton({ event, ...props }: { event: NostrEve
{reactions?.length ?? 0}
</IconButton>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverBody>
<ReactionPicker onSelect={addReaction} />
</PopoverBody>
</PopoverContent>
{portal ? <Portal>{content}</Portal> : content}
</Popover>
);
}

View File

@ -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) => (
<MessageBlock key={group.id} messages={group.events} reverse />
))}
</>
);
});
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 }) {
<IconButton
aria-label="Threads"
title="Threads"
icon={<MessageChatSquare boxSize={5} />}
onClick={() => {
marker.set(0);
navigate(".", { state: { thread: "list" } });
}}
icon={<ThreadIcon boxSize={5} />}
onClick={openDrawerList}
/>
</ButtonGroup>
</Card>
<Flex h="0" flex={1} overflowX="hidden" overflowY="scroll" direction="column-reverse" gap="2" py="4" px="2">
<LightboxProvider>
{grouped.map((group) => (
<MessageBlock key={group.id} messages={group.events} reverse />
))}
</LightboxProvider>
<ChatLog timeline={timeline} />
<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}
/>
<ThreadDrawer isOpen onClose={closeDrawer} threadId={location.state.thread} pubkey={pubkey} />
)}
</IntersectionObserverProvider>
</ThreadsProvider>

View File

@ -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 && <ThreadButton thread={thread} />}
<MessageBubble message={message} {...props} />
{showThreadButton && !!thread && <ThreadButton thread={thread} />}
<MessageBubble message={message} showThreadButton={showThreadButton && !thread} {...props} />
</>
);
}
export default function MessageBlock({
function MessageBlock({
messages,
showThreadButtons = true,
showThreadButton = true,
reverse = false,
}: { messages: NostrEvent[]; showThreadButtons?: boolean; reverse?: boolean } & Omit<CardProps, "children">) {
}: { messages: NostrEvent[]; showThreadButton?: 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;
const MessageBubbleComponent = showThreadButton ? MessageBubbleWithThread : MessageBubble;
return (
<Flex direction="row" gap="2" alignItems="flex-end">
@ -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}
/>
))}
</Flex>
@ -59,3 +62,5 @@ export default function MessageBlock({
</Flex>
);
}
export default memo(MessageBlock);

View File

@ -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<IconButtonProps, "aria-label" | "icon">) {
const navigate = useNavigate();
const location = useLocation();
const onClick = () => {
navigate(`.`, { state: { ...location.state, thread: event.id } });
};
return (
<IconButton
icon={<ThreadIcon />}
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 (
<TrustProvider event={event}>
<Box whiteSpace="pre-wrap" {...props}>
{content}
{children}
</Box>
<LightboxProvider>
<Box whiteSpace="pre-wrap" {...props}>
{content}
{children}
</Box>
</LightboxProvider>
</TrustProvider>
);
}
export type MessageBubbleProps = { message: NostrEvent; showHeader?: boolean } & Omit<CardProps, "children">;
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<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(message));
const actions = (
<>
<NoteZapButton event={message} />
<AddReactionButton event={message} portal />
{showThreadButton && <IconThreadButton event={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" />
{actionPosition === "header" && (
<ButtonGroup size="xs" variant="ghost" ml="auto">
{actions}
</ButtonGroup>
)}
</CardHeader>
)}
<CardBody px="2" py="2">
@ -65,15 +125,10 @@ export default function MessageBubble({ message, showHeader = true, ...props }:
{(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" />
</>
)}
<ButtonGroup size="xs" variant="ghost" float="right">
{actionPosition === "inline" && actions}
<Timestamp timestamp={message.created_at} ml="2" />
</Flex>
</ButtonGroup>
)}
</MessageContent>
)}
@ -81,8 +136,10 @@ export default function MessageBubble({ message, showHeader = true, ...props }:
</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" />
<ButtonGroup size="xs" variant="ghost">
{actionPosition === "footer" ? actions : <AddReactionButton event={message} portal />}
<EventReactionButtons event={message} />
</ButtonGroup>
<Timestamp ml="auto" timestamp={message.created_at} />
</CardFooter>
)}

View File

@ -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();

View File

@ -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<TextProps, "children">) {
return (
@ -84,12 +82,10 @@ function ThreadMessages({ thread, pubkey }: { thread: Thread; pubkey: string })
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>
{thread.root && <MessageBlock messages={[thread.root]} showThreadButton={false} />}
{grouped.map((group) => (
<MessageBlock key={group.id} messages={group.events} showThreadButton={false} />
))}
</Flex>
<SendMessageForm flexShrink={0} pubkey={pubkey} rootId={thread.rootId} />
</>
@ -101,7 +97,7 @@ export default function ThreadDrawer({
pubkey,
...props
}: Omit<DrawerProps, "children"> & { 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 <ListThreads />;
if (!thread) return <Spinner />;
return <ThreadMessages thread={thread} pubkey={pubkey} />;
if (!thread) {
return <ThreadMessages thread={{ rootId: threadId, messages: [], root: getRoot(threadId) }} pubkey={pubkey} />;
} else return <ThreadMessages thread={thread} pubkey={pubkey} />;
};
return (

View File

@ -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<string, Thread>;
getRoot: (id: string) => NostrEvent | undefined;
};
const ThreadsContext = createContext<ThreadsContextType>({ threads: {} });
const ThreadsContext = createContext<ThreadsContextType>({
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<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 = useMemo(() => {
const grouped: Record<string, Thread> = {};
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<string, Thread> = {};
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 <ThreadsContext.Provider value={{ threads }}>{children}</ThreadsContext.Provider>;
const context = useMemo(() => ({ threads, getRoot }), [threads, getRoot]);
return <ThreadsContext.Provider value={context}>{children}</ThreadsContext.Provider>;
}

View File

@ -110,9 +110,11 @@ export default function LoginStartView() {
return (
<Flex direction="column" gap="2" flexShrink={0} alignItems="center">
<Button onClick={signinWithExtension} leftIcon={<Key01 boxSize={6} />} w="sm" colorScheme="primary">
Sign in with extension
</Button>
{window.nostr && (
<Button onClick={signinWithExtension} leftIcon={<Key01 boxSize={6} />} w="sm" colorScheme="primary">
Sign in with extension
</Button>
)}
<Button as={RouterLink} to="./nostr-connect" state={location.state} w="sm" colorScheme="blue">
Nostr Connect (NIP-46)
</Button>