From 789329c9c296ec302d4e48a6e6afea32fd0a10b5 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 18 Aug 2026 16:47:12 +0100 Subject: [PATCH] Polish channel rows and split message media Signed-off-by: kenny lopez --- .../lib/outgoingMessageParts.test.mjs | 86 ++++++++ .../messages/lib/outgoingMessageParts.ts | 43 ++-- .../messages/ui/useMentionSendFlow.ts | 52 ++++- .../features/sidebar/ui/SidebarSection.tsx | 10 +- desktop/src/shared/lib/linkPreview.test.mjs | 45 +++++ desktop/src/shared/lib/linkPreview.ts | 183 ++++++++++++++++-- .../src/shared/styles/globals/markdown.css | 10 +- desktop/tests/e2e/channels.spec.ts | 12 +- desktop/tests/e2e/file-attachment.spec.ts | 1 + .../e2e/message-feedback-snapshots.spec.ts | 49 +++++ desktop/tests/e2e/messaging.spec.ts | 85 ++++++++ 11 files changed, 527 insertions(+), 49 deletions(-) diff --git a/desktop/src/features/messages/lib/outgoingMessageParts.test.mjs b/desktop/src/features/messages/lib/outgoingMessageParts.test.mjs index 108f36c049..b51c8e8ab4 100644 --- a/desktop/src/features/messages/lib/outgoingMessageParts.test.mjs +++ b/desktop/src/features/messages/lib/outgoingMessageParts.test.mjs @@ -51,3 +51,89 @@ test("text-only submissions remain one text event", () => { { content: "hello", kind: "text", outgoingTags: undefined }, ]); }); + +test("mixed link submissions put the link preview before remaining text", () => { + const previewTag = [ + "link-preview", + "snapshot", + "1", + "https://example.com/launch", + ]; + const emojiTag = ["emoji", "ship", "https://relay.example/ship.png"]; + const parts = buildOutgoingMessageParts({ + body: "Launch notes https://example.com/launch are ready :ship:", + media: [], + nowSeconds: 100, + textTags: [previewTag, emojiTag], + }); + + assert.deepEqual(parts, [ + { + content: "https://example.com/launch", + createdAt: 99, + kind: "link", + outgoingTags: [previewTag], + }, + { + content: "Launch notes are ready :ship:", + kind: "text", + outgoingTags: [emojiTag], + }, + ]); +}); + +test("attachment, link, and prose events keep media-first ordering", () => { + const parts = buildOutgoingMessageParts({ + body: "Context for https://example.com/launch", + media: [image], + nowSeconds: 100, + }); + + assert.equal(parts.length, 3); + assert.equal(parts[0].kind, "media"); + assert.equal(parts[0].createdAt, 98); + assert.equal(parts[1].kind, "link"); + assert.equal(parts[1].createdAt, 99); + assert.equal(parts[2].kind, "text"); + assert.equal(parts[2].createdAt, undefined); +}); + +test("link-only submissions remain one link event", () => { + assert.deepEqual( + buildOutgoingMessageParts({ + body: "https://example.com/launch", + media: [], + nowSeconds: 100, + }), + [ + { + content: "https://example.com/launch", + kind: "link", + outgoingTags: undefined, + }, + ], + ); +}); + +test("markdown link labels remain readable in the prose event", () => { + assert.deepEqual( + buildOutgoingMessageParts({ + body: "Please review [the launch plan](https://example.com/launch).", + media: [], + nowSeconds: 100, + }), + [ + { + content: "https://example.com/launch", + createdAt: 99, + kind: "link", + outgoingTags: undefined, + }, + { + content: "Please review the launch plan.", + kind: "text", + outgoingTags: undefined, + }, + ], + ); +}); diff --git a/desktop/src/features/messages/lib/outgoingMessageParts.ts b/desktop/src/features/messages/lib/outgoingMessageParts.ts index 8f10db5720..ddf4a0dc15 100644 --- a/desktop/src/features/messages/lib/outgoingMessageParts.ts +++ b/desktop/src/features/messages/lib/outgoingMessageParts.ts @@ -1,15 +1,17 @@ import { buildOutgoingMessage, type ImetaMedia } from "./imetaMediaMarkdown"; +import { splitOutgoingLinkContent } from "@/shared/lib/linkPreview"; export type OutgoingMessagePart = { content: string; createdAt?: number; - kind: "media" | "text"; + kind: "link" | "media" | "text"; outgoingTags: string[][] | undefined; }; /** * Build the ordered events for one composer submission. Attachments remain - * together in a media-only event and precede any authored text event. + * together in a media-only event, authored links move to a link-only event, + * and both precede any remaining authored text event. */ export function buildOutgoingMessageParts({ body, @@ -25,28 +27,45 @@ export function buildOutgoingMessageParts({ textTags?: string[][]; }): OutgoingMessagePart[] { const parts: OutgoingMessagePart[] = []; - const hasText = body.trim().length > 0; + const { linkContent, textContent } = splitOutgoingLinkContent(body); + const linkTags = textTags.filter((tag) => tag[0] === "link-preview"); + const remainingTextTags = textTags.filter((tag) => tag[0] !== "link-preview"); if (media.length > 0) { const mediaMessage = buildOutgoingMessage("", media, spoileredMediaUrls); parts.push({ content: mediaMessage.content, - // Relay events sort by whole-second timestamps and then event id. Give - // the media half of a mixed submission the immediately preceding second - // so every client renders it first without delaying either network send. - createdAt: hasText ? Math.max(0, nowSeconds - 1) : undefined, kind: "media", outgoingTags: mediaMessage.mediaTags, }); } - if (hasText) { + if (linkContent) { parts.push({ - content: body, - kind: "text", - outgoingTags: textTags.length > 0 ? textTags : undefined, + content: linkContent, + kind: "link", + outgoingTags: linkTags.length > 0 ? linkTags : undefined, }); } - return parts; + if (textContent) { + parts.push({ + content: textContent, + kind: "text", + outgoingTags: + remainingTextTags.length > 0 ? remainingTextTags : undefined, + }); + } + + // Relay events sort by whole-second timestamps and then event id. Backdate + // every part except the final one by one additional second so attachment → + // link → prose order is stable without delaying any network send. + return parts.map((part, index) => + index < parts.length - 1 + ? { + ...part, + createdAt: Math.max(0, nowSeconds - (parts.length - 1 - index)), + } + : part, + ); } diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index d7a004c1a8..68b4ae55a3 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -493,7 +493,15 @@ export function useMentionSendFlow({ ); const send = onSendRef.current; let mediaMessageSent = false; - const persistCanceledDraft = (withoutMedia = false) => { + let linkMessageSent = false; + let remainingTextContent = draft.savedContent; + const persistCanceledDraft = ({ + content = draft.savedContent, + withoutMedia = false, + }: { + content?: string; + withoutMedia?: boolean; + } = {}) => { if (isSendCancelled() || !draft.recoveryDraftKey) return; const existing = drafts.loadDraft(draft.recoveryDraftKey); if ( @@ -510,16 +518,22 @@ export function useMentionSendFlow({ } drafts.persistDraft( draft.recoveryDraftKey, - draft.savedContent, + content, draft.capturedChannelId ?? draft.recoveryDraftKey, withoutMedia ? [] : draft.savedImeta, withoutMedia ? [] : [...draft.savedSpoileredAttachmentUrls], draft.savedMentionRefs, ); }; - const restoreComposerAfterFailure = (withoutMedia = false) => { + const restoreComposerAfterFailure = ({ + content = draft.savedContent, + withoutMedia = false, + }: { + content?: string; + withoutMedia?: boolean; + } = {}) => { if (isSendCancelled()) return; - persistCanceledDraft(withoutMedia); + persistCanceledDraft({ content, withoutMedia }); const canRestoreCurrentComposer = isMountedRef.current && (draft.capturedChannelId === channelIdRef.current || @@ -535,9 +549,9 @@ export function useMentionSendFlow({ if (!canRestoreCurrentComposer) { return; } - setContent(draft.savedContent); - contentRef.current = draft.savedContent; - richText.setContent(draft.savedContent); + setContent(content); + contentRef.current = content; + richText.setContent(content); setPendingImeta(withoutMedia ? [] : draft.savedImeta); restoreQueuedAttachments(withoutMedia ? [] : draft.queuedAttachments); mentions.restoreDraftMentionRefs(draft.savedMentionRefs); @@ -564,6 +578,8 @@ export function useMentionSendFlow({ ]), textTags, }); + remainingTextContent = + parts.find((part) => part.kind === "text")?.content ?? ""; const revalidatedMentionPubkeys = await mentions.revalidateMentionPubkeys(mentionPubkeys); if (signal?.aborted || isSendCancelled()) return; @@ -583,6 +599,7 @@ export function useMentionSendFlow({ draft.preparedLinkPreviews != null, ); if (part.kind === "media") mediaMessageSent = true; + if (part.kind === "link") linkMessageSent = true; if (signal?.aborted || isSendCancelled()) return; } if (signal?.aborted || isSendCancelled()) return; @@ -610,7 +627,12 @@ export function useMentionSendFlow({ try { await finishSend(uploaded, signal); } catch { - restoreComposerAfterFailure(mediaMessageSent); + restoreComposerAfterFailure({ + content: linkMessageSent + ? remainingTextContent + : draft.savedContent, + withoutMedia: mediaMessageSent, + }); } }, onError: (error) => { @@ -620,7 +642,12 @@ export function useMentionSendFlow({ ); }, onCancel: () => { - restoreComposerAfterFailure(mediaMessageSent); + restoreComposerAfterFailure({ + content: linkMessageSent + ? remainingTextContent + : draft.savedContent, + withoutMedia: mediaMessageSent, + }); }, }); if (!uploadStarted) { @@ -640,7 +667,12 @@ export function useMentionSendFlow({ try { await finishSend([]); } catch { - restoreComposerAfterFailure(mediaMessageSent); + restoreComposerAfterFailure({ + content: linkMessageSent + ? remainingTextContent + : draft.savedContent, + withoutMedia: mediaMessageSent, + }); } } } finally { diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 9823ced175..a93d97e18d 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -306,7 +306,8 @@ export function ChannelMenuButton({