From 0e2671891fcb0a8a16cec2409879ea684a575dfa Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 13:23:25 -0400 Subject: [PATCH] feat(e2ee): undecryptable placeholder says why and offers the fix (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Unable to decrypt message' now carries one sentence per matrix-js-sdk DecryptionFailureCode (describeDecryptionFailure, unit-tested against every code so no raw code can leak into the copy) and, where something fixes it, one button: no key backup → 'Set up key backup'; backup exists but this session can't open it / key withheld for an unverified session → 'Unlock key backup' / 'Verify this session' (both open Settings → Devices via a new settingsRequestAtom that SettingsTab consumes); backup working or unknown session (rust-crypto re-requests keys itself) → 'Retry', which re-runs decryptEventIfNeeded. Sender-side problems are plain text. The raw code sits in the placeholder's tooltip for support. Verified headless on a fresh session in the encrypted seed room: each event shows 'Sent before you signed in here, and no key backup exists…' with tooltip HISTORICAL_MESSAGE_NO_KEY_BACKUP; the button opens Settings → Devices. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/components/RenderMessageContent.tsx | 7 +- .../components/message/MsgTypeRenderers.tsx | 62 +++++++++++++-- src/app/features/room/RoomTimeline.tsx | 2 + .../features/room/thread/ThreadTimeline.tsx | 1 + src/app/pages/client/sidebar/SettingsTab.tsx | 23 +++++- src/app/state/settingsRequest.ts | 10 +++ src/app/utils/decryptionReason.test.ts | 44 +++++++++++ src/app/utils/decryptionReason.ts | 75 +++++++++++++++++++ 8 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 src/app/state/settingsRequest.ts create mode 100644 src/app/utils/decryptionReason.test.ts create mode 100644 src/app/utils/decryptionReason.ts diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index e57053278..906b79e36 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { MsgType } from 'matrix-js-sdk'; +import { MatrixEvent, MsgType } from 'matrix-js-sdk'; import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts } from 'linkifyjs'; import { config, Text } from 'folds'; @@ -72,6 +72,8 @@ type RenderMessageContentProps = { eventId?: string; /** [Gitea #219] Open the room's shared media lightbox at this event. */ onOpenImageViewer?: () => void; + /** [Gitea #159] The event, for the undecryptable placeholder's reason + retry. */ + mEvent?: MatrixEvent; }; export function RenderMessageContent({ displayName, @@ -88,6 +90,7 @@ export function RenderMessageContent({ outlineAttachment, eventId, onOpenImageViewer, + mEvent, }: RenderMessageContentProps) { const renderUrlsPreview = (urls: string[]) => { // Cap previews per message so a link-dump doesn't spawn dozens of preview @@ -346,7 +349,7 @@ export function RenderMessageContent({ } if (msgType === 'm.bad.encrypted') { - return ; + return ; } if (msgType === 'm.key.verification.request') { diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index dd2c894a0..e124b9cfe 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -1,7 +1,11 @@ import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds'; -import { IContent } from 'matrix-js-sdk'; +import { IContent, MatrixEvent } from 'matrix-js-sdk'; +import { useSetAtom } from 'jotai'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { settingsRequestAtom } from '../../state/settingsRequest'; +import { describeDecryptionFailure } from '../../utils/decryptionReason'; import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex'; import { trimReplyFromBody } from '../../utils/room'; import { MessageTextBody } from './layout'; @@ -120,11 +124,59 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) { ); } -export function MBadEncrypted() { +type MBadEncryptedProps = { + mEvent?: MatrixEvent; +}; +/** + * [Gitea #159] The undecryptable placeholder says WHY and offers the one + * action that fixes it (set up key backup / verify this session / retry). + * The raw SDK code stays in the tooltip for support. + */ +export function MBadEncrypted({ mEvent }: MBadEncryptedProps) { + const mx = useMatrixClient(); + const requestSettings = useSetAtom(settingsRequestAtom); + const [retrying, setRetrying] = useState(false); + const code = mEvent?.decryptionFailureReason ?? null; + const reason = describeDecryptionFailure(code); + + const handleAction = async () => { + if (reason.action === 'setup-backup' || reason.action === 'verify-session') { + requestSettings('devices'); + return; + } + if (reason.action === 'retry' && mEvent) { + setRetrying(true); + try { + await mx.decryptEventIfNeeded(mEvent, { forceRedecryptIfUntrusted: true }); + } catch { + // still undecryptable — the placeholder re-renders with the current reason + } finally { + setRetrying(false); + } + } + }; + return ( - - - + + + + + + {reason.text} + + {reason.action !== 'none' && ( + + )} + ); } diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 1da3c02b8..fd58a6aa9 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1235,6 +1235,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli outlineAttachment={messageLayout === MessageLayout.Bubble} eventId={mEventId} onOpenImageViewer={() => setLightboxEventId(mEventId)} + mEvent={mEvent} /> )} @@ -1365,6 +1366,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli outlineAttachment={messageLayout === MessageLayout.Bubble} eventId={mEventId} onOpenImageViewer={() => setLightboxEventId(mEventId)} + mEvent={mEvent} /> ); } diff --git a/src/app/features/room/thread/ThreadTimeline.tsx b/src/app/features/room/thread/ThreadTimeline.tsx index edc02499e..090ba3aa3 100644 --- a/src/app/features/room/thread/ThreadTimeline.tsx +++ b/src/app/features/room/thread/ThreadTimeline.tsx @@ -715,6 +715,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) { outlineAttachment={messageLayout === MessageLayout.Bubble} eventId={mEventId} onOpenImageViewer={() => setLightboxEventId(mEventId)} + mEvent={mEvent} /> ); }; diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index 39cb3c86a..cf063e0e3 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; +import { useAtom } from 'jotai'; import { Badge, Box, @@ -23,7 +24,8 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { nameInitials } from '../../../utils/common'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { Settings } from '../../../features/settings'; +import { Settings, SettingsPages } from '../../../features/settings'; +import { settingsRequestAtom } from '../../../state/settingsRequest'; import { useUserProfile } from '../../../hooks/useUserProfile'; import { Modal500 } from '../../../components/Modal500'; import { useSetting } from '../../../state/hooks/settings'; @@ -172,6 +174,21 @@ export function SettingsTab() { const profile = useUserProfile(userId); const [settingsOpen, setSettingsOpen] = useState(false); + // [Gitea #159] Programmatic "open settings at page X" requests. + const [settingsRequest, setSettingsRequest] = useAtom(settingsRequestAtom); + const [initialPage, setInitialPage] = useState(); + useEffect(() => { + if (!settingsRequest) return; + const page = { + general: SettingsPages.GeneralPage, + account: SettingsPages.AccountPage, + notifications: SettingsPages.NotificationPage, + devices: SettingsPages.DevicesPage, + }[settingsRequest]; + setInitialPage(page); + setSettingsOpen(true); + setSettingsRequest(null); + }, [settingsRequest, setSettingsRequest]); const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const avatarUrl = profile.avatarUrl @@ -201,7 +218,7 @@ export function SettingsTab() { {settingsOpen && ( setSettingsOpen(false)}> - setSettingsOpen(false)} /> + setSettingsOpen(false)} /> )} diff --git a/src/app/state/settingsRequest.ts b/src/app/state/settingsRequest.ts new file mode 100644 index 000000000..5e2ce23b6 --- /dev/null +++ b/src/app/state/settingsRequest.ts @@ -0,0 +1,10 @@ +import { atom } from 'jotai'; + +export type SettingsRequestPage = 'general' | 'account' | 'notifications' | 'devices'; + +/** + * Ask the sidebar to open User Settings at a page from anywhere in the app + * (e.g. an undecryptable message's "Set up key backup" button, #159). + * SettingsTab consumes and clears it. + */ +export const settingsRequestAtom = atom(null); diff --git a/src/app/utils/decryptionReason.test.ts b/src/app/utils/decryptionReason.test.ts new file mode 100644 index 000000000..dc12a376e --- /dev/null +++ b/src/app/utils/decryptionReason.test.ts @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DecryptionFailureCode } from 'matrix-js-sdk/lib/crypto-api'; +import { describeDecryptionFailure } from './decryptionReason'; + +test('every SDK failure code maps to a sentence without the raw code in it', () => { + Object.values(DecryptionFailureCode).forEach((code) => { + const r = describeDecryptionFailure(code); + assert.ok(r.text.length > 10, code); + assert.ok(!r.text.includes(code), `raw code leaked for ${code}`); + assert.ok(!/[A-Z_]{8,}/.test(r.text), `shouty code-like text for ${code}: ${r.text}`); + if (r.action !== 'none') assert.ok(r.actionLabel, `action without label for ${code}`); + }); +}); + +test('the fixable cases carry the right action', () => { + assert.equal( + describeDecryptionFailure(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP).action, + 'setup-backup', + ); + assert.equal( + describeDecryptionFailure(DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED).action, + 'verify-session', + ); + assert.equal( + describeDecryptionFailure(DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP).action, + 'retry', + ); + assert.equal( + describeDecryptionFailure(DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE) + .action, + 'verify-session', + ); + assert.equal( + describeDecryptionFailure(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID).action, + 'retry', + ); + assert.equal( + describeDecryptionFailure(DecryptionFailureCode.UNKNOWN_SENDER_DEVICE).action, + 'none', + ); + assert.equal(describeDecryptionFailure(undefined).action, 'none'); + assert.equal(describeDecryptionFailure('SOMETHING_NEW').text, 'Unable to decrypt this message.'); +}); diff --git a/src/app/utils/decryptionReason.ts b/src/app/utils/decryptionReason.ts new file mode 100644 index 000000000..2d8c5f7f0 --- /dev/null +++ b/src/app/utils/decryptionReason.ts @@ -0,0 +1,75 @@ +import { DecryptionFailureCode } from 'matrix-js-sdk/lib/crypto-api'; + +/** + * [Gitea #159] What to tell the user about an undecryptable message, and the + * one thing that fixes it (if anything does). Raw codes never reach the UI + * text — they go in the tooltip for support. + */ +export type DecryptionAction = 'setup-backup' | 'retry' | 'verify-session' | 'none'; + +export type DecryptionReason = { + text: string; + action: DecryptionAction; + actionLabel?: string; +}; + +export const describeDecryptionFailure = ( + code: DecryptionFailureCode | string | null | undefined, +): DecryptionReason => { + switch (code) { + case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP: + // No backup exists on the server at all: this key is gone for this + // session (another signed-in device may still share it), and the fix is + // for the future. + return { + text: 'Sent before you signed in here, and no key backup exists — set one up so your next session can read history.', + action: 'setup-backup', + actionLabel: 'Set up key backup', + }; + case DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED: + // A backup exists but this session can't open it (needs the recovery key + // / verification). + return { + text: "Sent before you signed in here. Your key backup exists but this session can't open it yet.", + action: 'verify-session', + actionLabel: 'Unlock key backup', + }; + case DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP: + return { + text: 'Waiting for the key from your backup…', + action: 'retry', + actionLabel: 'Retry', + }; + case DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED: + return { text: 'Sent before you joined this room.', action: 'none' }; + case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE: + return { + text: "The sender's device won't share keys with unverified sessions.", + action: 'verify-session', + actionLabel: 'Verify this session', + }; + case DecryptionFailureCode.MEGOLM_KEY_WITHHELD: + return { text: 'The sender chose not to share the key for this message.', action: 'none' }; + case DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID: + case DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX: + // rust-crypto re-requests the key from our other devices by itself + // (there is no public re-request API any more); a retry re-runs the + // decryption in case it has arrived since. + return { + text: "This session doesn't have the key yet — it is being requested from your other devices.", + action: 'retry', + actionLabel: 'Retry', + }; + case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED: + return { + text: "The sender's identity changed since you verified them — re-verify to read this.", + action: 'none', + }; + case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE: + return { text: "Sent from a device the sender hasn't verified.", action: 'none' }; + case DecryptionFailureCode.UNKNOWN_SENDER_DEVICE: + return { text: "Sent from a device we don't know about.", action: 'none' }; + default: + return { text: 'Unable to decrypt this message.', action: 'none' }; + } +};