From 765445c091784bc3e601af6a50c24fc10420d843 Mon Sep 17 00:00:00 2001 From: heaven Date: Thu, 21 May 2026 14:08:50 +0300 Subject: [PATCH] feat(discord-widget): render Open-in-Channels card after login via VOJO-LOGIN-SPACE-V1 sentinel and generic open-matrix-to widget action --- apps/widget-discord/src/App.tsx | 92 +++++++++++++++++-- .../bridge-protocol/dialects/legacy_v076.ts | 59 ++++++++---- .../src/bridge-protocol/types.ts | 9 ++ apps/widget-discord/src/i18n/en.ts | 9 +- apps/widget-discord/src/i18n/ru.ts | 7 +- apps/widget-discord/src/state.ts | 51 ++++++---- apps/widget-discord/src/widget-api.ts | 21 +++++ src/app/features/bots/BotWidgetEmbed.ts | 91 +++++++++++++----- src/app/features/bots/BotWidgetMount.tsx | 50 ++++++++-- src/app/features/bots/useBotWidgetEmbed.ts | 14 +++ src/app/plugins/matrix-to.ts | 29 +++++- 11 files changed, 347 insertions(+), 85 deletions(-) diff --git a/apps/widget-discord/src/App.tsx b/apps/widget-discord/src/App.tsx index 55ee2258..ebd1cb1e 100644 --- a/apps/widget-discord/src/App.tsx +++ b/apps/widget-discord/src/App.tsx @@ -95,6 +95,18 @@ const LinkIcon = () => ( ); +// 2×2 grid of rounded squares — leads the OpenSpaceCard. Reads as +// «space with channels inside»; consistent visual vocabulary with the +// channels-tab workspace grid affordances on the host side. +const SpaceGridIcon = () => ( + +); + // Linkifier — same heuristic as TG widget. const URL_RE = /https?:\/\/[^\s)]+/g; @@ -388,6 +400,13 @@ const loadHCaptcha = (): Promise => { `script[src^="https://js.hcaptcha.com/1/api.js"]` ) as HTMLScriptElement | null; + // `timeoutHandle` is read in the `settle` closure declared below + // BEFORE the assignment at the bottom of this function. ESLint's + // flow analysis can't see the deferred assignment through the + // closure and flags this as never-reassigned; in practice the value + // IS reassigned and using `const` here would break the hcaptcha + // script-load timeout path. + // eslint-disable-next-line prefer-const let timeoutHandle: number | undefined; let settled = false; const settle = (action: () => void) => { @@ -599,9 +618,7 @@ const CaptchaPanel = ({ state, t, onSolved, onCancel, onExpired }: CaptchaPanelP
{t('auth-card.captcha.hint')}
- {loadError ? ( -
{t('auth-card.captcha.load-error')}
- ) : null} + {loadError ?
{t('auth-card.captcha.load-error')}
: null}
+); + // -------------------------------------------------------------------------- // Main App // -------------------------------------------------------------------------- @@ -927,6 +978,15 @@ export function App({ bootstrap, api }: Props) { // hydrate too; the live path treats it identically. append({ kind: 'diag', text: t('diag.captcha-issued') }); appendedAnyHistory = true; + } else if (parsed.kind === 'space_ready') { + // VOJO-LOGIN-SPACE-V1 sentinel body is a JSON blob — + // machine-readable, never user-readable. Suppress the raw + // body from the transcript and emit a diag breadcrumb + // instead so a reload-replay shows «space ready» rather + // than `VOJO-LOGIN-SPACE-V1 {"matrix_to_url":"…"}` ugly + // verbatim. Same discipline as the captcha branch above. + append({ kind: 'diag', text: t('diag.space-ready') }); + appendedAnyHistory = true; } else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') { // m.text / m.notice — body is safe to replay verbatim, // BUT we still scrub any login-URL-shaped substring as @@ -989,10 +1049,7 @@ export function App({ bootstrap, api }: Props) { append({ kind: 'diag', text: t('diag.qr-issued') }); } else if (event.kind === 'qr_redacted') { const liveState = stateRef.current; - if ( - liveState.kind === 'awaiting_qr_scan' && - liveState.qrEventId === event.redactsEventId - ) { + if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) { append({ kind: 'diag', text: t('diag.qr-consumed') }); } } else if (event.kind === 'captcha_challenge') { @@ -1001,6 +1058,12 @@ export function App({ bootstrap, api }: Props) { // transcript DOM (where screenshots / accessibility tools could // leak them). Diag-only display. append({ kind: 'diag', text: t('diag.captcha-issued') }); + } else if (event.kind === 'space_ready') { + // Sentinel body is the JSON `{"matrix_to_url":"…"}` — not human- + // readable and pointless to show verbatim. Emit a diag breadcrumb; + // the actual «Open in Channels» card is rendered by the reducer + // attaching `spaceMatrixToUrl` to the connected state. + append({ kind: 'diag', text: t('diag.space-ready') }); } else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') { const body = ev.content.body ?? ''; append({ kind: 'from-bot', text: `← ${scrubLoginSecret(body)}` }); @@ -1185,9 +1248,7 @@ export function App({ bootstrap, api }: Props) { // entry, but a manual disconnect path could leave us in connected // and trigger reconnect from there). const handle = - state.kind === 'connected_dead' || state.kind === 'connected' - ? state.handle - : undefined; + state.kind === 'connected_dead' || state.kind === 'connected' ? state.handle : undefined; dispatch({ kind: 'request_reconnect', handle }); try { await sendBare('reconnect'); @@ -1353,6 +1414,17 @@ export function App({ bootstrap, api }: Props) { } />
+ {/* Open-space CTA — only against a Vojo-patched bridge that + * emitted the VOJO-LOGIN-SPACE-V1 sentinel. Listed first so a + * fresh post-login user sees «next step» before the Logout + * destructive action. */} + {state.spaceMatrixToUrl ? ( + api.openMatrixToUrl(url)} + /> + ) : null} setAboutOpen(true)} />
diff --git a/apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts b/apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts index d6fc1467..311bb89e 100644 --- a/apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts +++ b/apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts @@ -56,6 +56,15 @@ const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i; const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1'; const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/; +// Vojo-patched bridge emits this sentinel right after «Successfully logged +// in as @user» (commands_login_space.go::sendLoginSpaceNotice). Carries the +// matrix.to URL of the user's personal Discord space so the widget can +// render a CTA. Same markdown-inert + structured-JSON discipline as the +// captcha sentinel above; the bridge sends this via SendMessageEvent to +// bypass goldmark round-trip. +const LOGIN_SPACE_SENTINEL_PREFIX = 'VOJO-LOGIN-SPACE-V1'; +const LOGIN_SPACE_SENTINEL_RE = /^VOJO-LOGIN-SPACE-V1\s+(\{[\s\S]*\})\s*$/; + // Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED // upstream v0.7.6: «CAPTCHAs are currently not supported - use token login // instead». Kept so a deployment running unpatched bridge still produces a @@ -160,6 +169,28 @@ export const parseLegacyV076Body = (rawBody: string): LoginEvent => { } if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' }; + // Vojo login-space sentinel: structured JSON with the personal Discord + // space's matrix.to URL. Checked alongside the captcha sentinel — + // markdown-inert prefix means it lands verbatim from the bridge, parsed + // into a `space_ready` event for the reducer to attach to connected state. + // Malformed payload (missing/empty `matrix_to_url`, JSON parse failure) is + // silently dropped as `unknown` rather than surfacing a stale CTA. + if (body.startsWith(LOGIN_SPACE_SENTINEL_PREFIX)) { + const match = LOGIN_SPACE_SENTINEL_RE.exec(body); + if (match) { + try { + const payload = JSON.parse(match[1]) as Record; + const matrixToUrl = typeof payload.matrix_to_url === 'string' ? payload.matrix_to_url : ''; + if (matrixToUrl) { + return { kind: 'space_ready', matrixToUrl }; + } + } catch { + // fall through — malformed payload is treated as unknown + } + } + return { kind: 'unknown' }; + } + const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body); if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() }; @@ -247,8 +278,8 @@ export const parseEventLegacyV076 = (event: ParsableEvent): LoginEvent => { typeof event.redacts === 'string' ? event.redacts : isObject(event.content) && typeof event.content.redacts === 'string' - ? event.content.redacts - : undefined; + ? event.content.redacts + : undefined; if (!target) return { kind: 'unknown' }; return { kind: 'qr_redacted', redactsEventId: target }; } @@ -330,20 +361,11 @@ function runSanityChecks(): void { // Login success (post-QR scan). No snowflake in this line; App fires // `ping` afterwards to pick up the discordId. - [ - 'Successfully logged in as @example', - { kind: 'login_success', handle: 'example' }, - ], - [ - 'Successfully logged in as @user.name', - { kind: 'login_success', handle: 'user.name' }, - ], + ['Successfully logged in as @example', { kind: 'login_success', handle: 'example' }], + ['Successfully logged in as @user.name', { kind: 'login_success', handle: 'user.name' }], // Login failure paths. - [ - 'Error logging in: rate limited 429', - { kind: 'login_failed', reason: 'rate limited 429' }, - ], + ['Error logging in: rate limited 429', { kind: 'login_failed', reason: 'rate limited 429' }], // CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on // unpatched upstream v0.7.6. [ @@ -387,10 +409,7 @@ function runSanityChecks(): void { // Logout. ['Logged out successfully.', { kind: 'logout_ok' }], - [ - "You weren't logged in, but data was re-cleared just to be safe.", - { kind: 'logout_no_op' }, - ], + ["You weren't logged in, but data was re-cleared just to be safe.", { kind: 'logout_no_op' }], // Disconnect / reconnect. ['Successfully disconnected', { kind: 'disconnect_ok' }], @@ -521,7 +540,9 @@ function runSanityChecks(): void { // eslint-disable-next-line no-console console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected }); throw new Error( - `legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? ''}` + `legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${ + event.content?.msgtype ?? '' + }` ); } } diff --git a/apps/widget-discord/src/bridge-protocol/types.ts b/apps/widget-discord/src/bridge-protocol/types.ts index afa780a9..128ac540 100644 --- a/apps/widget-discord/src/bridge-protocol/types.ts +++ b/apps/widget-discord/src/bridge-protocol/types.ts @@ -113,6 +113,15 @@ export type LoginEvent = | { kind: 'reconnect_no_op' } | { kind: 'reconnect_failed'; reason?: string } + // --- Vojo: bridge-managed personal space --------------------------------- + // Vojo-patched bridge emits the sentinel `VOJO-LOGIN-SPACE-V1 {...}` as a + // separate m.notice right after the «Successfully logged in» line. Carries + // a `matrix.to` URL pointing at the user's auto-created Discord space + // (user.go::GetSpaceRoom on the bridge side). The widget surfaces this as + // an «Open in Channels» card; click → host navigates cinny to the space. + // See vojo-mautrix-discord/commands_login_space.go for the wire format. + | { kind: 'space_ready'; matrixToUrl: string } + // --- bridge-side errors -------------------------------------------------- // Generic «I don't know that command» — should not happen since we only // ship known commands, but visible if the bridge image is misconfigured diff --git a/apps/widget-discord/src/i18n/en.ts b/apps/widget-discord/src/i18n/en.ts index 5e742925..42182974 100644 --- a/apps/widget-discord/src/i18n/en.ts +++ b/apps/widget-discord/src/i18n/en.ts @@ -55,13 +55,11 @@ export const EN: Record = { 'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bot’s chat.', 'auth-error.captcha-send-failed': 'Could not deliver your CAPTCHA solution. Check your network and try signing in again.', - 'auth-error.captcha-expired': - 'CAPTCHA expired — tap «Sign in with QR code» and solve it again.', + 'auth-error.captcha-expired': 'CAPTCHA expired — tap «Sign in with QR code» and solve it again.', 'auth-error.login-failed': 'Sign-in failed: {reason}', 'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}', 'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}', - 'auth-error.connect-after-login-failed': - 'Signed in, but could not connect to Discord: {reason}', + 'auth-error.connect-after-login-failed': 'Signed in, but could not connect to Discord: {reason}', 'auth-error.already-logged-in': 'You are already signed in to Discord — refresh status.', 'auth-error.unknown-command': 'The bot does not recognise this command — check the prefix in config.json.', @@ -73,6 +71,9 @@ export const EN: Record = { 'card.logout.confirm-prompt': 'Sign out for real?', 'card.logout.confirm-yes': 'Sign out', 'card.logout.confirm-no': 'Cancel', + 'card.open-space.name': 'Open in Channels', + 'card.open-space.desc': 'Jump to your Discord space with all chats and servers', + 'diag.space-ready': 'Discord space ready to open.', 'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.', 'diag.ready': 'Ready to send commands.', 'diag.checking-status': 'Checking connection status…', diff --git a/apps/widget-discord/src/i18n/ru.ts b/apps/widget-discord/src/i18n/ru.ts index 7e770c21..3549215c 100644 --- a/apps/widget-discord/src/i18n/ru.ts +++ b/apps/widget-discord/src/i18n/ru.ts @@ -86,8 +86,7 @@ export const RU = { 'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.', 'auth-error.captcha-send-failed': 'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.', - 'auth-error.captcha-expired': - 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.', + 'auth-error.captcha-expired': 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.', 'auth-error.login-failed': 'Не удалось войти: {reason}', 'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}', 'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}', @@ -106,7 +105,11 @@ export const RU = { 'card.logout.confirm-prompt': 'Точно выйти?', 'card.logout.confirm-yes': 'Выйти', 'card.logout.confirm-no': 'Отмена', + // --- Open Discord space (Vojo bridge sentinel) ------------------------ + 'card.open-space.name': 'Открыть в Каналах', + 'card.open-space.desc': 'Перейти в спейс Discord со списком чатов и серверов', // --- Diagnostics in transcript ---------------------------------------- + 'diag.space-ready': 'Discord-спейс готов к открытию.', 'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.', 'diag.ready': 'Готов отправлять команды.', 'diag.checking-status': 'Проверяю статус подключения…', diff --git a/apps/widget-discord/src/state.ts b/apps/widget-discord/src/state.ts index a1c6344d..f2a02ab0 100644 --- a/apps/widget-discord/src/state.ts +++ b/apps/widget-discord/src/state.ts @@ -104,8 +104,13 @@ export type LoginState = | { kind: 'reconnecting'; handle?: string } // Live session — ping or login_success confirmed. Discord legacy bridge // doesn't have a per-account loginId concept (single Discord account - // per Matrix user), so logout doesn't need an id. - | { kind: 'connected'; handle: string; discordId?: string } + // per Matrix user), so logout doesn't need an id. `spaceMatrixToUrl` + // is populated from the Vojo `VOJO-LOGIN-SPACE-V1` sentinel that lands + // right after login_success; it survives the post-login re-ping and the + // reconnect-ok transitions so the «Open in Channels» card stays visible + // until logout. Absent until the sentinel arrives (and absent forever + // against an UNPATCHED bridge — the card simply never appears). + | { kind: 'connected'; handle: string; discordId?: string; spaceMatrixToUrl?: string } // ping says we have a token but the connection's down. Status pill: // green-ish but with a Reconnect recovery action exposed. The reducer // distinguishes `connection_dead` (Discord WS dropped) from `token_stored` @@ -120,10 +125,7 @@ export type LoginState = // staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but // often valid for a couple of minutes — fresh enough to reuse). Other // transient states (logging_out, reconnecting) deliberately don't survive. -export type HydrateRestoredState = - | PendingFormState - | CaptchaSolveState - | { kind: 'qr_verifying' }; +export type HydrateRestoredState = PendingFormState | CaptchaSolveState | { kind: 'qr_verifying' }; // Outbound user actions the App dispatches. Form-submit actions clear any // pending lastError; structural transitions optimistically advance state — @@ -169,9 +171,7 @@ const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiti const isCaptchaAcceptingState = ( s: LoginState ): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState => - s.kind === 'awaiting_qr_scan' || - s.kind === 'qr_verifying' || - s.kind === 'awaiting_captcha_solve'; + s.kind === 'awaiting_qr_scan' || s.kind === 'qr_verifying' || s.kind === 'awaiting_captcha_solve'; export const loginReducer = (state: LoginState, action: LoginAction): LoginState => { if (action.kind === 'hydrate') { @@ -266,11 +266,14 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState case 'logged_in': // Authoritative source — accept from any state. Used by both the // initial ping AND the post-`login_success` re-ping that picks up - // the discordId snowflake. + // the discordId snowflake. Preserve `spaceMatrixToUrl` from a prior + // `connected` so the post-login_success re-ping doesn't blank the + // CTA before the user gets a chance to click it. return { kind: 'connected', handle: event.handle, discordId: event.discordId, + spaceMatrixToUrl: state.kind === 'connected' ? state.spaceMatrixToUrl : undefined, }; case 'connection_dead': @@ -492,12 +495,28 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState // green with an empty handle, which the UI's // `state.handle ? connected-as : connected` ternary tolerates. // This avoids the `unknown` flap that the previous draft would - // produce when no handle was stashed. + // produce when no handle was stashed. spaceMatrixToUrl is not + // restorable from connected_dead (the dead state never carried it), + // so the CTA stays hidden until a fresh sentinel arrives — bridge + // does NOT re-emit on reconnect, but the card returns once the user + // explicitly re-logs in. if (state.kind === 'reconnecting' || state.kind === 'connected_dead') { return { kind: 'connected', handle: state.handle ?? '' }; } return state; + case 'space_ready': + // Vojo-patched bridge surfaced the personal Discord space — attach + // its matrix.to URL to the connected state so the «Open in Channels» + // card renders. Late-arriving sentinels from an abandoned flow drop + // here silently (e.g. a sentinel that lands during `logging_out` + // mustn't resurrect a connected state). Honour only from the + // canonical alive states. + if (state.kind === 'connected') { + return { ...state, spaceMatrixToUrl: event.matrixToUrl }; + } + return state; + case 'reconnect_failed': if (state.kind !== 'reconnecting') return state; // Roll back to connected_dead carrying the previous handle. The @@ -565,10 +584,7 @@ type HydrateAccumulator = { terminated: boolean; }; -const stepHydrate = ( - prevAcc: HydrateAccumulator, - input: HydrateInput -): HydrateAccumulator => { +const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateAccumulator => { const { ev, ts } = input; // After a terminal event we normally stop — except if a fresh @@ -693,9 +709,12 @@ const stepHydrate = ( case 'already_logged_in': case 'unknown': + case 'space_ready': // Soft no-op for hydrate. already_logged_in is a live-flow warning // that doesn't reflect persistent state; unknown is a wording-drift - // catch-all. + // catch-all; space_ready is a post-terminal sentinel — hydrate + // terminates on login_success and lets live ping reconcile, so + // the URL gets attached on the live path, not here. return acc; default: { diff --git a/apps/widget-discord/src/widget-api.ts b/apps/widget-discord/src/widget-api.ts index c88e1469..fbed93e9 100644 --- a/apps/widget-discord/src/widget-api.ts +++ b/apps/widget-discord/src/widget-api.ts @@ -125,6 +125,27 @@ export class WidgetApi { ); } + // Ask the host to navigate to a matrix.to URL inside the cinny app + // (room or space). Same side-channel pattern as `openExternalUrl` — + // distinct from matrix-widget-api's `fromWidget` so the SDK stays + // ignorant of this Vojo extension. The host validates the URL via + // `parseMatrixToRoom` (rejecting non-room URLs, javascript:/data:, etc.) + // BEFORE routing into the react-router; sending anything that isn't a + // matrix.to/#/!roomId or matrix.to/#/#alias URL silently no-ops on the + // host side. The widget is responsible for only invoking this when it + // genuinely has a matrix.to room URL (e.g. parsed from a bridge + // sentinel). + public openMatrixToUrl(url: string): void { + window.parent.postMessage( + { + api: 'io.vojo.bot-widget', + action: 'open-matrix-to', + data: { url }, + }, + this.bootstrap.parentOrigin + ); + } + // Always prefix outbound commands with ` ` (trailing space). // Legacy mautrix-discord routes management-room commands through the // bridge.commands.Processor in mautrix/go bridge/commands; outside the diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts index 54090bde..e59e3ae5 100644 --- a/src/app/features/bots/BotWidgetEmbed.ts +++ b/src/app/features/bots/BotWidgetEmbed.ts @@ -18,6 +18,7 @@ import { } from 'matrix-widget-api'; import { Theme } from '../../hooks/useTheme'; import { openExternalUrl } from '../../utils/capacitor'; +import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to'; import type { BotPreset } from './catalog'; import { BotWidgetDriver, @@ -34,6 +35,14 @@ export type BotWidgetEmbedOptions = { language: string; onError: (error: Error) => void; onReady?: () => void; + // Optional generic «navigate cinny to a matrix.to room/alias» callback. + // Plumbed from `BotWidgetMount` where react-router's `useNavigate` is + // available. The embed validates the URL via `parseMatrixToRoom` BEFORE + // calling — handler receives an already-parsed `{roomIdOrAlias, viaServers}` + // and is free to assume the inputs are well-formed Matrix references. Not + // bot-aware: any widget that delivers a matrix.to URL via the side-channel + // (`open-matrix-to` action) reaches the same handler. + onOpenMatrixToRoom?: (target: MatrixToRoom) => void; }; const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`; @@ -214,22 +223,30 @@ export class BotWidgetEmbed { this.feedStateUpdate(ev); }; - // Side-channel postMessage handler for the widget's `openExternalUrl` - // call. Distinct from matrix-widget-api's `fromWidget` channel + // Side-channel postMessage handler for the widget's Vojo-extension + // actions. Distinct from matrix-widget-api's `fromWidget` channel // (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it // doesn't go through ClientWidgetApi at all — keeps the SDK ignorant // of our extension and avoids the «unknown action» reply path. // - // Why this exists: the host's global `setupExternalLinkHandler` - // (utils/capacitor.ts) intercepts `` clicks at - // the host document level and routes them via Capacitor's Browser - // plugin. But cross-origin iframes don't bubble click events into - // the parent document, so widget-side links are invisible to it — - // on Capacitor's Android WebView those clicks silently disappear. - // The widget posts this message; we validate the URL and forward - // to the same `openExternalUrl` helper the host uses elsewhere. + // Two actions today: // - // Security gates (defence in depth): + // * `open-external-url` — forwards an https:// URL to the host's + // `openExternalUrl` (utils/capacitor.ts), which routes through + // Capacitor's Browser plugin on native and `window.open` on web. + // Exists because cross-origin iframes don't bubble click events + // to the host document, so the global `setupExternalLinkHandler` + // never sees widget-side `` clicks — on + // Capacitor's Android WebView those would silently disappear. + // + // * `open-matrix-to` — generic «navigate cinny to a matrix.to room + // or alias». Validates the URL through the same `parseMatrixToRoom` + // cinny uses for in-app mention rendering, then hands the parsed + // `MatrixToRoom` to `options.onOpenMatrixToRoom` (composed by + // BotWidgetMount with `useNavigate` + `getChannelsSpacePath`). The + // widget never sees a route — it only knows matrix.to URLs. + // + // Security gates (defence in depth, apply to BOTH actions): // 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this // check, a compromised widget bundle could `window.location.href // = 'https://attacker.example/'` — the browser keeps the same @@ -242,11 +259,17 @@ export class BotWidgetEmbed { // iframe of the SAME origin — e.g. an ad embed loaded into a // sibling frame on the same origin in a future deployment — // could otherwise pass the origin check). - // 3. Only https URLs are honoured. We tightened from http+https to - // https-only because no shipped widget content links over plain - // http; rejecting http closes a cleartext-redirect vector via - // Capacitor `Browser.open` on Android. - // 4. javascript:, data:, file:, etc. are implicitly rejected by (3). + // + // Per-action URL validation (NOT shared, but each branch enforces): + // * `open-external-url` — requires `https:` protocol, rejecting plain + // http, javascript:, data:, file:, etc. We tightened from http+https + // to https-only because no shipped widget content links over plain + // http; rejecting http closes a cleartext-redirect vector via + // Capacitor `Browser.open` on Android. + // * `open-matrix-to` — requires the URL to parse as a matrix.to room + // or alias via `parseMatrixToRoom`. Anything else (matrix.to user + // links, event links, arbitrary https URLs, javascript:/data:/file: + // pseudo-schemes) returns undefined and silently no-ops. private readonly onWidgetMessage = (ev: MessageEvent) => { if (ev.origin !== this.widgetOrigin) return; if (ev.source !== this.iframe.contentWindow) return; @@ -255,18 +278,38 @@ export class BotWidgetEmbed { | undefined; if (!msg || typeof msg !== 'object') return; if (msg.api !== 'io.vojo.bot-widget') return; - if (msg.action !== 'open-external-url') return; const url = msg.data?.url; if (typeof url !== 'string') return; - try { - const parsed = new URL(url); - if (parsed.protocol !== 'https:') return; - } catch { + + if (msg.action === 'open-external-url') { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') return; + } catch { + return; + } + openExternalUrl(url).catch(() => { + /* fire-and-forget: log handled inside openExternalUrl */ + }); return; } - openExternalUrl(url).catch(() => { - /* fire-and-forget: log handled inside openExternalUrl */ - }); + + if (msg.action === 'open-matrix-to') { + // Generic «navigate cinny to a matrix.to room/alias». Not bot-aware — + // the widget hands over a matrix.to URL it obtained however (parsed + // from a bridge sentinel, scraped from chat, whatever), and we + // validate via the same `parseMatrixToRoom` cinny uses for in-app + // mention rendering (plugins/react-custom-html-parser.tsx). Only the + // matrix.to/#/!roomId and matrix.to/#/#alias shapes pass — user + // links, event links, non-matrix.to URLs, javascript:/data:/etc. all + // return undefined and silently no-op here. The host-side router + // hop (`onOpenMatrixToRoom`) is the optional caller — embedded code + // paths that don't provide a callback (e.g. future test harness) get + // a silent drop, not a crash. + const parsed = parseMatrixToRoom(url); + if (!parsed) return; + this.options.onOpenMatrixToRoom?.(parsed); + } }; public constructor(private readonly options: BotWidgetEmbedOptions) { diff --git a/src/app/features/bots/BotWidgetMount.tsx b/src/app/features/bots/BotWidgetMount.tsx index 78ff0fa9..fe2e50ef 100644 --- a/src/app/features/bots/BotWidgetMount.tsx +++ b/src/app/features/bots/BotWidgetMount.tsx @@ -1,8 +1,16 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Room, SyncState } from 'matrix-js-sdk'; import type { BotPreset } from './catalog'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useSyncState } from '../../hooks/useSyncState'; +import { + getCanonicalAliasOrRoomId, + getCanonicalAliasRoomId, + isRoomAlias, +} from '../../utils/matrix'; +import { getChannelsSpacePath } from '../../pages/pathUtils'; +import type { MatrixToRoom } from '../../plugins/matrix-to'; import { useBotWidgetEmbed } from './useBotWidgetEmbed'; import * as css from './BotWidgetMount.css'; @@ -34,15 +42,46 @@ type BotWidgetMountProps = { export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { const containerRef = useRef(null); - const { ready } = useBotWidgetEmbed({ containerRef, preset, room, onError }); + const navigate = useNavigate(); + const mx = useMatrixClient(); + + // Generic «navigate cinny to a matrix.to room/alias». Bot-agnostic: any + // widget that posts `{action: 'open-matrix-to', data: {url}}` on the + // `io.vojo.bot-widget` side-channel reaches this. The embed has already + // validated the URL via `parseMatrixToRoom` so `target` is well-formed. + // For an alias we resolve to the canonical room id first — the channels + // path expects an id-or-alias either way, but joined-room lookup needs + // the id form for the via-server hint to be effective. `viaServers` are + // currently dropped (the channels view doesn't propagate them); add a + // dedicated «join-via» path if a future widget needs to surface a room + // the user hasn't joined yet. + const handleOpenMatrixToRoom = useCallback( + (target: MatrixToRoom) => { + const { roomIdOrAlias } = target; + const idOrAlias = isRoomAlias(roomIdOrAlias) + ? getCanonicalAliasRoomId(mx, roomIdOrAlias) ?? roomIdOrAlias + : roomIdOrAlias; + const canonical = getCanonicalAliasOrRoomId(mx, idOrAlias); + navigate(getChannelsSpacePath(canonical)); + }, + [mx, navigate] + ); + + const { ready } = useBotWidgetEmbed({ + containerRef, + preset, + room, + onError, + onOpenMatrixToRoom: handleOpenMatrixToRoom, + }); // Track Matrix sync state so the bot loading bar yields to the global // SyncIndicator when the connection is unhealthy. Without this, on a // dropped network the user would see TWO sweeping bars at once — the // bot bar at top stuck in «still loading» plus the SyncIndicator at // bottom in transient/error state. The bottom bar is the canonical - // connection-state surface; the top one defers. - const mx = useMatrixClient(); + // connection-state surface; the top one defers. Reuses `mx` from the + // navigate-callback block above — single hook call per render. const [syncState, setSyncState] = useState(() => mx.getSyncState()); useSyncState( mx, @@ -106,10 +145,7 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { // SyncIndicator can take over without two bars overlapping. // Reduced-motion: animation is off (no iterations ever land), so // parking a static stripe for ~2s isn't graceful, just stuck. - if ( - hideReason === 'sync' || - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ) { + if (hideReason === 'sync' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { setVisible(false); setPendingHide(false); return undefined; diff --git a/src/app/features/bots/useBotWidgetEmbed.ts b/src/app/features/bots/useBotWidgetEmbed.ts index 0dba6b3d..e61ffb63 100644 --- a/src/app/features/bots/useBotWidgetEmbed.ts +++ b/src/app/features/bots/useBotWidgetEmbed.ts @@ -3,6 +3,7 @@ import { Room } from 'matrix-js-sdk'; import { useTranslation } from 'react-i18next'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { Theme, useTheme } from '../../hooks/useTheme'; +import type { MatrixToRoom } from '../../plugins/matrix-to'; import type { BotPreset } from './catalog'; import { BotWidgetEmbed } from './BotWidgetEmbed'; @@ -11,6 +12,9 @@ type UseBotWidgetEmbedOptions = { preset: BotPreset; room: Room; onError: () => void; + // Forwarded into the embed. Plumbed from `BotWidgetMount` where the + // react-router context is available — the hook stays unaware of routing. + onOpenMatrixToRoom?: (target: MatrixToRoom) => void; }; type UseBotWidgetEmbedResult = { @@ -30,6 +34,7 @@ export const useBotWidgetEmbed = ({ preset, room, onError, + onOpenMatrixToRoom, }: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => { const { i18n } = useTranslation(); const mx = useMatrixClient(); @@ -43,6 +48,12 @@ export const useBotWidgetEmbed = ({ themeRef.current = theme; const languageRef = useRef(i18n.language); languageRef.current = i18n.language; + // Same indirection for `onOpenMatrixToRoom`: the callback identity + // typically changes per render (closes over `navigate`/`mx`), and we do + // NOT want that to remount the embed. The ref carries the latest fn; the + // embed only sees a stable shim that re-reads it. + const onOpenMatrixToRoomRef = useRef(onOpenMatrixToRoom); + onOpenMatrixToRoomRef.current = onOpenMatrixToRoom; // Depend on primitive identity for the embed lifecycle — using `preset` // directly would remount the iframe (and re-handshake with the widget) @@ -72,6 +83,9 @@ export const useBotWidgetEmbed = ({ language: languageRef.current, onError, onReady: () => setReady(true), + // Indirection so the embed lifecycle doesn't reset when the + // navigate-callback closes over a new render's `mx`/`navigate`. + onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target), }); embedRef.current = embed; } catch (error) { diff --git a/src/app/plugins/matrix-to.ts b/src/app/plugins/matrix-to.ts index 03a7d2c1..ace311c9 100644 --- a/src/app/plugins/matrix-to.ts +++ b/src/app/plugins/matrix-to.ts @@ -41,20 +41,43 @@ export type MatrixToRoomEvent = MatrixToRoom & { const MATRIX_TO = /^https?:\/\/matrix\.to\S*$/; export const testMatrixTo = (href: string): boolean => MATRIX_TO.test(href); +// Matrix room IDs start with `!` (and aliases with `#`) — characters that +// some URL builders percent-encode in path segments. Go's `id.MatrixURI` +// builder (mautrix-go id/matrixuri.go) uses `url.PathEscape`, which emits +// `%21` for `!` — so every matrix.to URL produced by a mautrix bridge +// arrives here as `https://matrix.to/#/%21abc:server`. Our regexes below +// match literal `!`/`#` only, so without a decode pass every bridge- +// generated permalink would silently fail to parse — both the in-chat +// linkifier (`plugins/react-custom-html-parser.tsx`) and the widget +// «open-matrix-to» action would drop the URL on the floor. +// +// Element Web does the same `decodeURIComponent` step before parsing in +// `apps/web/src/utils/permalinks/Permalinks.ts::parsePermalink`; we +// mirror that contract here. `decodeURIComponent` throws synchronously on +// malformed `%XX` sequences (e.g. lone `%`), so wrap it; a malformed URL +// is dropped the same way as a non-matching one (undefined). +const tryDecodeHref = (href: string): string => { + try { + return decodeURIComponent(href); + } catch { + return href; + } +}; + const MATRIX_TO_USER = /^https?:\/\/matrix\.to\/#\/(@[^:\s]+:[^?/\s]+)\/?$/; const MATRIX_TO_ROOM = /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/?(\?[\S]*)?$/; const MATRIX_TO_ROOM_EVENT = /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/(\$[^?/\s]+)\/?(\?[\S]*)?$/; export const parseMatrixToUser = (href: string): string | undefined => { - const match = href.match(MATRIX_TO_USER); + const match = tryDecodeHref(href).match(MATRIX_TO_USER); if (!match) return undefined; const userId = match[1]; return userId; }; export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => { - const match = href.match(MATRIX_TO_ROOM); + const match = tryDecodeHref(href).match(MATRIX_TO_ROOM); if (!match) return undefined; const roomIdOrAlias = match[1]; @@ -68,7 +91,7 @@ export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => { }; export const parseMatrixToRoomEvent = (href: string): MatrixToRoomEvent | undefined => { - const match = href.match(MATRIX_TO_ROOM_EVENT); + const match = tryDecodeHref(href).match(MATRIX_TO_ROOM_EVENT); if (!match) return undefined; const roomIdOrAlias = match[1];