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', {