Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
46 changes: 45 additions & 1 deletion desktop/src/features/messages/ui/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -134,6 +135,15 @@ export function MessageComposer({
emojiAutocomplete.isEmojiAutocompleteOpen;

const submitMessageRef = React.useRef<() => void>(() => {});
const composerScrollRef = React.useRef<HTMLDivElement>(null);

const scrollComposerToBottom = React.useCallback(() => {
window.requestAnimationFrame(() => {
const scrollElement = composerScrollRef.current;
if (!scrollElement) return;
scrollElement.scrollTop = scrollElement.scrollHeight;
});
}, []);

const computedPlaceholder = editTarget
? "Edit your message"
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -673,6 +715,8 @@ export function MessageComposer({
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
<div
className="rich-text-composer max-h-32 overflow-y-auto"
data-testid="message-input-scroll"
ref={composerScrollRef}
onKeyDown={handleEditorKeyDown}
>
<EditorContent editor={richText.editor} />
Expand Down
57 changes: 57 additions & 0 deletions desktop/src/shared/lib/codeBlockClipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const SPROUT_CODE_BLOCK_ATTRIBUTE = "data-sprout-code-block";

function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

function createSproutCodeBlockHtml(code: string) {
// Keep the code as one text node; the paste reader recovers it via textContent.
return `<pre ${SPROUT_CODE_BLOCK_ATTRIBUTE}="true"><code>${escapeHtml(code)}</code></pre>`;
}

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;
}
94 changes: 78 additions & 16 deletions desktop/src/shared/ui/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
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";
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,
Expand Down Expand Up @@ -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<HTMLButtonElement>) => {
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 (
<div className="group relative">
<pre className="overflow-x-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 pr-12 shadow-sm">
{children}
</pre>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Copy code block"
className="absolute right-2 top-2 h-7 w-7 bg-background/80 text-muted-foreground opacity-0 shadow-sm ring-1 ring-border/60 backdrop-blur transition-opacity hover:bg-background hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 disabled:opacity-60"
disabled={isCopying}
onClick={handleCopy}
size="icon"
type="button"
variant="ghost"
>
<Copy className="h-3.5 w-3.5" />
<span className="sr-only">Copy code block</span>
</Button>
</TooltipTrigger>
<TooltipContent>Copy code</TooltipContent>
</Tooltip>
</div>
);
}

function createMarkdownComponents(
variant: MarkdownVariant,
channels: Channel[],
Expand Down Expand Up @@ -141,18 +213,12 @@ function createMarkdownComponents(
</blockquote>
),
br: () => <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 (
<code
{...props}
Expand Down Expand Up @@ -297,11 +363,7 @@ function createMarkdownComponents(

return <p className={paragraphClassName}>{children}</p>;
},
pre: ({ children }) => (
<pre className="overflow-x-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 shadow-sm">
{children}
</pre>
),
pre: ({ children }) => <MarkdownCodeBlock>{children}</MarkdownCodeBlock>,
strong: ({ children }) => (
<strong className="font-semibold">{children}</strong>
),
Expand Down
85 changes: 85 additions & 0 deletions desktop/tests/e2e/messaging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading