diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 54c924c112..6090b1a7eb 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -40,7 +40,7 @@ const overrides = new Map([ ["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification ["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state ["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates - ["src/features/messages/ui/MessageComposer.tsx", 710], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + ["src/features/messages/ui/MessageComposer.tsx", 760], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav ["src/shared/api/relayClientSession.ts", 930], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 92ecfab6cb..c4102a5a79 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -21,6 +21,7 @@ import { } from "@/features/messages/lib/normalizeMentionClipboard"; import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor"; import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; +import { getSproutCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; @@ -134,6 +135,15 @@ export function MessageComposer({ emojiAutocomplete.isEmojiAutocompleteOpen; const submitMessageRef = React.useRef<() => void>(() => {}); + const composerScrollRef = React.useRef(null); + + const scrollComposerToBottom = React.useCallback(() => { + window.requestAnimationFrame(() => { + const scrollElement = composerScrollRef.current; + if (!scrollElement) return; + scrollElement.scrollTop = scrollElement.scrollHeight; + }); + }, []); const computedPlaceholder = editTarget ? "Edit your message" @@ -498,6 +508,33 @@ export function MessageComposer({ return true; } + // --- Sprout code-block paste --- + // The code block copy button writes a small Sprout marker alongside + // plain text. Use it to paste back as a literal code block so Markdown + // parsing cannot reshape indentation, fence markers, or headings. + const codeBlockText = getSproutCodeBlockClipboardText( + event.clipboardData, + ); + if (codeBlockText !== null) { + event.preventDefault(); + richText.editor + ?.chain() + .focus() + .insertContent([ + { + type: "codeBlock", + content: + codeBlockText.length > 0 + ? [{ type: "text", text: codeBlockText }] + : [], + }, + { type: "paragraph" }, + ]) + .run(); + scrollComposerToBottom(); + return true; + } + // --- Mention / channel-link normalization --- // When copying from the chat area the browser puts styled HTML // on the clipboard. TipTap's DOMParser doesn't understand our @@ -524,11 +561,16 @@ export function MessageComposer({ return true; } + const plainText = event.clipboardData?.getData("text/plain") ?? ""; + if (plainText.includes("\n")) { + scrollComposerToBottom(); + } + return false; }, }, }); - }, [richText.editor]); + }, [richText.editor, scrollComposerToBottom]); // ── Send button state ─────────────────────────────────────────────── const sendDisabled = React.useMemo( @@ -673,6 +715,8 @@ export function MessageComposer({ {/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
diff --git a/desktop/src/shared/lib/codeBlockClipboard.ts b/desktop/src/shared/lib/codeBlockClipboard.ts new file mode 100644 index 0000000000..45cc4ff09f --- /dev/null +++ b/desktop/src/shared/lib/codeBlockClipboard.ts @@ -0,0 +1,57 @@ +const SPROUT_CODE_BLOCK_ATTRIBUTE = "data-sprout-code-block"; + +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function createSproutCodeBlockHtml(code: string) { + // Keep the code as one text node; the paste reader recovers it via textContent. + return `
${escapeHtml(code)}
`; +} + +export async function copyCodeBlockToClipboard(code: string) { + const clipboard = navigator.clipboard; + if (!clipboard) { + throw new Error("Clipboard API is unavailable"); + } + + if ( + typeof ClipboardItem !== "undefined" && + typeof clipboard.write === "function" + ) { + try { + await clipboard.write([ + new ClipboardItem({ + "text/html": new Blob([createSproutCodeBlockHtml(code)], { + type: "text/html", + }), + "text/plain": new Blob([code], { type: "text/plain" }), + }), + ]); + return; + } catch (error) { + console.warn("Failed to write rich code block clipboard data", error); + } + } + + await clipboard.writeText(code); +} + +export function getSproutCodeBlockClipboardText( + clipboardData: DataTransfer | null | undefined, +) { + const html = clipboardData?.getData("text/html"); + if (!html?.includes(SPROUT_CODE_BLOCK_ATTRIBUTE)) { + return null; + } + + const document = new DOMParser().parseFromString(html, "text/html"); + const code = document.querySelector(`[${SPROUT_CODE_BLOCK_ATTRIBUTE}] code`); + const fallback = document.querySelector(`[${SPROUT_CODE_BLOCK_ATTRIBUTE}]`); + + return code?.textContent ?? fallback?.textContent ?? null; +} diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index a222bbd5eb..00cb2bd21e 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1,9 +1,9 @@ import * as React from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { Copy } from "lucide-react"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; - import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -11,12 +11,15 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { invokeTauri } from "@/shared/api/tauri"; import type { Channel } from "@/shared/api/types"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; +import { copyCodeBlockToClipboard } from "@/shared/lib/codeBlockClipboard"; import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import rehypeImageGallery from "@/shared/lib/rehypeImageGallery"; import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight"; import remarkChannelLinks from "@/shared/lib/remarkChannelLinks"; import remarkMentions from "@/shared/lib/remarkMentions"; +import { Button } from "@/shared/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { classifyChildren, @@ -103,6 +106,75 @@ function ImageContextMenu({ ); } +function getReactNodeText(node: React.ReactNode): string { + if (typeof node === "string" || typeof node === "number") { + return String(node); + } + + if (Array.isArray(node)) { + return node.map(getReactNodeText).join(""); + } + + if (React.isValidElement<{ children?: React.ReactNode }>(node)) { + return getReactNodeText(node.props.children); + } + + return ""; +} + +function getCodeBlockText(children: React.ReactNode) { + return getReactNodeText(children).replace(/\n$/, ""); +} + +function MarkdownCodeBlock({ children }: { children?: React.ReactNode }) { + const [isCopying, setIsCopying] = React.useState(false); + const code = React.useMemo(() => getCodeBlockText(children), [children]); + + const handleCopy = React.useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsCopying(true); + + try { + await copyCodeBlockToClipboard(code); + toast.success("Copied code to clipboard"); + } catch (error) { + console.error("Failed to copy code block", error); + toast.error("Failed to copy code"); + } finally { + setIsCopying(false); + } + }, + [code], + ); + + return ( +
+
+        {children}
+      
+ + + + + Copy code + +
+ ); +} + function createMarkdownComponents( variant: MarkdownVariant, channels: Channel[], @@ -141,18 +213,12 @@ function createMarkdownComponents( ), br: () =>
, - code: ({ - children, - className, - ...props - }: React.ComponentProps<"code"> & { inline?: boolean }) => { + code: ({ children, className, ...props }: React.ComponentProps<"code">) => { const code = String(children).replace(/\n$/, ""); - const isBlock = - typeof className === "string" && className.includes("language-") - ? true - : code.includes("\n"); + const isFencedCodeBlock = + typeof className === "string" && className.includes("language-"); - if (isBlock) { + if (isFencedCodeBlock || code.includes("\n")) { return ( {children}

; }, - pre: ({ children }) => ( -
-        {children}
-      
- ), + pre: ({ children }) => {children}, strong: ({ children }) => ( {children} ), diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 2f72e08a29..145e8f2258 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -49,6 +49,91 @@ test("send multiple messages in sequence", async ({ page }) => { } }); +test("copy a rendered code block and paste it back as code", async ({ + page, +}) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { + origin: "http://127.0.0.1:4173", + }); + + const code = "# not a heading\nconst answer = 42;\n indented();"; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await page.evaluate( + (text) => navigator.clipboard.writeText(text), + `\`\`\`ts\n${code}\n\`\`\``, + ); + await input.click(); + await page.keyboard.press("ControlOrMeta+V"); + await page.getByTestId("send-message").click(); + + const copiedCodeBlock = page.locator("pre", { hasText: code }); + await expect(copiedCodeBlock).toHaveCount(1); + + const copyButton = page.getByLabel("Copy code block"); + await expect(copyButton).toHaveCSS("opacity", "0"); + await copiedCodeBlock.hover(); + await expect(copyButton).toHaveCSS("opacity", "1"); + await copyButton.click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(code); + + await input.click(); + await page.keyboard.press("ControlOrMeta+V"); + await input.press("Enter"); + + await expect(copiedCodeBlock).toHaveCount(2); +}); + +test("pasting a long copied code block scrolls composer to cursor", async ({ + page, +}) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { + origin: "http://127.0.0.1:4173", + }); + + const longCode = Array.from( + { length: 48 }, + (_, index) => `const line${index} = ${index};`, + ).join("\n"); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await page.evaluate( + (text) => navigator.clipboard.writeText(text), + `\`\`\`ts\n${longCode}\n\`\`\``, + ); + await input.click(); + await page.keyboard.press("ControlOrMeta+V"); + await page.getByTestId("send-message").click(); + + const copiedCodeBlock = page.locator("pre", { hasText: longCode }); + await expect(copiedCodeBlock).toHaveCount(1); + await copiedCodeBlock.hover(); + await page.getByLabel("Copy code block").click(); + + await input.fill("typed before paste"); + await page.keyboard.press("ControlOrMeta+V"); + + const scrollContainer = page.getByTestId("message-input-scroll"); + await expect + .poll(() => + scrollContainer.evaluate( + (element) => + element.scrollHeight - element.clientHeight - element.scrollTop, + ), + ) + .toBeLessThanOrEqual(1); +}); + test("message input clears after send", async ({ page }) => { const message = `Clear after send ${Date.now()}`; const input = page.getByTestId("message-input");