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:
+1
-1
Submodule nobilis updated: ace4fd7a0c...3e522afdc5
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`}
|
||||
|
||||
Reference in New Issue
Block a user