diff --git a/nobilis b/nobilis index ace4fd7..3e522af 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit ace4fd7a0cb579f5c4c6cfd3e21554b91ea6a9e8 +Subproject commit 3e522afdc55be5650fc260675026dc89acca9e73 diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 6041c3e..5a650f9 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -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; +} diff --git a/src/renderer/src/components/EventSource.tsx b/src/renderer/src/components/EventSource.tsx new file mode 100644 index 0000000..c4ee0f5 --- /dev/null +++ b/src/renderer/src/components/EventSource.tsx @@ -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(null) + const [failed, setFailed] = useState(null) + + useEffect(() => { + void window.moho + .rpc('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( +
+
e.stopPropagation()} role="dialog" aria-modal="true"> +
+ + Message source +
+
{messageId}
+ + {!source && !failed &&
Reading…
} + {failed &&
Couldn’t read it: {failed}
} + + {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 && ( + + )} + + + )} + +
+ +
+
+
, + document.body + ) +} + +function Block({ + label, + value, + onCopy +}: { + label: string + value: unknown + onCopy: (value: unknown) => void +}): JSX.Element { + return ( +
+
+ {label} + onCopy(value)} /> +
+ {/* 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. */} +
{JSON.stringify(value, null, 2)}
+
+ ) +} diff --git a/src/renderer/src/components/MessageRow.tsx b/src/renderer/src/components/MessageRow.tsx index a016108..0f3064a 100644 --- a/src/renderer/src/components/MessageRow.tsx +++ b/src/renderer/src/components/MessageRow.tsx @@ -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(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 && ( + setSourceOpen(false)} /> + )} {reporting && (