From 0033955adf332445ec04bd48a02d245b53cd2c57 Mon Sep 17 00:00:00 2001 From: Salastil Date: Thu, 8 Oct 2026 14:16:01 -0400 Subject: [PATCH] Copy and Copy text on every message; Copy message link on Discord (#299) The row's own context menu replaced the window's, so right-clicking highlighted text offered no Copy. It now leads with Copy for the selection, then Copy text for the whole message. Discord gets Copy message link beside Open in Discord, from the daemon's existing link. Co-Authored-By: Claude Sonnet 5.5 --- scripts/ui-shots.mjs | 19 +++++++++++++++ src/renderer/src/components/MessageRow.tsx | 27 +++++++++++++++++++++- src/renderer/src/state/store.ts | 11 +++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/scripts/ui-shots.mjs b/scripts/ui-shots.mjs index d8d9f0d..42a03c1 100644 --- a/scripts/ui-shots.mjs +++ b/scripts/ui-shots.mjs @@ -420,6 +420,25 @@ const SCENES = [ }, clip: { x: 277, y: 80, width: 640, height: 90 } }, + { + // Right-click on highlighted text in a Discord message (#299). + name: 'discord-message-menu', + setup: async (p) => { + await discord(false)(p) + await p.evaluate(`(() => { + const row = [...document.querySelectorAll('.message-row')].find((r) => r.innerText.includes('craft fair')) + const body = row.querySelector('.message-body') + const range = document.createRange(); range.selectNodeContents(body) + const sel = getSelection(); sel.removeAllRanges(); sel.addRange(range) + return true + })()`) + const c = await centre(p, '.message-row', 'craft fair') + await mouse(p, 'mousePressed', c.x + 120, c.y, 'right') + await mouse(p, 'mouseReleased', c.x + 120, c.y, 'right') + await sleep(300) + }, + clip: { x: 277, y: 80, width: 640, height: 420 } + }, // Motion, recorded rather than photographed. { name: 'motion-hover-rows', diff --git a/src/renderer/src/components/MessageRow.tsx b/src/renderer/src/components/MessageRow.tsx index e8ffbfe..6e1e20e 100644 --- a/src/renderer/src/components/MessageRow.tsx +++ b/src/renderer/src/components/MessageRow.tsx @@ -439,6 +439,10 @@ function MessageRowBody({ // Somebody else's name or picture opens who they are, as it does in the // clients this is measured against; your own and the system's have nothing // to open. + // Read when the menu opens: by the time an entry is clicked the window may + // have let go of the selection. + const selectedRef = useRef('') + const selected = selectedRef.current const canOpenProfile = !!message.from && !isSystem && !message.isOwn const openProfile = (): void => store.showProfile(bufferId, message.from, message.senderId) // Back to what this answers, bringing it into view first if it is a long @@ -465,6 +469,15 @@ function MessageRowBody({ ] as MenuEntry[]) : []), { label: 'Reply', icon: 'reply', onClick: () => reply() }, + // What was selected when the menu opened, first: right-clicking on + // highlighted text and finding no Copy is the one thing this menu must not + // do, having replaced the one the window would have given. + ...(selected + ? ([{ label: 'Copy', icon: 'content_copy', onClick: () => void window.moho.copyText(selected) }] as MenuEntry[]) + : []), + ...(message.body && !isSystem + ? ([{ label: 'Copy text', icon: 'content_copy', onClick: () => void window.moho.copyText(message.body) }] as MenuEntry[]) + : []), // Sending somebody else's message on. Only where the service has a real // forward: Discord's carries the original itself, and Matrix's sends its // content again, the same upload and formatting. Anywhere else it would @@ -589,6 +602,15 @@ function MessageRowBody({ } ] as MenuEntry[]) : []), + ...(service === 'discord' && message.id && !isSystem + ? ([ + { + label: 'Copy message link', + icon: 'link', + onClick: () => void store.copyDiscordMessageLink(bufferId, message.id) + } + ] as MenuEntry[]) + : []), ...(service === 'discord' ? ([ { @@ -693,7 +715,10 @@ function MessageRowBody({ // Where a picture in it came from, for fetching it again once its // cached copy has gone - see lib/mediarestore. data-buffer-id={bufferId} - onContextMenu={open} + onContextMenu={(e) => { + selectedRef.current = window.getSelection()?.toString().trim() ?? '' + open(e) + }} > {/* Bubbles carry their own time inside, which is the whole point of the shape - so the gutter that reserves 42px for it on every other diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index a27a3c7..6818d76 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -3951,6 +3951,17 @@ export class ChatStore { * Opens the real Discord message in a browser - the last resort when even * a refresh cannot produce a working link. */ + /** A link to one message, on the clipboard - the same address Open in Discord goes to. */ + async copyDiscordMessageLink(bufferId: string, messageId: string): Promise { + try { + const { url } = await window.moho.rpc<{ url: string }>('getDiscordMessageLink', { bufferId, messageId }) + void window.moho.copyText(url) + this.toast('info', 'Link copied') + } catch (e) { + this.toast('error', (e as Error).message) + } + } + async openInDiscord(bufferId: string, messageId: string): Promise { try { const { url } = await window.moho.rpc<{ url: string }>('getDiscordMessageLink', {