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
408b9eefc3
commit
765445c091
11 changed files with 347 additions and 85 deletions
|
|
@ -95,6 +95,18 @@ const LinkIcon = () => (
|
||||||
</svg>
|
</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.
|
// Linkifier — same heuristic as TG widget.
|
||||||
const URL_RE = /https?:\/\/[^\s)]+/g;
|
const URL_RE = /https?:\/\/[^\s)]+/g;
|
||||||
|
|
||||||
|
|
@ -388,6 +400,13 @@ const loadHCaptcha = (): Promise<HCaptchaApi> => {
|
||||||
`script[src^="https://js.hcaptcha.com/1/api.js"]`
|
`script[src^="https://js.hcaptcha.com/1/api.js"]`
|
||||||
) as HTMLScriptElement | null;
|
) 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 timeoutHandle: number | undefined;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const settle = (action: () => void) => {
|
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-hint">{t('auth-card.captcha.hint')}</div>
|
||||||
<div class="auth-card-captcha-frame">
|
<div class="auth-card-captcha-frame">
|
||||||
<div ref={containerRef} class="auth-card-captcha-host" />
|
<div ref={containerRef} class="auth-card-captcha-host" />
|
||||||
{loadError ? (
|
{loadError ? <div class="auth-card-error">{t('auth-card.captcha.load-error')}</div> : null}
|
||||||
<div class="auth-card-error">{t('auth-card.captcha.load-error')}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-card-row">
|
<div class="auth-card-row">
|
||||||
<button type="button" class="btn-text" onClick={onCancel}>
|
<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
|
// Main App
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
@ -927,6 +978,15 @@ export function App({ bootstrap, api }: Props) {
|
||||||
// hydrate too; the live path treats it identically.
|
// hydrate too; the live path treats it identically.
|
||||||
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
||||||
appendedAnyHistory = true;
|
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') {
|
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
|
||||||
// m.text / m.notice — body is safe to replay verbatim,
|
// m.text / m.notice — body is safe to replay verbatim,
|
||||||
// BUT we still scrub any login-URL-shaped substring as
|
// 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') });
|
append({ kind: 'diag', text: t('diag.qr-issued') });
|
||||||
} else if (event.kind === 'qr_redacted') {
|
} else if (event.kind === 'qr_redacted') {
|
||||||
const liveState = stateRef.current;
|
const liveState = stateRef.current;
|
||||||
if (
|
if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
|
||||||
liveState.kind === 'awaiting_qr_scan' &&
|
|
||||||
liveState.qrEventId === event.redactsEventId
|
|
||||||
) {
|
|
||||||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||||
}
|
}
|
||||||
} else if (event.kind === 'captcha_challenge') {
|
} else if (event.kind === 'captcha_challenge') {
|
||||||
|
|
@ -1001,6 +1058,12 @@ export function App({ bootstrap, api }: Props) {
|
||||||
// transcript DOM (where screenshots / accessibility tools could
|
// transcript DOM (where screenshots / accessibility tools could
|
||||||
// leak them). Diag-only display.
|
// leak them). Diag-only display.
|
||||||
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
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') {
|
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
|
||||||
const body = ev.content.body ?? '';
|
const body = ev.content.body ?? '';
|
||||||
append({ kind: 'from-bot', text: `← ${scrubLoginSecret(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
|
// entry, but a manual disconnect path could leave us in connected
|
||||||
// and trigger reconnect from there).
|
// and trigger reconnect from there).
|
||||||
const handle =
|
const handle =
|
||||||
state.kind === 'connected_dead' || state.kind === 'connected'
|
state.kind === 'connected_dead' || state.kind === 'connected' ? state.handle : undefined;
|
||||||
? state.handle
|
|
||||||
: undefined;
|
|
||||||
dispatch({ kind: 'request_reconnect', handle });
|
dispatch({ kind: 'request_reconnect', handle });
|
||||||
try {
|
try {
|
||||||
await sendBare('reconnect');
|
await sendBare('reconnect');
|
||||||
|
|
@ -1353,6 +1414,17 @@ export function App({ bootstrap, api }: Props) {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div class="command-grid">
|
<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} />
|
<LogoutCard t={t} onConfirm={onConfirmLogout} />
|
||||||
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
||||||
</div>
|
</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_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1';
|
||||||
const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/;
|
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
|
// Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED
|
||||||
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
|
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
|
||||||
// instead». Kept so a deployment running unpatched bridge still produces a
|
// 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' };
|
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);
|
const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body);
|
||||||
if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() };
|
if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() };
|
||||||
|
|
||||||
|
|
@ -330,20 +361,11 @@ function runSanityChecks(): void {
|
||||||
|
|
||||||
// Login success (post-QR scan). No snowflake in this line; App fires
|
// Login success (post-QR scan). No snowflake in this line; App fires
|
||||||
// `ping` afterwards to pick up the discordId.
|
// `ping` afterwards to pick up the discordId.
|
||||||
[
|
['Successfully logged in as @example', { kind: 'login_success', handle: 'example' }],
|
||||||
'Successfully logged in as @example',
|
['Successfully logged in as @user.name', { kind: 'login_success', handle: 'user.name' }],
|
||||||
{ kind: 'login_success', handle: 'example' },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'Successfully logged in as @user.name',
|
|
||||||
{ kind: 'login_success', handle: 'user.name' },
|
|
||||||
],
|
|
||||||
|
|
||||||
// Login failure paths.
|
// 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
|
// CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on
|
||||||
// unpatched upstream v0.7.6.
|
// unpatched upstream v0.7.6.
|
||||||
[
|
[
|
||||||
|
|
@ -387,10 +409,7 @@ function runSanityChecks(): void {
|
||||||
|
|
||||||
// Logout.
|
// Logout.
|
||||||
['Logged out successfully.', { kind: 'logout_ok' }],
|
['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.
|
// Disconnect / reconnect.
|
||||||
['Successfully disconnected', { kind: 'disconnect_ok' }],
|
['Successfully disconnected', { kind: 'disconnect_ok' }],
|
||||||
|
|
@ -521,7 +540,9 @@ function runSanityChecks(): void {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected });
|
console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected });
|
||||||
throw new Error(
|
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_no_op' }
|
||||||
| { kind: 'reconnect_failed'; reason?: string }
|
| { 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 --------------------------------------------------
|
// --- bridge-side errors --------------------------------------------------
|
||||||
// Generic «I don't know that command» — should not happen since we only
|
// Generic «I don't know that command» — should not happen since we only
|
||||||
// ship known commands, but visible if the bridge image is misconfigured
|
// 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.',
|
'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':
|
'auth-error.captcha-send-failed':
|
||||||
'Could not deliver your CAPTCHA solution. Check your network and try signing in again.',
|
'Could not deliver your CAPTCHA solution. Check your network and try signing in again.',
|
||||||
'auth-error.captcha-expired':
|
'auth-error.captcha-expired': 'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
|
||||||
'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
|
|
||||||
'auth-error.login-failed': 'Sign-in failed: {reason}',
|
'auth-error.login-failed': 'Sign-in failed: {reason}',
|
||||||
'auth-error.prepare-failed': 'Failed to prepare sign-in: {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.websocket-failed': 'Could not connect to the sign-in server: {reason}',
|
||||||
'auth-error.connect-after-login-failed':
|
'auth-error.connect-after-login-failed': 'Signed in, but could not connect to Discord: {reason}',
|
||||||
'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.already-logged-in': 'You are already signed in to Discord — refresh status.',
|
||||||
'auth-error.unknown-command':
|
'auth-error.unknown-command':
|
||||||
'The bot does not recognise this command — check the prefix in config.json.',
|
'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-prompt': 'Sign out for real?',
|
||||||
'card.logout.confirm-yes': 'Sign out',
|
'card.logout.confirm-yes': 'Sign out',
|
||||||
'card.logout.confirm-no': 'Cancel',
|
'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.connecting': 'Connecting to Vojo… awaiting capability handshake.',
|
||||||
'diag.ready': 'Ready to send commands.',
|
'diag.ready': 'Ready to send commands.',
|
||||||
'diag.checking-status': 'Checking connection status…',
|
'diag.checking-status': 'Checking connection status…',
|
||||||
|
|
|
||||||
|
|
@ -86,8 +86,7 @@ export const RU = {
|
||||||
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
|
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
|
||||||
'auth-error.captcha-send-failed':
|
'auth-error.captcha-send-failed':
|
||||||
'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.',
|
'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.',
|
||||||
'auth-error.captcha-expired':
|
'auth-error.captcha-expired': 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
|
||||||
'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
|
|
||||||
'auth-error.login-failed': 'Не удалось войти: {reason}',
|
'auth-error.login-failed': 'Не удалось войти: {reason}',
|
||||||
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
||||||
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
|
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
|
||||||
|
|
@ -106,7 +105,11 @@ export const RU = {
|
||||||
'card.logout.confirm-prompt': 'Точно выйти?',
|
'card.logout.confirm-prompt': 'Точно выйти?',
|
||||||
'card.logout.confirm-yes': 'Выйти',
|
'card.logout.confirm-yes': 'Выйти',
|
||||||
'card.logout.confirm-no': 'Отмена',
|
'card.logout.confirm-no': 'Отмена',
|
||||||
|
// --- Open Discord space (Vojo bridge sentinel) ------------------------
|
||||||
|
'card.open-space.name': 'Открыть в Каналах',
|
||||||
|
'card.open-space.desc': 'Перейти в спейс Discord со списком чатов и серверов',
|
||||||
// --- Diagnostics in transcript ----------------------------------------
|
// --- Diagnostics in transcript ----------------------------------------
|
||||||
|
'diag.space-ready': 'Discord-спейс готов к открытию.',
|
||||||
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
|
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
|
||||||
'diag.ready': 'Готов отправлять команды.',
|
'diag.ready': 'Готов отправлять команды.',
|
||||||
'diag.checking-status': 'Проверяю статус подключения…',
|
'diag.checking-status': 'Проверяю статус подключения…',
|
||||||
|
|
|
||||||
|
|
@ -104,8 +104,13 @@ export type LoginState =
|
||||||
| { kind: 'reconnecting'; handle?: string }
|
| { kind: 'reconnecting'; handle?: string }
|
||||||
// Live session — ping or login_success confirmed. Discord legacy bridge
|
// Live session — ping or login_success confirmed. Discord legacy bridge
|
||||||
// doesn't have a per-account loginId concept (single Discord account
|
// doesn't have a per-account loginId concept (single Discord account
|
||||||
// per Matrix user), so logout doesn't need an id.
|
// per Matrix user), so logout doesn't need an id. `spaceMatrixToUrl`
|
||||||
| { kind: 'connected'; handle: string; discordId?: string }
|
// 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:
|
// ping says we have a token but the connection's down. Status pill:
|
||||||
// green-ish but with a Reconnect recovery action exposed. The reducer
|
// green-ish but with a Reconnect recovery action exposed. The reducer
|
||||||
// distinguishes `connection_dead` (Discord WS dropped) from `token_stored`
|
// 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
|
// staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but
|
||||||
// often valid for a couple of minutes — fresh enough to reuse). Other
|
// often valid for a couple of minutes — fresh enough to reuse). Other
|
||||||
// transient states (logging_out, reconnecting) deliberately don't survive.
|
// transient states (logging_out, reconnecting) deliberately don't survive.
|
||||||
export type HydrateRestoredState =
|
export type HydrateRestoredState = PendingFormState | CaptchaSolveState | { kind: 'qr_verifying' };
|
||||||
| PendingFormState
|
|
||||||
| CaptchaSolveState
|
|
||||||
| { kind: 'qr_verifying' };
|
|
||||||
|
|
||||||
// Outbound user actions the App dispatches. Form-submit actions clear any
|
// Outbound user actions the App dispatches. Form-submit actions clear any
|
||||||
// pending lastError; structural transitions optimistically advance state —
|
// pending lastError; structural transitions optimistically advance state —
|
||||||
|
|
@ -169,9 +171,7 @@ const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiti
|
||||||
const isCaptchaAcceptingState = (
|
const isCaptchaAcceptingState = (
|
||||||
s: LoginState
|
s: LoginState
|
||||||
): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState =>
|
): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState =>
|
||||||
s.kind === 'awaiting_qr_scan' ||
|
s.kind === 'awaiting_qr_scan' || s.kind === 'qr_verifying' || s.kind === 'awaiting_captcha_solve';
|
||||||
s.kind === 'qr_verifying' ||
|
|
||||||
s.kind === 'awaiting_captcha_solve';
|
|
||||||
|
|
||||||
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
|
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
|
||||||
if (action.kind === 'hydrate') {
|
if (action.kind === 'hydrate') {
|
||||||
|
|
@ -266,11 +266,14 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
||||||
case 'logged_in':
|
case 'logged_in':
|
||||||
// Authoritative source — accept from any state. Used by both the
|
// Authoritative source — accept from any state. Used by both the
|
||||||
// initial ping AND the post-`login_success` re-ping that picks up
|
// 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 {
|
return {
|
||||||
kind: 'connected',
|
kind: 'connected',
|
||||||
handle: event.handle,
|
handle: event.handle,
|
||||||
discordId: event.discordId,
|
discordId: event.discordId,
|
||||||
|
spaceMatrixToUrl: state.kind === 'connected' ? state.spaceMatrixToUrl : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'connection_dead':
|
case 'connection_dead':
|
||||||
|
|
@ -492,12 +495,28 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
||||||
// green with an empty handle, which the UI's
|
// green with an empty handle, which the UI's
|
||||||
// `state.handle ? connected-as : connected` ternary tolerates.
|
// `state.handle ? connected-as : connected` ternary tolerates.
|
||||||
// This avoids the `unknown` flap that the previous draft would
|
// 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') {
|
if (state.kind === 'reconnecting' || state.kind === 'connected_dead') {
|
||||||
return { kind: 'connected', handle: state.handle ?? '' };
|
return { kind: 'connected', handle: state.handle ?? '' };
|
||||||
}
|
}
|
||||||
return state;
|
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':
|
case 'reconnect_failed':
|
||||||
if (state.kind !== 'reconnecting') return state;
|
if (state.kind !== 'reconnecting') return state;
|
||||||
// Roll back to connected_dead carrying the previous handle. The
|
// Roll back to connected_dead carrying the previous handle. The
|
||||||
|
|
@ -565,10 +584,7 @@ type HydrateAccumulator = {
|
||||||
terminated: boolean;
|
terminated: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stepHydrate = (
|
const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateAccumulator => {
|
||||||
prevAcc: HydrateAccumulator,
|
|
||||||
input: HydrateInput
|
|
||||||
): HydrateAccumulator => {
|
|
||||||
const { ev, ts } = input;
|
const { ev, ts } = input;
|
||||||
|
|
||||||
// After a terminal event we normally stop — except if a fresh
|
// After a terminal event we normally stop — except if a fresh
|
||||||
|
|
@ -693,9 +709,12 @@ const stepHydrate = (
|
||||||
|
|
||||||
case 'already_logged_in':
|
case 'already_logged_in':
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
|
case 'space_ready':
|
||||||
// Soft no-op for hydrate. already_logged_in is a live-flow warning
|
// Soft no-op for hydrate. already_logged_in is a live-flow warning
|
||||||
// that doesn't reflect persistent state; unknown is a wording-drift
|
// 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;
|
return acc;
|
||||||
|
|
||||||
default: {
|
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).
|
// Always prefix outbound commands with `<commandPrefix> ` (trailing space).
|
||||||
// Legacy mautrix-discord routes management-room commands through the
|
// Legacy mautrix-discord routes management-room commands through the
|
||||||
// bridge.commands.Processor in mautrix/go bridge/commands; outside the
|
// bridge.commands.Processor in mautrix/go bridge/commands; outside the
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
} from 'matrix-widget-api';
|
} from 'matrix-widget-api';
|
||||||
import { Theme } from '../../hooks/useTheme';
|
import { Theme } from '../../hooks/useTheme';
|
||||||
import { openExternalUrl } from '../../utils/capacitor';
|
import { openExternalUrl } from '../../utils/capacitor';
|
||||||
|
import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to';
|
||||||
import type { BotPreset } from './catalog';
|
import type { BotPreset } from './catalog';
|
||||||
import {
|
import {
|
||||||
BotWidgetDriver,
|
BotWidgetDriver,
|
||||||
|
|
@ -34,6 +35,14 @@ export type BotWidgetEmbedOptions = {
|
||||||
language: string;
|
language: string;
|
||||||
onError: (error: Error) => void;
|
onError: (error: Error) => void;
|
||||||
onReady?: () => 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}`;
|
const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
|
||||||
|
|
@ -214,22 +223,30 @@ export class BotWidgetEmbed {
|
||||||
this.feedStateUpdate(ev);
|
this.feedStateUpdate(ev);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Side-channel postMessage handler for the widget's `openExternalUrl`
|
// Side-channel postMessage handler for the widget's Vojo-extension
|
||||||
// call. Distinct from matrix-widget-api's `fromWidget` channel
|
// actions. Distinct from matrix-widget-api's `fromWidget` channel
|
||||||
// (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it
|
// (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it
|
||||||
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
|
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
|
||||||
// of our extension and avoids the «unknown action» reply path.
|
// of our extension and avoids the «unknown action» reply path.
|
||||||
//
|
//
|
||||||
// Why this exists: the host's global `setupExternalLinkHandler`
|
// Two actions today:
|
||||||
// (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.
|
|
||||||
//
|
//
|
||||||
// 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
|
// 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this
|
||||||
// check, a compromised widget bundle could `window.location.href
|
// check, a compromised widget bundle could `window.location.href
|
||||||
// = 'https://attacker.example/'` — the browser keeps the same
|
// = '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
|
// iframe of the SAME origin — e.g. an ad embed loaded into a
|
||||||
// sibling frame on the same origin in a future deployment —
|
// sibling frame on the same origin in a future deployment —
|
||||||
// could otherwise pass the origin check).
|
// 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
|
// 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
|
// http; rejecting http closes a cleartext-redirect vector via
|
||||||
// Capacitor `Browser.open` on Android.
|
// Capacitor `Browser.open` on Android.
|
||||||
// 4. javascript:, data:, file:, etc. are implicitly rejected by (3).
|
// * `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) => {
|
private readonly onWidgetMessage = (ev: MessageEvent) => {
|
||||||
if (ev.origin !== this.widgetOrigin) return;
|
if (ev.origin !== this.widgetOrigin) return;
|
||||||
if (ev.source !== this.iframe.contentWindow) return;
|
if (ev.source !== this.iframe.contentWindow) return;
|
||||||
|
|
@ -255,9 +278,10 @@ export class BotWidgetEmbed {
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!msg || typeof msg !== 'object') return;
|
if (!msg || typeof msg !== 'object') return;
|
||||||
if (msg.api !== 'io.vojo.bot-widget') return;
|
if (msg.api !== 'io.vojo.bot-widget') return;
|
||||||
if (msg.action !== 'open-external-url') return;
|
|
||||||
const url = msg.data?.url;
|
const url = msg.data?.url;
|
||||||
if (typeof url !== 'string') return;
|
if (typeof url !== 'string') return;
|
||||||
|
|
||||||
|
if (msg.action === 'open-external-url') {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
if (parsed.protocol !== 'https:') return;
|
if (parsed.protocol !== 'https:') return;
|
||||||
|
|
@ -267,6 +291,25 @@ export class BotWidgetEmbed {
|
||||||
openExternalUrl(url).catch(() => {
|
openExternalUrl(url).catch(() => {
|
||||||
/* fire-and-forget: log handled inside openExternalUrl */
|
/* fire-and-forget: log handled inside openExternalUrl */
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
public constructor(private readonly options: BotWidgetEmbedOptions) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Room, SyncState } from 'matrix-js-sdk';
|
import { Room, SyncState } from 'matrix-js-sdk';
|
||||||
import type { BotPreset } from './catalog';
|
import type { BotPreset } from './catalog';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useSyncState } from '../../hooks/useSyncState';
|
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 { useBotWidgetEmbed } from './useBotWidgetEmbed';
|
||||||
import * as css from './BotWidgetMount.css';
|
import * as css from './BotWidgetMount.css';
|
||||||
|
|
||||||
|
|
@ -34,15 +42,46 @@ type BotWidgetMountProps = {
|
||||||
|
|
||||||
export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
|
export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
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
|
// Track Matrix sync state so the bot loading bar yields to the global
|
||||||
// SyncIndicator when the connection is unhealthy. Without this, on a
|
// SyncIndicator when the connection is unhealthy. Without this, on a
|
||||||
// dropped network the user would see TWO sweeping bars at once — the
|
// dropped network the user would see TWO sweeping bars at once — the
|
||||||
// bot bar at top stuck in «still loading» plus the SyncIndicator at
|
// bot bar at top stuck in «still loading» plus the SyncIndicator at
|
||||||
// bottom in transient/error state. The bottom bar is the canonical
|
// bottom in transient/error state. The bottom bar is the canonical
|
||||||
// connection-state surface; the top one defers.
|
// connection-state surface; the top one defers. Reuses `mx` from the
|
||||||
const mx = useMatrixClient();
|
// navigate-callback block above — single hook call per render.
|
||||||
const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState());
|
const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState());
|
||||||
useSyncState(
|
useSyncState(
|
||||||
mx,
|
mx,
|
||||||
|
|
@ -106,10 +145,7 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
|
||||||
// SyncIndicator can take over without two bars overlapping.
|
// SyncIndicator can take over without two bars overlapping.
|
||||||
// Reduced-motion: animation is off (no iterations ever land), so
|
// Reduced-motion: animation is off (no iterations ever land), so
|
||||||
// parking a static stripe for ~2s isn't graceful, just stuck.
|
// parking a static stripe for ~2s isn't graceful, just stuck.
|
||||||
if (
|
if (hideReason === 'sync' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
hideReason === 'sync' ||
|
|
||||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
||||||
) {
|
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
setPendingHide(false);
|
setPendingHide(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Room } from 'matrix-js-sdk';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { Theme, useTheme } from '../../hooks/useTheme';
|
import { Theme, useTheme } from '../../hooks/useTheme';
|
||||||
|
import type { MatrixToRoom } from '../../plugins/matrix-to';
|
||||||
import type { BotPreset } from './catalog';
|
import type { BotPreset } from './catalog';
|
||||||
import { BotWidgetEmbed } from './BotWidgetEmbed';
|
import { BotWidgetEmbed } from './BotWidgetEmbed';
|
||||||
|
|
||||||
|
|
@ -11,6 +12,9 @@ type UseBotWidgetEmbedOptions = {
|
||||||
preset: BotPreset;
|
preset: BotPreset;
|
||||||
room: Room;
|
room: Room;
|
||||||
onError: () => void;
|
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 = {
|
type UseBotWidgetEmbedResult = {
|
||||||
|
|
@ -30,6 +34,7 @@ export const useBotWidgetEmbed = ({
|
||||||
preset,
|
preset,
|
||||||
room,
|
room,
|
||||||
onError,
|
onError,
|
||||||
|
onOpenMatrixToRoom,
|
||||||
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
|
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
@ -43,6 +48,12 @@ export const useBotWidgetEmbed = ({
|
||||||
themeRef.current = theme;
|
themeRef.current = theme;
|
||||||
const languageRef = useRef<string>(i18n.language);
|
const languageRef = useRef<string>(i18n.language);
|
||||||
languageRef.current = 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`
|
// Depend on primitive identity for the embed lifecycle — using `preset`
|
||||||
// directly would remount the iframe (and re-handshake with the widget)
|
// directly would remount the iframe (and re-handshake with the widget)
|
||||||
|
|
@ -72,6 +83,9 @@ export const useBotWidgetEmbed = ({
|
||||||
language: languageRef.current,
|
language: languageRef.current,
|
||||||
onError,
|
onError,
|
||||||
onReady: () => setReady(true),
|
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;
|
embedRef.current = embed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -41,20 +41,43 @@ export type MatrixToRoomEvent = MatrixToRoom & {
|
||||||
const MATRIX_TO = /^https?:\/\/matrix\.to\S*$/;
|
const MATRIX_TO = /^https?:\/\/matrix\.to\S*$/;
|
||||||
export const testMatrixTo = (href: string): boolean => MATRIX_TO.test(href);
|
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_USER = /^https?:\/\/matrix\.to\/#\/(@[^:\s]+:[^?/\s]+)\/?$/;
|
||||||
const MATRIX_TO_ROOM = /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/?(\?[\S]*)?$/;
|
const MATRIX_TO_ROOM = /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/?(\?[\S]*)?$/;
|
||||||
const MATRIX_TO_ROOM_EVENT =
|
const MATRIX_TO_ROOM_EVENT =
|
||||||
/^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/(\$[^?/\s]+)\/?(\?[\S]*)?$/;
|
/^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/(\$[^?/\s]+)\/?(\?[\S]*)?$/;
|
||||||
|
|
||||||
export const parseMatrixToUser = (href: string): string | undefined => {
|
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;
|
if (!match) return undefined;
|
||||||
const userId = match[1];
|
const userId = match[1];
|
||||||
return userId;
|
return userId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => {
|
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;
|
if (!match) return undefined;
|
||||||
|
|
||||||
const roomIdOrAlias = match[1];
|
const roomIdOrAlias = match[1];
|
||||||
|
|
@ -68,7 +91,7 @@ export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parseMatrixToRoomEvent = (href: string): MatrixToRoomEvent | 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;
|
if (!match) return undefined;
|
||||||
|
|
||||||
const roomIdOrAlias = match[1];
|
const roomIdOrAlias = match[1];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue