Copy a link to a message, or read what it really says

Two entries on a Matrix message's menu, and a dialog behind the second.

The source panel leads with the plaintext where there is one - that is
the half that answers the question - and puts the envelope underneath as
the supporting evidence. Each half copies on its own, since what gets
pasted into a bug report is usually one of them. The JSON scrolls in its
own box on both axes: wrapping long lines would destroy the indentation
that makes it readable, and an event from a bridge is tall enough to
push a dialog's close button off screen.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-13 13:28:19 -04:00
co-authored by Claude Opus 5
parent 8900723ad8
commit 178b2a5c23
4 changed files with 203 additions and 1 deletions
+1 -1
Submodule nobilis updated: ace4fd7a0c...3e522afdc5
+50
View File
@@ -6675,3 +6675,53 @@ video.lightbox-media {
.follow-button.following {
color: var(--error);
}
/* --- Message source --------------------------------------------------- */
/* The event behind a message, for when what is on screen is wrong. Built like
the prompts beside it and capped well short of the window: an event from a
bridge runs to hundreds of lines, and a dialog as tall as the conversation
behind it has its close button off screen. */
.event-source {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
width: min(680px, 90vw);
max-height: calc(100vh - 96px);
margin: auto;
padding: var(--spacing-m);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius);
background: var(--surface-container-high);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
overflow-y: auto;
}
.event-source-block {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.event-source-label {
display: flex;
align-items: center;
justify-content: space-between;
}
.event-source-json {
margin: 0;
max-height: 40vh;
padding: var(--spacing-s);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius-small);
background: var(--surface-container-lowest);
color: var(--surface-variant-text);
font-family: var(--font-mono);
font-size: 0.78rem;
line-height: 1.45;
/* Both axes in its own box: JSON has long lines, and wrapping them would
destroy the indentation that makes it readable at all. */
overflow: auto;
white-space: pre;
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon, IconButton } from './Icon'
import { useStore } from '../state/hooks'
/** What the daemon read back off the server. */
interface Source {
eventId: string
roomId: string
event: unknown
/** Present only where there was something to decrypt. */
decrypted?: unknown
}
/**
* The event behind a message, as the server holds it.
*
* What somebody reaches for when a message is wrong: a body that rendered
* oddly, a reply pointing at nothing, a state change nobody made. Everything
* else in this window shows what the client *made* of an event, and the whole
* reason to look at the source is that what it made may be wrong - so this is
* fetched from the server rather than taken from the timeline.
*
* Two halves in an encrypted room, which is what Element shows and what is
* actually useful: the envelope is what was sent and what any other client
* sees, and the plaintext is what it turned out to say. A room with nothing
* to decrypt shows one, because the same JSON twice teaches nobody anything.
*/
export function EventSource({
bufferId,
messageId,
onClose
}: {
bufferId: string
messageId: string
onClose: () => void
}): JSX.Element {
const store = useStore()
const [source, setSource] = useState<Source | null>(null)
const [failed, setFailed] = useState<string | null>(null)
useEffect(() => {
void window.moho
.rpc<Source>('matrixEventSource', { bufferId, messageId })
.then(setSource)
.catch((e: Error) => setFailed(e.message))
}, [bufferId, messageId])
// Escape closes it, the same as every other dialog here. A panel of JSON is
// exactly the thing somebody opens by accident and wants gone.
useEffect(() => {
const key = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', key)
return () => window.removeEventListener('keydown', key)
}, [onClose])
const copy = (value: unknown): void => {
void window.moho.copyText(JSON.stringify(value, null, 2))
store.toast('info', 'Copied')
}
return createPortal(
<div className="lightbox-backdrop" onClick={onClose}>
<div className="event-source" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="reason-prompt-head">
<Icon name="data_object" size={18} />
<span>Message source</span>
</div>
<div className="small muted ellipsis">{messageId}</div>
{!source && !failed && <div className="small muted">Reading…</div>}
{failed && <div className="small muted">Couldn’t read it: {failed}</div>}
{source && (
<>
{/* The plaintext first where there is one: it is the half that
answers the question, and the envelope below it is the
supporting evidence. */}
{source.decrypted !== undefined && source.decrypted !== null && (
<Block label="Decrypted" value={source.decrypted} onCopy={copy} />
)}
<Block
label={source.decrypted ? 'As sent (encrypted)' : 'As sent'}
value={source.event}
onCopy={copy}
/>
</>
)}
<div className="reason-prompt-actions">
<button type="button" className="button" onClick={onClose}>
Close
</button>
</div>
</div>
</div>,
document.body
)
}
function Block({
label,
value,
onCopy
}: {
label: string
value: unknown
onCopy: (value: unknown) => void
}): JSX.Element {
return (
<div className="event-source-block">
<div className="event-source-label">
<span className="small muted">{label}</span>
<IconButton name="content_copy" size={16} title="Copy this" onClick={() => onCopy(value)} />
</div>
{/* Scrolls in its own box rather than stretching the dialog. An event
from a bridge can be hundreds of lines, and a dialog as tall as the
conversation behind it has no close button on screen. */}
<pre className="event-source-json">{JSON.stringify(value, null, 2)}</pre>
</div>
)
}
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { Avatar } from './Avatar'
import { ReasonPrompt } from './ReasonPrompt'
import { EventSource } from './EventSource'
import { ForwardPicker } from './ForwardPicker'
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
import { MediaEmbed } from './MediaEmbed'
@@ -318,6 +319,7 @@ function MessageRowBody({
const [pickerOpen, setPickerOpen] = useState(false)
/** Writing the reason for a report, before it is sent. */
const [reporting, setReporting] = useState(false)
const [sourceOpen, setSourceOpen] = useState(false)
/** Choosing where to send this message on to. */
const [forwarding, setForwarding] = useState(false)
const reactButtonRef = useRef<HTMLButtonElement>(null)
@@ -564,6 +566,29 @@ function MessageRowBody({
}
] as MenuEntry[])
: []),
// The two things somebody reaches for when a message is wrong: an
// address to quote it by, and what it actually says. A permalink is how
// a message is shown to somebody outside the room, and the source is how
// a malformed event is described to whoever can fix it - without either,
// a bug in a room can only be described in prose.
...(service === 'matrix' && !isSystem && message.id
? ([
{
label: 'Copy link to this message',
icon: 'link',
onClick: () => {
void window.moho
.rpc<{ url: string }>('matrixMessageLink', { bufferId, messageId: message.id })
.then(({ url }) => {
void window.moho.copyText(url)
store.toast('info', 'Link copied')
})
.catch((e: Error) => store.toast('error', e.message))
}
},
{ label: 'View source', icon: 'data_object', onClick: () => setSourceOpen(true) }
] as MenuEntry[])
: []),
...(message.failed
? ([{ label: 'Retry', icon: 'refresh', onClick: () => store.retrySend(message.id) }] as MenuEntry[])
: [])
@@ -1041,6 +1066,9 @@ function MessageRowBody({
onClose={() => setForwarding(false)}
/>
)}
{sourceOpen && (
<EventSource bufferId={bufferId} messageId={message.id} onClose={() => setSourceOpen(false)} />
)}
{reporting && (
<ReasonPrompt
title={`Report ${message.from}’s message`}