diff --git a/apps/docs/content/components/(chatbot)/message.mdx b/apps/docs/content/components/(chatbot)/message.mdx index ed189847..280173e1 100644 --- a/apps/docs/content/components/(chatbot)/message.mdx +++ b/apps/docs/content/components/(chatbot)/message.mdx @@ -30,6 +30,7 @@ This is **required** for the MessageResponse component to work properly. Without - **Response branching** with navigation controls to switch between multiple AI response versions - **Markdown rendering** with GFM support (tables, task lists, strikethrough), math equations, and smart streaming - **Action buttons** for common operations (retry, like, dislike, copy, share) with tooltips and state management +- **Replies and quotes** for referencing a complete message or selected text, with a removable quote preview - **File attachments** display with support for images and generic files with preview and remove functionality - Code blocks with syntax highlighting and copy-to-clipboard functionality - Keyboard accessible with proper ARIA labels @@ -158,6 +159,76 @@ const ActionsDemo = () => { export default ActionsDemo; ``` +## Quoting and replying + +Use `MessageReply` inside `MessageActions` to reply to a message. Pass the original +message as `text` and, optionally, a ref to `MessageContent` as `selectionRef`. +When text is selected entirely within that content, `onReply` receives the selected +plain text. Otherwise, it receives the original `text`, preserving its Markdown. + + + +The example keeps the quote separate from the draft and renders it with +`MessageQuote` inside `PromptInputHeader`. The Reply action remains available to +keyboard and touch users without requiring text selection. Selecting a new quote +replaces the previous one without changing the draft. Removing a quote returns +focus to the input. + +`MessageQuote` can also display a sent message's reference: omit `onRemove` for a +read-only quote. Quote text is rendered as text, without interpreting Markdown or +HTML. Long quotes scroll within the preview and can be focused to scroll with the +keyboard. Labels can be localized; pass children to `MessageReply` to replace its +default icon. + +### State and keyboard behavior + +Reply state belongs to your application; no provider or changes to the +`PromptInputMessage` shape are required. Store the message ID alongside the quoted +text if you need to link back to its source. Use a snapshot of the quote so that +later edits or streaming updates do not change what the user selected. + +The example handles Escape on the composer, not globally. It respects prevented +events and IME composition, so dismissing a nested menu or composing text does not +accidentally remove a quote. Escape and the remove button preserve the draft. +The textarea also guards the IME confirmation Enter event so it does not send the +reply while the input method is finishing a character. + +### Sending quoted context with the AI SDK + +The preview above adds replies locally. In your application, explicitly include +the quote when calling `sendMessage`; displaying `MessageQuote` alone does not +send context to the model. One option is to prepend a Markdown blockquote to the +outgoing text: + +```tsx +const handleSubmit = ({ text, files }: PromptInputMessage) => { + if (!text.trim()) { + return; + } + + const quotedText = quote?.text + .split("\n") + .map((line) => `> ${line}`) + .join("\n"); + + return sendMessage({ + text: quotedText ? `${quotedText}\n\n${text}` : text, + files, + }); +}; +``` + +Keep a snapshot of the submitted quote and decide when to clear it in your +application's send lifecycle. With `useChat`, use `onError` and the `isError`, +`isAbort`, and `isDisconnect` flags passed to `onFinish`: `sendMessage` can resolve +after a transport error, so awaiting it alone does not confirm success. Use a +controlled `PromptInputTextarea` if you need to preserve or restore the draft on +failure, and avoid clearing a newer draft or quote when an earlier request ends. + +If your application stores quotes as custom message data instead, ensure your +server includes that data when constructing model messages. Treat quoted content +as user-provided context, just like the rest of the message. + ## Props ### `` @@ -181,8 +252,9 @@ export default ActionsDemo; ", + description: + "Any other props, including ref, are spread to the content div.", + type: 'React.ComponentProps<"div">', }, }} /> @@ -278,6 +350,71 @@ export default ActionsDemo; }} /> +### `` + + void", + required: true, + }, + selectionRef: { + description: + "Ref to the message content. Only a selection entirely inside this element is used.", + type: "React.RefObject", + }, + label: { + description: "Accessible label for the reply action.", + type: "string", + default: '"Reply"', + }, + "...props": { + description: + "Other MessageAction props, including disabled, tooltip, children, and event handlers. Prevent default in onClick to cancel a reply.", + type: "MessageActionProps", + }, + }} +/> + +### `` + + void", + }, + removeLabel: { + description: "Accessible label for the remove button.", + type: "string", + default: '"Remove quote"', + }, + "...props": { + description: "Any other props are spread to the root div.", + type: 'React.ComponentProps<"div">', + }, + }} +/> + ### `` ", }, }} diff --git a/packages/elements/__tests__/message-reply-example.test.tsx b/packages/elements/__tests__/message-reply-example.test.tsx new file mode 100644 index 00000000..3902f3e2 --- /dev/null +++ b/packages/elements/__tests__/message-reply-example.test.tsx @@ -0,0 +1,179 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; + +import Example from "../../examples/src/message-reply"; + +describe("message reply example", () => { + it("preserves the draft and focuses the input when adding a quote", async () => { + render(); + const input = screen.getByRole("textbox", { name: "Your reply" }); + await userEvent.type(input, "My unfinished thought"); + await userEvent.click( + screen.getByRole("button", { name: "Reply to assistant" }) + ); + expect(input).toHaveFocus(); + expect(input).toHaveValue("My unfinished thought"); + expect( + screen.getByRole("group", { name: "Replying to assistant" }) + ).toBeInTheDocument(); + }); + + it("replaces a quote without replacing the draft", async () => { + render(); + const input = screen.getByRole("textbox", { name: "Your reply" }); + await userEvent.type(input, "My unfinished thought"); + await userEvent.click( + screen.getByRole("button", { name: "Reply to assistant" }) + ); + await userEvent.click( + screen.getByRole("button", { name: "Reply to user" }) + ); + expect( + screen.queryByRole("group", { name: "Replying to assistant" }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "Replying to user" }) + ).toHaveTextContent("What makes a good loading state?"); + expect(input).toHaveValue("My unfinished thought"); + }); + + it("preserves the draft and restores focus when removing a quote", async () => { + render(); + const input = screen.getByRole("textbox", { name: "Your reply" }); + await userEvent.type(input, "My unfinished thought"); + await userEvent.click( + screen.getByRole("button", { name: "Reply to assistant" }) + ); + await userEvent.click(screen.getByRole("button", { name: "Remove quote" })); + expect(input).toHaveFocus(); + expect(input).toHaveValue("My unfinished thought"); + expect( + screen.queryByRole("button", { name: "Remove quote" }) + ).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Quote removed."); + }); + + it("removes the quote with Escape in the composer without clearing the draft", async () => { + render(); + await userEvent.click( + screen.getByRole("button", { name: "Reply to assistant" }) + ); + const input = screen.getByRole("textbox", { name: "Your reply" }); + await userEvent.type(input, "Keep this draft"); + await userEvent.keyboard("{Escape}"); + expect( + screen.queryByRole("button", { name: "Remove quote" }) + ).not.toBeInTheDocument(); + expect(input).toHaveValue("Keep this draft"); + expect(input).toHaveFocus(); + }); + + it("does not consume Escape outside the composer", async () => { + render(); + const reply = screen.getByRole("button", { name: "Reply to assistant" }); + await userEvent.click(reply); + reply.focus(); + await userEvent.keyboard("{Escape}"); + expect( + screen.getByRole("button", { name: "Remove quote" }) + ).toBeInTheDocument(); + }); + + it("keeps the quote when Escape belongs to IME composition", async () => { + render(); + await userEvent.click( + screen.getByRole("button", { name: "Reply to assistant" }) + ); + const input = screen.getByRole("textbox", { name: "Your reply" }); + fireEvent.compositionStart(input); + fireEvent.keyDown(input, { key: "Escape" }); + expect( + screen.getByRole("button", { name: "Remove quote" }) + ).toBeInTheDocument(); + fireEvent.compositionEnd(input); + fireEvent.keyDown(input, { key: "Escape", keyCode: 229 }); + expect( + screen.getByRole("button", { name: "Remove quote" }) + ).toBeInTheDocument(); + fireEvent.keyDown(input, { isComposing: true, key: "Escape" }); + expect( + screen.getByRole("button", { name: "Remove quote" }) + ).toBeInTheDocument(); + fireEvent.keyDown(input, { key: "Escape" }); + expect( + screen.queryByRole("button", { name: "Remove quote" }) + ).not.toBeInTheDocument(); + }); + + it("respects Escape already handled by a nested control", async () => { + render(); + await userEvent.click( + screen.getByRole("button", { name: "Reply to assistant" }) + ); + const input = screen.getByRole("textbox", { name: "Your reply" }); + input.addEventListener("keydown", (event) => event.preventDefault(), { + once: true, + }); + fireEvent.keyDown(input, { key: "Escape" }); + expect( + screen.getByRole("button", { name: "Remove quote" }) + ).toBeInTheDocument(); + }); + + it("sends the quote with the reply and clears the composer", async () => { + render(); + await userEvent.click( + screen.getByRole("button", { name: "Reply to user" }) + ); + const input = screen.getByRole("textbox", { name: "Your reply" }); + await userEvent.type(input, "Explain this in more detail"); + await userEvent.keyboard("{Enter}"); + await expect( + screen.findByText("Explain this in more detail") + ).resolves.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Remove quote" }) + ).not.toBeInTheDocument(); + expect(input).toHaveValue(""); + const quote = screen.getByRole("group", { name: "Replying to user" }); + expect(quote).toHaveTextContent("What makes a good loading state?"); + expect(screen.getByRole("button", { name: "Send reply" })).toBeDisabled(); + }); + + it("supports multiline drafts without sending on Shift+Enter", async () => { + render(); + const input = screen.getByRole("textbox", { name: "Your reply" }); + await userEvent.type(input, "First line"); + await userEvent.keyboard("{Shift>}{Enter}{/Shift}"); + await userEvent.type(input, "Second line"); + expect(input).toHaveValue("First line\nSecond line"); + expect( + screen.getAllByRole("button", { name: "Reply to user" }) + ).toHaveLength(1); + }); + + it("does not send on the IME confirmation Enter after composition ends", async () => { + render(); + await userEvent.click( + screen.getByRole("button", { name: "Reply to assistant" }) + ); + const input = screen.getByRole("textbox", { name: "Your reply" }); + await userEvent.type(input, "입력 확정"); + await act(() => { + fireEvent.compositionStart(input); + }); + await act(() => { + fireEvent.compositionEnd(input); + }); + await act(() => { + fireEvent.keyDown(input, { key: "Enter", keyCode: 229 }); + }); + expect(input).toHaveValue("입력 확정"); + expect( + screen.getByRole("button", { name: "Remove quote" }) + ).toBeInTheDocument(); + expect( + screen.getAllByRole("button", { name: "Reply to user" }) + ).toHaveLength(1); + }); +}); diff --git a/packages/elements/__tests__/message-reply.test.tsx b/packages/elements/__tests__/message-reply.test.tsx new file mode 100644 index 00000000..3172e4bb --- /dev/null +++ b/packages/elements/__tests__/message-reply.test.tsx @@ -0,0 +1,218 @@ +import type { MouseEvent } from "react"; + +import { fireEvent, render, screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { createRef } from "react"; + +import { MessageContent, MessageQuote, MessageReply } from "../src/message"; + +const selectContents = (element: Node) => { + const range = document.createRange(); + range.selectNodeContents(element); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +}; + +const preventReply = (event: MouseEvent) => + event.preventDefault(); + +const renderReply = (onReply: (text: string) => void) => { + document.getSelection()?.removeAllRanges(); + const ref = createRef(); + render( + <> +

Another message

+ +

+ Before selected words after. +

+
+ + + ); + return screen.getByRole("button", { name: "Reply" }); +}; + +describe("messageReply", () => { + it("replies with the original message when nothing is selected", async () => { + const onReply = vi.fn(); + renderReply(onReply); + await userEvent.click(screen.getByRole("button", { name: "Reply" })); + expect(onReply).toHaveBeenCalledExactlyOnceWith( + "Before **selected words** after." + ); + }); + + it.each(["{Enter}", " "])( + "supports keyboard activation with %s", + async (key) => { + const onReply = vi.fn(); + const button = renderReply(onReply); + button.focus(); + await userEvent.keyboard(key); + expect(onReply).toHaveBeenCalledExactlyOnceWith( + "Before **selected words** after." + ); + } + ); + + it("quotes only selected text within the referenced content", async () => { + const onReply = vi.fn(); + const button = renderReply(onReply); + selectContents(screen.getByText("selected words")); + await userEvent.click(button); + expect(onReply).toHaveBeenCalledExactlyOnceWith("selected words"); + }); + + it("captures a selection before pointer focus collapses it", () => { + const onReply = vi.fn(); + const button = renderReply(onReply); + selectContents(screen.getByText("selected words")); + fireEvent.pointerDown(button, { button: 0 }); + document.getSelection()?.removeAllRanges(); + fireEvent.click(button, { detail: 1 }); + expect(onReply).toHaveBeenCalledExactlyOnceWith("selected words"); + }); + + it("does not reuse a cancelled pointer selection", () => { + const onReply = vi.fn(); + const button = renderReply(onReply); + selectContents(screen.getByText("selected words")); + fireEvent.pointerDown(button, { button: 0 }); + fireEvent.pointerCancel(button); + document.getSelection()?.removeAllRanges(); + fireEvent.click(button, { detail: 1 }); + expect(onReply).toHaveBeenCalledExactlyOnceWith( + "Before **selected words** after." + ); + }); + + it("does not reuse a pointer selection for a later keyboard activation", () => { + const onReply = vi.fn(); + const button = renderReply(onReply); + selectContents(screen.getByText("selected words")); + fireEvent.pointerDown(button, { button: 0 }); + document.getSelection()?.removeAllRanges(); + fireEvent.click(button, { detail: 0 }); + expect(onReply).toHaveBeenCalledExactlyOnceWith( + "Before **selected words** after." + ); + }); + + it("ignores selections from another message", async () => { + const onReply = vi.fn(); + const button = renderReply(onReply); + selectContents(screen.getByText("Another message")); + await userEvent.click(button); + expect(onReply).toHaveBeenCalledExactlyOnceWith( + "Before **selected words** after." + ); + }); + + it("ignores selections crossing message boundaries", async () => { + const onReply = vi.fn(); + const button = renderReply(onReply); + const range = document.createRange(); + range.setStartBefore(screen.getByText("Another message")); + range.setEndAfter(screen.getByText("selected words")); + document.getSelection()?.addRange(range); + await userEvent.click(button); + expect(onReply).toHaveBeenCalledExactlyOnceWith( + "Before **selected words** after." + ); + }); + + it("preserves Unicode and whitespace in a quote", async () => { + const onReply = vi.fn(); + const ref = createRef(); + render( + <> + +
{"  안녕하세요 👋\n世界  "}
+
+ + + ); + selectContents(screen.getByText("안녕하세요 👋 世界")); + await userEvent.click(screen.getByRole("button", { name: "Reply" })); + expect(onReply).toHaveBeenCalledExactlyOnceWith(" 안녕하세요 👋\n世界 "); + }); + + it("allows the consumer to prevent a reply", async () => { + const onReply = vi.fn(); + render( + + ); + await userEvent.click(screen.getByRole("button", { name: "Reply" })); + expect(onReply).not.toHaveBeenCalled(); + }); + + it("does not reply when disabled", async () => { + const onReply = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Reply" })); + expect(onReply).not.toHaveBeenCalled(); + }); + + it("does not submit its enclosing form", async () => { + const onSubmit = vi.fn((event) => event.preventDefault()); + render( +
+ + + ); + await userEvent.click(screen.getByRole("button", { name: "Reply" })); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("does not create an empty quote", async () => { + const onReply = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Reply" })); + expect(onReply).not.toHaveBeenCalled(); + }); +}); + +describe("messageQuote", () => { + it("renders text literally and supports a read-only quote", () => { + const { container } = render( + + {" **text**"} + + ); + expect( + screen.getByRole("group", { name: "Replying to assistant" }) + ).toBeInTheDocument(); + expect(container.querySelector("blockquote")).toHaveTextContent( + " **text**" + ); + expect(container.querySelector("img")).toBeNull(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("supports a localized remove action without submitting a form", async () => { + const onRemove = vi.fn(); + const onSubmit = vi.fn((event) => event.preventDefault()); + render( +
+ + Quoted text + +
+ ); + const button = screen.getByRole("button", { name: "인용 삭제" }); + button.focus(); + await userEvent.keyboard("{Enter}"); + expect(onRemove).toHaveBeenCalledOnce(); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/elements/src/message.tsx b/packages/elements/src/message.tsx index c04a3993..0ad53c55 100644 --- a/packages/elements/src/message.tsx +++ b/packages/elements/src/message.tsx @@ -1,5 +1,15 @@ "use client"; +import type { UIMessage } from "ai"; +import type { + ComponentProps, + HTMLAttributes, + MouseEventHandler, + PointerEventHandler, + ReactElement, + RefObject, +} from "react"; + import { Button } from "@repo/shadcn-ui/components/ui/button"; import { ButtonGroup, @@ -16,9 +26,12 @@ import { cjk } from "@streamdown/cjk"; import { code } from "@streamdown/code"; import { math } from "@streamdown/math"; import { mermaid } from "@streamdown/mermaid"; -import type { UIMessage } from "ai"; -import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; +import { + ChevronLeftIcon, + ChevronRightIcon, + ReplyIcon, + XIcon, +} from "lucide-react"; import { createContext, memo, @@ -26,6 +39,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import { Streamdown } from "streamdown"; @@ -45,7 +59,7 @@ export const Message = ({ className, from, ...props }: MessageProps) => ( /> ); -export type MessageContentProps = HTMLAttributes; +export type MessageContentProps = ComponentProps<"div">; export const MessageContent = ({ children, @@ -113,6 +127,156 @@ export const MessageAction = ({ return button; }; +const getMessageSelection = (element: HTMLElement | null | undefined) => { + const selection = element?.ownerDocument.getSelection(); + + if ( + !element || + !selection || + selection.isCollapsed || + selection.rangeCount !== 1 + ) { + return; + } + + const range = selection.getRangeAt(0); + + // Never quote text from another message or a selection crossing messages. + if ( + !element.contains(range.startContainer) || + !element.contains(range.endContainer) + ) { + return; + } + + const text = selection.toString(); + return text.trim() ? text : undefined; +}; + +export type MessageReplyProps = MessageActionProps & { + /** The complete message to quote when no text is selected. */ + text: string; + /** Limit selected text to this message's content. */ + selectionRef?: RefObject; + onReply: (text: string) => void; +}; + +export const MessageReply = ({ + children, + label = "Reply", + onClick, + onPointerDown, + onPointerCancel, + onReply, + selectionRef, + text, + ...props +}: MessageReplyProps) => { + const selectedText = useRef(null); + + const handleClick = useCallback>( + (event) => { + onClick?.(event); + const selection = + (event.detail > 0 ? selectedText.current : undefined) ?? + getMessageSelection(selectionRef?.current); + selectedText.current = null; + + if (event.defaultPrevented) { + return; + } + + const quote = selection ?? text; + if (quote.trim()) { + onReply(quote); + } + }, + [onClick, onReply, selectionRef, text] + ); + + const handlePointerCancel = useCallback< + PointerEventHandler + >( + (event) => { + selectedText.current = null; + onPointerCancel?.(event); + }, + [onPointerCancel] + ); + + const handlePointerDown = useCallback>( + (event) => { + onPointerDown?.(event); + // Capture before the browser moves focus and collapses the selection. + selectedText.current = + event.button === 0 && !event.defaultPrevented + ? (getMessageSelection(selectionRef?.current) ?? null) + : null; + }, + [onPointerDown, selectionRef] + ); + + return ( + + {children ?? } + + ); +}; + +export type MessageQuoteProps = ComponentProps<"div"> & { + label?: string; + onRemove?: () => void; + removeLabel?: string; +}; + +export const MessageQuote = ({ + children, + className, + label = "Replying to", + onRemove, + removeLabel = "Remove quote", + ...props +}: MessageQuoteProps) => ( +
+
+); + interface MessageBranchContextType { currentBranch: number; totalBranches: number; diff --git a/packages/examples/src/message-reply.tsx b/packages/examples/src/message-reply.tsx new file mode 100644 index 00000000..002c518d --- /dev/null +++ b/packages/examples/src/message-reply.tsx @@ -0,0 +1,235 @@ +"use client"; + +import type { PromptInputMessage } from "@repo/elements/prompt-input"; +import type { ChangeEventHandler, KeyboardEventHandler } from "react"; + +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from "@repo/elements/conversation"; +import { + Message, + MessageActions, + MessageContent, + MessageQuote, + MessageReply, + MessageResponse, +} from "@repo/elements/message"; +import { + PromptInput, + PromptInputFooter, + PromptInputHeader, + PromptInputSubmit, + PromptInputTextarea, +} from "@repo/elements/prompt-input"; +import { ReplyIcon } from "lucide-react"; +import { nanoid } from "nanoid"; +import { useCallback, useId, useRef, useState } from "react"; + +interface Quote { + messageId: string; + text: string; + from: "user" | "assistant"; +} + +interface ChatMessage { + id: string; + from: "user" | "assistant"; + text: string; + quote?: Quote; +} + +const initialMessages: ChatMessage[] = [ + { + from: "user", + id: "question", + text: "What makes a good loading state?", + }, + { + from: "assistant", + id: "answer", + text: "Keep the user's context visible while work happens in the background.\n\nA good loading state explains **what is happening**, preserves the current layout, and lets the user keep working. For longer tasks, show progress and provide a way to cancel.", + }, +]; + +const ReplyMessage = ({ + message, + onReply, +}: { + message: ChatMessage; + onReply: (quote: Quote) => void; +}) => { + const contentRef = useRef(null); + const handleReply = useCallback( + (text: string) => + onReply({ from: message.from, messageId: message.id, text }), + [message.from, message.id, onReply] + ); + + return ( + + {message.quote && ( + + {message.quote.text} + + )} + + {message.text} + + + + + + + ); +}; + +const Example = () => { + const [messages, setMessages] = useState(initialMessages); + const [input, setInput] = useState(""); + const [quote, setQuote] = useState(null); + const [announcement, setAnnouncement] = useState(""); + const inputRef = useRef(null); + const composing = useRef(false); + const quoteId = useId(); + const hintId = useId(); + + const handleReply = useCallback((nextQuote: Quote) => { + setQuote(nextQuote); + setAnnouncement(`Quote added. Replying to ${nextQuote.from}.`); + inputRef.current?.focus(); + }, []); + + const removeQuote = useCallback(() => { + setQuote(null); + setAnnouncement("Quote removed."); + inputRef.current?.focus(); + }, []); + + const handleSubmit = useCallback( + ({ text }: PromptInputMessage) => { + if (!text.trim()) { + return; + } + + // Local preview. In an app, keep the draft and quote until sending succeeds. + setMessages((current) => [ + ...current, + { from: "user", id: nanoid(), quote: quote ?? undefined, text }, + ]); + setInput(""); + setQuote(null); + setAnnouncement("Reply added to the conversation."); + inputRef.current?.focus(); + }, + [quote] + ); + + const handleChange = useCallback>( + (event) => setInput(event.currentTarget.value), + [] + ); + const handleCompositionStart = useCallback(() => { + composing.current = true; + }, []); + const handleCompositionEnd = useCallback(() => { + composing.current = false; + }, []); + const handleInputKeyDown = useCallback< + KeyboardEventHandler + >((event) => { + // Some IMEs dispatch their confirmation Enter after compositionend. + if (event.key === "Enter" && event.keyCode === 229) { + event.preventDefault(); + } + }, []); + const handleKeyDown = useCallback>( + (event) => { + if ( + event.key !== "Escape" || + !quote || + event.defaultPrevented || + composing.current || + event.nativeEvent.isComposing || + event.keyCode === 229 + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + removeQuote(); + }, + [quote, removeQuote] + ); + + return ( +
+

+ Select part of a message, then choose Reply to quote it. Without a + selection, Reply quotes the whole message. +

+ + + {messages.map((message) => ( + + ))} + + + + + {quote && ( + + + {quote.text} + + + )} + + + + {quote + ? "Esc to remove quote" + : "Enter to send · Shift+Enter for a new line"} + + + + + + {announcement} + +
+ ); +}; + +export default Example;