feat(discord-widget): render Open-in-Channels card after login via VOJO-LOGIN-SPACE-V1 sentinel and generic open-matrix-to widget action
This commit is contained in:
parent
a62761af06
commit
f104bdfe8b
11 changed files with 347 additions and 85 deletions
|
|
@ -95,6 +95,18 @@ const LinkIcon = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
// 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 = () => (
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<rect x="3.5" y="3.5" width="5.5" height="5.5" rx="1.4" />
|
||||
<rect x="11" y="3.5" width="5.5" height="5.5" rx="1.4" />
|
||||
<rect x="3.5" y="11" width="5.5" height="5.5" rx="1.4" />
|
||||
<rect x="11" y="11" width="5.5" height="5.5" rx="1.4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Linkifier — same heuristic as TG widget.
|
||||
const URL_RE = /https?:\/\/[^\s)]+/g;
|
||||
|
||||
|
|
@ -388,6 +400,13 @@ const loadHCaptcha = (): Promise<HCaptchaApi> => {
|
|||
`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
|
|||
<div class="auth-card-hint">{t('auth-card.captcha.hint')}</div>
|
||||
<div class="auth-card-captcha-frame">
|
||||
<div ref={containerRef} class="auth-card-captcha-host" />
|
||||
{loadError ? (
|
||||
<div class="auth-card-error">{t('auth-card.captcha.load-error')}</div>
|
||||
) : null}
|
||||
{loadError ? <div class="auth-card-error">{t('auth-card.captcha.load-error')}</div> : null}
|
||||
</div>
|
||||
<div class="auth-card-row">
|
||||
<button type="button" class="btn-text" onClick={onCancel}>
|
||||
|
|
@ -764,6 +781,40 @@ const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Open-space card — Vojo extension
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
type OpenSpaceCardProps = {
|
||||
t: T;
|
||||
matrixToUrl: string;
|
||||
onOpen: (url: string) => void;
|
||||
};
|
||||
|
||||
// Surfaces the personal Discord space the bridge auto-created at login.
|
||||
// Renders only when `state.spaceMatrixToUrl` is populated — i.e. against a
|
||||
// Vojo-patched bridge that emitted `VOJO-LOGIN-SPACE-V1`. Against an
|
||||
// upstream/unpatched bridge the card is absent (no sentinel, no URL, the
|
||||
// `space_ready` reducer case never fires).
|
||||
//
|
||||
// Click hands the URL to the host via the `io.vojo.bot-widget`
|
||||
// side-channel (api.openMatrixToUrl) — the widget is sandboxed and
|
||||
// can't navigate cinny itself. Host validates and routes.
|
||||
const OpenSpaceCard = ({ t, matrixToUrl, onOpen }: OpenSpaceCardProps) => (
|
||||
<button class="command-card" type="button" onClick={() => onOpen(matrixToUrl)}>
|
||||
<span class="command-card-lead-icon" aria-hidden="true">
|
||||
<SpaceGridIcon />
|
||||
</span>
|
||||
<div class="command-card-body">
|
||||
<div class="command-card-name">{t('card.open-space.name')}</div>
|
||||
<div class="command-card-desc">{t('card.open-space.desc')}</div>
|
||||
</div>
|
||||
<span class="command-card-chevron" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 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) {
|
|||
}
|
||||
/>
|
||||
<div class="command-grid">
|
||||
{/* 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 ? (
|
||||
<OpenSpaceCard
|
||||
t={t}
|
||||
matrixToUrl={state.spaceMatrixToUrl}
|
||||
onOpen={(url) => api.openMatrixToUrl(url)}
|
||||
/>
|
||||
) : null}
|
||||
<LogoutCard t={t} onConfirm={onConfirmLogout} />
|
||||
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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 ?? '<none>'}`
|
||||
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${
|
||||
event.content?.msgtype ?? '<none>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -55,13 +55,11 @@ export const EN: Record<StringKey, string> = {
|
|||
'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<StringKey, string> = {
|
|||
'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…',
|
||||
|
|
|
|||
|
|
@ -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': 'Проверяю статус подключения…',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 `<commandPrefix> ` (trailing space).
|
||||
// Legacy mautrix-discord routes management-room commands through the
|
||||
// bridge.commands.Processor in mautrix/go bridge/commands; outside the
|
||||
|
|
|
|||
|
|
@ -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 `<a target="_blank">` 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 `<a target="_blank">` 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) {
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(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<SyncState | null>(() => 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;
|
||||
|
|
|
|||
|
|
@ -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<string>(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) {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue