Compare commits

..

No commits in common. "4a2e29fdb5f0a700bb9a9f12bf9d9359d1c6ab18" and "2617eaf46eae35b7351e17fac78895c06650e77d" have entirely different histories.

30 changed files with 425 additions and 1101 deletions

View file

@ -1,60 +0,0 @@
// Per-package ESLint config for the Preact widget apps under `apps/`.
//
// `root: true` stops ESLint from walking up to the host's
// `cinny/.eslintrc.cjs`, which extends airbnb + the React plugin. Those
// rule sets are tuned for the React host and flag legitimate Preact /
// small-widget patterns as errors (`class=` attributes, arrow-fn
// components, inline icon sub-components, for-of loops, etc.). Keeping
// the hierarchy open would force every widget file to fight host style
// for no real win.
//
// Widgets keep a minimal but real lint pass via the rule sets below:
//
// * `eslint:recommended` — catches genuine bugs (no-undef, no-dupe-*,
// no-redeclare, no-unused-vars, …) without enforcing style.
// * `@typescript-eslint/recommended` — TS-aware variants of the above
// plus type-level checks the recommended set ships.
//
// We deliberately DON'T extend `plugin:react/recommended` —
// `react/react-in-jsx-scope` and `react/no-unknown-property` both flag
// Preact-correct code as errors, and disabling them one by one creates
// a long suppression list. Widget JSX is type-checked by each app's
// `tsc --noEmit` (run by `vite build`), which is the better signal for
// JSX correctness anyway.
module.exports = {
root: true,
// `node` covers `module.exports` in this very file (CommonJS config);
// `browser` is the runtime widget code itself sees.
env: { browser: true, es2021: true, node: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
// preact/hooks has the same dep-array semantics as react/hooks, and
// the widget code already carries `// eslint-disable-next-line
// react-hooks/exhaustive-deps` directives at the relevant sites;
// loading the plugin (a) keeps those directives meaningful (without
// it ESLint errors on the «unknown rule» referenced by the comment)
// and (b) catches the real exhaustive-deps mistakes in widget hooks
// for free.
'plugin:react-hooks/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
plugins: ['@typescript-eslint', 'react-hooks'],
rules: {
// Underscore-prefixed args are intentionally unused (Preact event
// handlers receive args the body doesn't need); match the host's
// convention so lint reads consistently across both trees.
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
// Widget bridge-protocol regexes occasionally escape `-` inside
// character classes for visual clarity (e.g. `[0-9\-]`). The escape
// is harmless and pre-existing across all three widgets — keeping
// the rule on would force a churn-y diff in code that's been stable
// since the v0.7.6 bridge dialect work.
'no-useless-escape': 'off',
},
};

View file

@ -95,18 +95,6 @@ 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;
@ -400,13 +388,6 @@ 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) => {
@ -618,7 +599,9 @@ 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 ? <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>
<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}>
@ -781,40 +764,6 @@ 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
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -978,15 +927,6 @@ 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
@ -1049,7 +989,10 @@ 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 (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') }); append({ kind: 'diag', text: t('diag.qr-consumed') });
} }
} else if (event.kind === 'captcha_challenge') { } else if (event.kind === 'captcha_challenge') {
@ -1058,12 +1001,6 @@ 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)}` });
@ -1248,7 +1185,9 @@ 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.handle : undefined; state.kind === 'connected_dead' || state.kind === 'connected'
? state.handle
: undefined;
dispatch({ kind: 'request_reconnect', handle }); dispatch({ kind: 'request_reconnect', handle });
try { try {
await sendBare('reconnect'); await sendBare('reconnect');
@ -1414,17 +1353,6 @@ 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>

View file

@ -56,15 +56,6 @@ 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
@ -169,28 +160,6 @@ 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() };
@ -278,8 +247,8 @@ export const parseEventLegacyV076 = (event: ParsableEvent): LoginEvent => {
typeof event.redacts === 'string' typeof event.redacts === 'string'
? event.redacts ? event.redacts
: isObject(event.content) && typeof event.content.redacts === 'string' : isObject(event.content) && typeof event.content.redacts === 'string'
? event.content.redacts ? event.content.redacts
: undefined; : undefined;
if (!target) return { kind: 'unknown' }; if (!target) return { kind: 'unknown' };
return { kind: 'qr_redacted', redactsEventId: target }; return { kind: 'qr_redacted', redactsEventId: target };
} }
@ -361,11 +330,20 @@ 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 @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. // 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.
[ [
@ -409,7 +387,10 @@ 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' }],
@ -540,9 +521,7 @@ 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=${ `legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? '<none>'}`
event.content?.msgtype ?? '<none>'
}`
); );
} }
} }

View file

@ -113,15 +113,6 @@ 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

View file

@ -55,11 +55,13 @@ 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 bots chat.', 'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bots 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': '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.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': '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.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.',
@ -71,9 +73,6 @@ 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…',

View file

@ -86,7 +86,8 @@ 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': 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.', 'auth-error.captcha-expired':
'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}',
@ -105,11 +106,7 @@ 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': 'Проверяю статус подключения…',

View file

@ -104,13 +104,8 @@ 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. `spaceMatrixToUrl` // per Matrix user), so logout doesn't need an id.
// is populated from the Vojo `VOJO-LOGIN-SPACE-V1` sentinel that lands | { kind: 'connected'; handle: string; discordId?: string }
// 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`
@ -125,7 +120,10 @@ 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 = PendingFormState | CaptchaSolveState | { kind: 'qr_verifying' }; export type HydrateRestoredState =
| 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 —
@ -171,7 +169,9 @@ 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 === '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 => { export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
if (action.kind === 'hydrate') { if (action.kind === 'hydrate') {
@ -266,14 +266,11 @@ 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. Preserve `spaceMatrixToUrl` from a prior // the discordId snowflake.
// `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':
@ -495,28 +492,12 @@ 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. spaceMatrixToUrl is not // produce when no handle was stashed.
// 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
@ -584,7 +565,10 @@ type HydrateAccumulator = {
terminated: boolean; terminated: boolean;
}; };
const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateAccumulator => { const stepHydrate = (
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
@ -709,12 +693,9 @@ const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateA
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; space_ready is a post-terminal sentinel — hydrate // catch-all.
// 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: {

View file

@ -125,27 +125,6 @@ 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

View file

@ -742,21 +742,6 @@ body {
width: 100%; width: 100%;
} }
/* PasswordForm wraps its input + show/hide toggle in `.password-row`
* so the toggle pill sits next to the input on desktop. On narrow
* viewports that nested row stays row-direction with `flex-shrink: 0`
* on `.btn-icon`, and the input's monospace `font-size: 20px` +
* `letter-spacing: 4px` (see `.auth-input.password`) pushes the toggle
* off-screen. Continue the same column-stack pattern the outer
* `.auth-card-row` already uses so the toggle drops below the input
* full-width visually consistent with btn-primary / btn-text. */
.password-row {
flex-direction: column;
}
.password-row .btn-icon {
width: 100%;
}
/* Compact .command-card on mobile preserves the «two-row title + /* Compact .command-card on mobile preserves the «two-row title +
* chevron» structure but trims padding so a single login/logout card * chevron» structure but trims padding so a single login/logout card
* doesn't dominate a phone-height viewport. */ * doesn't dominate a phone-height viewport. */

View file

@ -81,13 +81,6 @@ export function MobileTabsPagerHeader({
const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]); const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]);
const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]); const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]);
const iconsDisabled = curtainControls === null; const iconsDisabled = curtainControls === null;
// Tab-specific override for the Plus button (Channels publishes
// «create channel» / «create community»). Falls back to the default
// «new chat» path that opens InlineNewChatForm via the curtain.
// `primaryAction.onClick` is already stable (memoised by the
// publishing pane), so we wire it directly into onClick without
// re-wrapping in another useCallback.
const primaryAction = curtainControls?.primaryAction ?? null;
// The static header does NOT translate to follow the curtain. It // The static header does NOT translate to follow the curtain. It
// stays put; the curtain physically rises ABOVE it via z-stack — see // stays put; the curtain physically rises ABOVE it via z-stack — see
@ -178,19 +171,14 @@ export function MobileTabsPagerHeader({
fill="None" fill="None"
size="400" size="400"
radii="Pill" radii="Pill"
onClick={primaryAction ? primaryAction.onClick : openChat} onClick={openChat}
aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')} aria-label={t('Direct.create_chat')}
// See StreamHeader's matching IconButton: drop only aria-controls={INLINE_FORM_ID}
// `aria-controls` when the override opens a portal
// Modal (no in-subtree form to point at). The
// override IS a dialog opener, so `aria-haspopup` +
// `aria-expanded={false}` stay accurate either way.
aria-controls={primaryAction ? undefined : INLINE_FORM_ID}
aria-expanded={false} aria-expanded={false}
aria-haspopup="dialog" aria-haspopup="dialog"
disabled={iconsDisabled} disabled={iconsDisabled}
> >
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} /> <Icon size="100" src={Icons.Plus} />
</IconButton> </IconButton>
<IconButton <IconButton
variant="SurfaceVariant" variant="SurfaceVariant"

View file

@ -143,11 +143,8 @@ export const strip = style({
// //
// No paddingTop here: the per-pane StreamHeader still renders its // No paddingTop here: the per-pane StreamHeader still renders its
// own tabs row (kept for the curtain's TABS_ROW_PX snap math, just // own tabs row (kept for the curtain's TABS_ROW_PX snap math, just
// painted invisible via `opacity: 0` — load-bearing because // painted invisible via visibility:hidden), and PageNav's inner
// `visibility: hidden` would remove the row from hit-testing and // column reserves the status-bar safe-area inset via its own
// the per-pane Segments need to capture taps at rest, see
// `StreamHeader.tsx` tabsRow rationale), and PageNav's inner column
// reserves the status-bar safe-area inset via its own
// `paddingTop: var(--vojo-safe-top)`. The static header overlay at // `paddingTop: var(--vojo-safe-top)`. The static header overlay at
// the pager root simply paints OVER the same screen zone, so the // the pager root simply paints OVER the same screen zone, so the
// underlying geometry stays identical to non-pager mode. // underlying geometry stays identical to non-pager mode.

View file

@ -1,6 +1,6 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes'; import { recipe } from '@vanilla-extract/recipes';
import { color, config, toRem } from 'folds'; import { color, toRem } from 'folds';
import { import {
CHIP_GAP_PX, CHIP_GAP_PX,
CURTAIN_BREATHER_PX, CURTAIN_BREATHER_PX,
@ -9,7 +9,6 @@ import {
CURTAIN_SNAP_MS, CURTAIN_SNAP_MS,
HANDLE_HEIGHT_PX, HANDLE_HEIGHT_PX,
TABS_ROW_PX, TABS_ROW_PX,
WEB_TABS_ROW_PX,
} from './geometry'; } from './geometry';
// Stage. Position-relative anchor. The header itself paints the // Stage. Position-relative anchor. The header itself paints the
@ -64,31 +63,13 @@ export const header = style({
}); });
// Tabs row. Stays fully visible regardless of curtain position // Tabs row. Stays fully visible regardless of curtain position
// because the curtain's `top` floor equals `TABS_ROW_PX` on native // because the curtain's `top` floor equals `TABS_ROW_PX`.
// (`WEB_TABS_ROW_PX` on web — see `geometry.ts::WEB_TABS_ROW_PX`).
//
// Web variant: shrink to `WEB_TABS_ROW_PX` (= 54 px = folds Header
// `size="600"`) so the row reads at the same height as the right-pane
// room `PageHeader`, AND own the 1 px divider rule as a
// `border-bottom`. Putting the rule on `tabsRow` (not on the curtain
// as a `border-top`) is load-bearing for pixel alignment: with the
// global `* { box-sizing: border-box }` reset (`src/index.css`),
// `tabsRow`'s 1 px bottom border lands at y=53→54 inside the 54 px
// box — exactly where PageHeader's outlined border-bottom paints. If
// the rule lived on the curtain's `border-top` at `top: 54`, it would
// paint at y=54→55, off-by-one against the right pane.
export const tabsRow = style({ export const tabsRow = style({
flexShrink: 0, flexShrink: 0,
height: toRem(TABS_ROW_PX), height: toRem(TABS_ROW_PX),
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: `0 ${toRem(8)}`, padding: `0 ${toRem(8)}`,
selectors: {
'[data-platform="web"] &': {
height: toRem(WEB_TABS_ROW_PX),
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
},
},
}); });
export const tabsCluster = style({ export const tabsCluster = style({
@ -108,32 +89,20 @@ export const iconsCluster = style({
// Curtain. Layered above the header (z-index higher). Its top edge // Curtain. Layered above the header (z-index higher). Its top edge
// moves with the snap state (and live finger drag); its bottom edge // moves with the snap state (and live finger drag); its bottom edge
// is anchored to the stage bottom so the curtain's `bottomPinned` // is anchored to the stage bottom so the curtain's `bottomPinned`
// child (DirectSelfRow / WorkspaceFooter) stays glued to the visible // child (DirectSelfRow / ChannelCreateRow / WorkspaceFooter) stays
// viewport bottom regardless of where the curtain's top is. // glued to the visible viewport bottom regardless of where the
// curtain's top is.
// //
// On native, only the TOP corners are rounded: the bottom is meant // Only the TOP corners are rounded: the bottom is meant to read as
// to read as continuous with the always-visible bottomPinned row // continuous with the always-visible bottomPinned row (DirectSelfRow
// (DirectSelfRow is the curtain's last flex child) — adding // is the curtain's last flex child) — adding `borderBottomRadius`
// `borderBottomRadius` would crop the row's corners against the // would crop the row's corners against the curtain's
// curtain's `overflow: hidden`, which visually reads as «a light- // `overflow: hidden`, which visually reads as «a light-blue strip
// blue strip cuts into the row». // cuts into the row».
// //
// Live finger tracking and snap commits both flow through React state // Live finger tracking and snap commits both flow through React state
// updates to `top` so the transition is always coordinated with the // updates to `top` so the transition is always coordinated with the
// rendered position — disabled during drag, restored on commit. // rendered position — disabled during drag, restored on commit.
//
// Web variant (`[data-platform="web"]` on `stage`, set by
// StreamHeader.tsx when `!isNativePlatform()`): there is no pin/peek
// gesture, so the curtain is a purely static slab under the tabs row.
// Drop ONLY the «card» rounding (top corners flat). The divider rule
// at the seam is owned by `tabsRow.borderBottom` under the same
// selector — that placement keeps the rule pixel-aligned with the
// right-pane `PageHeader`'s outlined border (see `tabsRow` comment
// above). The curtain bg stays `Background.Container` so the chat-
// row rows (`NavItem variant="Background"`) keep blending into one
// continuous list surface — if we made the curtain transparent the
// rows would paint as dark cards over the lighter
// `SurfaceVariant.Container` stage.
export const curtain = style({ export const curtain = style({
position: 'absolute', position: 'absolute',
left: 0, left: 0,
@ -151,12 +120,6 @@ export const curtain = style({
// Hint the compositor while the curtain is moving. Cheap since the // Hint the compositor while the curtain is moving. Cheap since the
// curtain is the only element in this stacking context that animates. // curtain is the only element in this stacking context that animates.
willChange: 'top', willChange: 'top',
selectors: {
'[data-platform="web"] &': {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
},
}); });
// Drag handle at the top of the curtain. Dedicated touch surface for // Drag handle at the top of the curtain. Dedicated touch surface for
@ -219,17 +182,20 @@ export const handleBar = style({
// Wrapper around `bottomPinned` inside the curtain. Anchored to the // Wrapper around `bottomPinned` inside the curtain. Anchored to the
// curtain's flex-bottom by virtue of being the last child. The TSX // curtain's flex-bottom by virtue of being the last child. The TSX
// collapses this slot to `{ height: 0, overflow: hidden }` when the // applies a `transform: translateY(keyboardH)` to this element when
// on-screen keyboard rises (via `VisualViewport.height` shrink) so // the on-screen keyboard rises (via `VisualViewport.height` shrink)
// the row neither paints nor claims flex space above the keyboard. // so the row stays at its ORIGINAL viewport-bottom position — under
// Without this compensation, `interactive-widget=resizes-content` // the keyboard, clipped by the curtain's `overflow: hidden`. Without
// (global viewport meta — load-bearing for the room composer) // this compensation, `interactive-widget=resizes-content` (global
// shrinks the layout viewport, dragging every `bottom: 0` element // meta — load-bearing for the room composer) shrinks the layout
// up over the inline form. The DirectSelfRow ending up immediately // viewport, dragging every `bottom: 0` element up over the inline
// above the keyboard would block the user's view of the form they're // form. The DirectSelfRow ending up immediately above the keyboard
// typing into. // blocks the user's view of the form they're typing into.
export const bottomPinnedSlot = style({ export const bottomPinnedSlot = style({
flexShrink: 0, flexShrink: 0,
// Compositor hint — the transform is applied/cleared on every
// VisualViewport resize while a keyboard is open.
willChange: 'transform',
}); });
// Segment button (Direct / Channels / Bots). // Segment button (Direct / Channels / Bots).

View file

@ -16,12 +16,7 @@ import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
import { isNativePlatform } from '../../utils/capacitor'; import { isNativePlatform } from '../../utils/capacitor';
import { useBotPresets } from '../../features/bots/catalog'; import { useBotPresets } from '../../features/bots/catalog';
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext'; import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
import { import { MobilePagerCurtainControls, mobilePagerCurtainAtom } from '../../state/mobilePagerHeader';
MobilePagerCurtainControls,
StreamHeaderPrimaryAction,
mobilePagerCurtainAtom,
} from '../../state/mobilePagerHeader';
import { TABS_ROW_PX, WEB_TABS_ROW_PX } from './geometry';
import { settingsSheetAtom } from '../../state/settingsSheet'; import { settingsSheetAtom } from '../../state/settingsSheet';
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet'; import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
import * as css from './StreamHeader.css'; import * as css from './StreamHeader.css';
@ -50,34 +45,21 @@ type StreamHeaderProps = {
// `overflow: auto` div that the gesture hook listens to. // `overflow: auto` div that the gesture hook listens to.
children: ReactNode; children: ReactNode;
// Optional row(s) pinned to the bottom of the curtain (DirectSelfRow, // Optional row(s) pinned to the bottom of the curtain (DirectSelfRow,
// WorkspaceFooter). Hidden while a form is active so the on-screen // ChannelCreateRow, WorkspaceFooter). Hidden while a form is active
// keyboard's viewport resize doesn't push them up over the form // so the on-screen keyboard's viewport resize doesn't push them up
// (see commit 14ed080). // over the form (see commit 14ed080).
bottomPinned?: ReactNode; bottomPinned?: ReactNode;
// Stable identifier used to persist the curtain's pinned overlay // Stable identifier used to persist the curtain's pinned overlay
// across listing-pane remounts (the user taps into a Room and back, // across listing-pane remounts (the user taps into a Room and back,
// which unmounts the listing pane). Pin state is stored in // which unmounts the listing pane). When provided, pin state is
// `curtainPinnedByTabAtom[pinKey]` so it outlives any individual // stored in `curtainPinnedByTabAtom[pinKey]`; without it, pin lives
// StreamHeader instance. Each listing tab (Direct/Channels/Bots) // in a local useState that resets on unmount. Listing surfaces
// passes its own key; the Channels landing CTA and workspace // wired into the mobile pager (Direct / Channels / Bots) all pass
// listing share `"channels"` so pin survives the toggle between // a key; other consumers can omit it.
// empty state and a chosen workspace. pinKey?: string;
pinKey: string;
// Optional override for the Plus button. When omitted the header
// renders the default «new chat» action that opens InlineNewChatForm
// via the curtain. Channels overrides this with «create channel» /
// «create community» so the same Plus slot launches a contextual
// action instead of the DM-creation form.
primaryAction?: StreamHeaderPrimaryAction;
}; };
export function StreamHeader({ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: StreamHeaderProps) {
scrollRef,
children,
bottomPinned,
pinKey,
primaryAction,
}: StreamHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const bots = useBotPresets(); const bots = useBotPresets();
@ -147,24 +129,20 @@ export function StreamHeader({
// Two parallel curtain-gesture surfaces: // Two parallel curtain-gesture surfaces:
// //
// * `useCurtainHandleGesture` — the dedicated 32 px drag-handle // * `useCurtainHandleGesture` — the dedicated 32 px drag-handle
// at the top of the curtain. Crisp 1:1 finger ↔ curtain. From // at the top of the curtain. Crisp 1:1 finger ↔ curtain on
// closed the gesture is a free-range drag spanning pin↔closed↔ // every transition (pin, unpin, peek, close-peek, form-close).
// peek in one motion (`closed-free`); other snaps drive single-
// destination transitions (unpin / close-peek / form-close).
// Engages regardless of whether the chat list is scrollable — // Engages regardless of whether the chat list is scrollable —
// the handle is a distinct surface and never competes with list // the handle is a distinct surface and never competes with list
// scroll. Only rendered on native (`isNativePlatform()`). // scroll.
// //
// * `useCurtainBodyGesture` — anywhere on the curtain body // * `useCurtainBodyGesture` — anywhere on the curtain body
// OUTSIDE the handle (chat list, empty-state placeholder). // OUTSIDE the handle (chat list, empty-state placeholder,
// Rubber-banded (0.65) for all transitions, so the body drag // bottom-pinned row). Rubber-banded (0.65) on every transition
// reads as physically «heavier» than the handle's crisp pull. // so the body drag reads as physically «heavier» than the
// Engages only when the chat list has no scrollable content; // handle's crisp pull. Engages ONLY when the chat list has no
// additionally bails on touches that start inside the bottom- // scrollable content — long lists keep native vertical scroll;
// pinned slot (DirectSelfRow / WorkspaceFooter have their own // short / empty lists let the user pull the curtain «from
// drag-to-open bottom sheets) and on touches that start while // anywhere».
// pinned (unpin is HANDLE-only — the user has to grab the
// dedicated affordance to release the lock).
// //
// Both hooks share `handleVisual` (mirrors desktop // Both hooks share `handleVisual` (mirrors desktop
// `PageNavResizeHandle`: `dragging` lights up the grabber pill; // `PageNavResizeHandle`: `dragging` lights up the grabber pill;
@ -176,7 +154,6 @@ export function StreamHeader({
// visual. // visual.
const handleRef = useRef<HTMLDivElement>(null); const handleRef = useRef<HTMLDivElement>(null);
const curtainRef = useRef<HTMLDivElement>(null); const curtainRef = useRef<HTMLDivElement>(null);
const bottomPinnedRef = useRef<HTMLDivElement>(null);
const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({ const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({
dragging: false, dragging: false,
atCommit: false, atCommit: false,
@ -194,7 +171,6 @@ export function StreamHeader({
useCurtainBodyGesture({ useCurtainBodyGesture({
curtainRef, curtainRef,
handleRef, handleRef,
bottomPinnedRef,
scrollRef, scrollRef,
snap: curtain.snap, snap: curtain.snap,
pinned: curtain.pinned, pinned: curtain.pinned,
@ -220,9 +196,8 @@ export function StreamHeader({
openChat, openChat,
closeForm: close, closeForm: close,
isFormActive: isActive, isFormActive: isActive,
primaryAction: primaryAction ?? null,
}), }),
[openSearch, openChat, close, isActive, primaryAction] [openSearch, openChat, close, isActive]
); );
const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom); const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom);
@ -248,20 +223,9 @@ export function StreamHeader({
// stage (= y = safe-top in viewport), covering the tabs row. The // stage (= y = safe-top in viewport), covering the tabs row. The
// global pinned atom shares this state across every listing tab so // global pinned atom shares this state across every listing tab so
// swiping between Direct / Channels / Bots preserves the lock. // swiping between Direct / Channels / Bots preserves the lock.
//
// `platformOffset` is the web-only shift that lifts every non-pinned
// snap by the delta between native and web tabs-row heights. Tabs
// row on web is `WEB_TABS_ROW_PX` (= 54px, matching PageHeader on
// the right pane); `snapTopPx` is computed against `TABS_ROW_PX`
// (= 64px) which stays authoritative for native pin/peek geometry.
// Subtracting the delta on web realigns the closed/form snaps with
// the smaller tabs row without touching the snap-state machine.
// Pinned (= 0) doesn't need the offset because the safe-top + native
// contract owns that case and pinned is native-only.
const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX;
const curtainTop = curtain.pinned const curtainTop = curtain.pinned
? 0 + curtain.liveDragPx ? 0 + curtain.liveDragPx
: snapTopPx(curtain.snap, curtain.formHeightPx) + platformOffset + curtain.liveDragPx; : snapTopPx(curtain.snap, curtain.formHeightPx) + curtain.liveDragPx;
// After the curtain settles at `closed`, unmount any lingering form. // After the curtain settles at `closed`, unmount any lingering form.
// Guarded so unrelated transitionend events (e.g. children's own // Guarded so unrelated transitionend events (e.g. children's own
@ -298,15 +262,6 @@ export function StreamHeader({
// spurious flips on small browser-chrome animations. // spurious flips on small browser-chrome animations.
const [keyboardOpen, setKeyboardOpen] = useState(false); const [keyboardOpen, setKeyboardOpen] = useState(false);
useEffect(() => { useEffect(() => {
// Desktop browsers / Electron have no soft keyboard, but their
// VisualViewport DOES shrink on aggressive page zoom (Ctrl+`+`).
// That used to slip past as a hidden quirk while the curtain
// rendered as a rounded card; once the web variant flattened the
// curtain it became a visible regression — the DirectSelfRow at
// the bottom would collapse to height: 0 under zoom and read as
// broken layout. Gate the listener to native so the probe only
// arms where a real soft keyboard can actually appear.
if (!isNativePlatform()) return undefined;
const vv = window.visualViewport; const vv = window.visualViewport;
if (!vv) return undefined; if (!vv) return undefined;
const KEYBOARD_PROBE_PX = 100; const KEYBOARD_PROBE_PX = 100;
@ -329,7 +284,7 @@ export function StreamHeader({
}, []); }, []);
return ( return (
<div className={css.stage} data-platform={isNativePlatform() ? undefined : 'web'}> <div className={css.stage}>
<header className={css.header}> <header className={css.header}>
{/* Tabs row + action icons (always visible) {/* Tabs row + action icons (always visible)
In pager mode the row stays mounted (curtain snap math In pager mode the row stays mounted (curtain snap math
@ -398,19 +353,13 @@ export function StreamHeader({
fill="None" fill="None"
size="400" size="400"
radii="Pill" radii="Pill"
onClick={primaryAction ? primaryAction.onClick : openChat} onClick={openChat}
aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')} aria-label={t('Direct.create_chat')}
// `aria-controls` points at the curtain-mounted form aria-controls={INLINE_FORM_ID}
// region — drop it when `primaryAction` opens a portal
// dialog (`Modal` lives outside this subtree, so there
// is nothing to control here). `aria-haspopup="dialog"`
// + `aria-expanded={false}` stay accurate for both
// branches: the override opens a true Modal dialog.
aria-controls={primaryAction ? undefined : INLINE_FORM_ID}
aria-expanded={false} aria-expanded={false}
aria-haspopup="dialog" aria-haspopup="dialog"
> >
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} /> <Icon size="100" src={Icons.Plus} />
</IconButton> </IconButton>
<IconButton <IconButton
variant="SurfaceVariant" variant="SurfaceVariant"
@ -473,9 +422,9 @@ export function StreamHeader({
</div> </div>
<div className={css.chipRow}> <div className={css.chipRow}>
<Chip <Chip
iconSrc={primaryAction ? primaryAction.iconSrc : Icons.Plus} iconSrc={Icons.Plus}
label={primaryAction ? primaryAction.label : t('Direct.create_chat')} label={t('Direct.create_chat')}
onClick={primaryAction ? primaryAction.onClick : openChat} onClick={openChat}
hidden={curtain.snap !== 'peek'} hidden={curtain.snap !== 'peek'}
/> />
</div> </div>
@ -499,17 +448,14 @@ export function StreamHeader({
}} }}
onTransitionEnd={onCurtainTransitionEnd} onTransitionEnd={onCurtainTransitionEnd}
> >
{/* Drag handle native-only. On web (desktop browsers, {/* Drag handle (native-only behaviour, but rendered on all
Electron) the curtain has no interactive snap states, so platforms so the layout stays identical the gesture hook
the handle would be pure decoration with no behaviour short-circuits off-native). Hosts the entire curtain
behind it; rendering it conditionally drops the 32 px gesture surface pin, unpin, peek, close-peek and
grabber strip on those surfaces and lets the chat list form-close all bind here, leaving the chat list to native
sit flush against the curtain's rounded top. scroll. Stays mounted across snap transitions so the
gesture surface is always reachable when there is one to
On native the handle hosts the authoritative curtain make.
gesture (pin / unpin / peek / close-peek / form-close)
and stays mounted across snap transitions so the gesture
surface is always reachable when there is one to make.
`data-dragging` / `data-at-commit` mirror the desktop `data-dragging` / `data-at-commit` mirror the desktop
`PageNavResizeHandle`: CSS selectors on `handleBar` light `PageNavResizeHandle`: CSS selectors on `handleBar` light
@ -517,20 +463,18 @@ export function StreamHeader({
Both attrs are emitted/cleared only via React state set by Both attrs are emitted/cleared only via React state set by
the gesture hook (dedup'd), so the handle visual updates the gesture hook (dedup'd), so the handle visual updates
without slamming the DOM on every touchmove. */} without slamming the DOM on every touchmove. */}
{isNativePlatform() && ( <div
<div ref={handleRef}
ref={handleRef} className={css.handle}
className={css.handle} data-dragging={handleVisual.dragging || undefined}
data-dragging={handleVisual.dragging || undefined} data-at-commit={handleVisual.atCommit || undefined}
data-at-commit={handleVisual.atCommit || undefined} aria-hidden
aria-hidden >
> <div className={css.handleBar} />
<div className={css.handleBar} /> </div>
</div>
)}
{children} {children}
{/* `bottomPinned` (DirectSelfRow, WorkspaceFooter) is kept {/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is
mounted across snaps so the curtain reads as a self- kept mounted across snaps so the curtain reads as a self-
contained "screen" with its bottom row always pinned to contained "screen" with its bottom row always pinned to
the stage bottom. While the on-screen keyboard is up the the stage bottom. While the on-screen keyboard is up the
slot collapses to `height: 0` so it neither paints nor slot collapses to `height: 0` so it neither paints nor
@ -538,7 +482,6 @@ export function StreamHeader({
`keyboardOpen` effect above for the rationale). */} `keyboardOpen` effect above for the rationale). */}
{bottomPinned && ( {bottomPinned && (
<div <div
ref={bottomPinnedRef}
className={css.bottomPinnedSlot} className={css.bottomPinnedSlot}
style={keyboardOpen ? { height: 0, overflow: 'hidden' } : undefined} style={keyboardOpen ? { height: 0, overflow: 'hidden' } : undefined}
> >

View file

@ -34,26 +34,6 @@
// Tabs row height. Always visible above the curtain. // Tabs row height. Always visible above the curtain.
export const TABS_ROW_PX = 64; export const TABS_ROW_PX = 64;
// Web-only tabs-row height override. Matches folds `<Header size="600">`
// = 3.375rem = 54 px, which is the height the right-side `PageHeader`
// in the room chat panel (see `components/page/Page.tsx::PageHeader`)
// renders at. The 1 px divider rule on web lives as `tabsRow.
// borderBottom` so — under the global `* { box-sizing: border-box }`
// reset — it lands at y=53→54 inside this 54 px box, exactly matching
// where PageHeader's `outlined: true` border-bottom paints on the
// right pane. The two panes thus share one visible header baseline.
//
// Native keeps `TABS_ROW_PX` because the pin-gesture travel
// (`PIN_TRAVEL_PX`) is anchored to it and shrinking it would change
// the curtain's snap geometry. Web has no pin gesture, so the override
// is applied through two coordinated levers:
// 1. CSS `[data-platform="web"] &` selectors on `tabsRow` (height
// → `WEB_TABS_ROW_PX`, plus the divider as `borderBottom`).
// 2. TSX `platformOffset = WEB_TABS_ROW_PX - TABS_ROW_PX` (= -10)
// added to `curtainTop` so the closed / peek / form snaps all
// ride the reduced tabs row without recomputing `snapTopPx`.
export const WEB_TABS_ROW_PX = 54;
// Each peek-chip row. Reveals one chip's pill (h=48) + 8px top breather. // Each peek-chip row. Reveals one chip's pill (h=48) + 8px top breather.
export const CHIP_ROW_PX = 56; export const CHIP_ROW_PX = 56;
@ -117,46 +97,30 @@ export const PIN_TRAVEL_PX = TABS_ROW_PX;
// release for the snap to flip. Anything shorter reads as accidental // release for the snap to flip. Anything shorter reads as accidental
// and springs back to the previous resting snap. // and springs back to the previous resting snap.
// //
// On the handle the up direction is 1:1 with no upper clamp (the // With 1:1 finger ↔ curtain tracking (no rubber-band on pin / unpin —
// «closed-free» transition spans the full pin↔closed↔peek range in // see `useCurtainHandleGesture`), the committing finger pull is
// one gesture and the curtain follows the finger off-screen freely);
// the committing curtain DISPLACEMENT is still
// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ 61 px — essentially «drag // `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ 61 px — essentially «drag
// the curtain across the full tabs-row height». On the body the same // the curtain across the full tabs-row height». The anti-accidental
// displacement is reached with a longer finger pull because the body // gate is provided by the dedicated handle hit-zone (intentional
// path is rubber-banded (×0.65). // surface) — the chat list under the curtain is left to native
// // scroll and never engages a pin path, so there's no scroll-vs-pin
// Unpin's clamp is asymmetric — `pinned-free` lower-bounds the live // ambiguity to disambiguate.
// delta at 0 (no destination above pinned) but leaves the upper
// direction unclamped so the same gesture can carry the curtain
// through closed into peek territory in one motion. The handle-only
// contract on unpin means the body never resolves to `pinned-free`,
// so the no-upper-clamp tolerance only applies on the dedicated
// drag-handle.
export const PIN_COMMIT_THRESHOLD = 0.95; export const PIN_COMMIT_THRESHOLD = 0.95;
// Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the // Drag-handle hit-zone at the top of the curtain. The handle is the
// handle is rendered only when `isNativePlatform()` is true (see // AUTHORITATIVE gesture surface — pin, unpin, peek, close-peek and
// StreamHeader.tsx) — on web (desktop / Electron) the curtain has // form-close all bind here with 1:1 finger ↔ curtain tracking, no
// no interactive snap states, so the handle would be pure // matter whether the chat list inside the curtain is scrollable. See
// decoration and is omitted entirely. // `useCurtainHandleGesture` for the full state machine.
//
// On native the handle is the AUTHORITATIVE gesture surface —
// closed-free / unpin / close-peek / form-close all bind here with
// 1:1 finger ↔ curtain tracking, no matter whether the chat list
// inside the curtain is scrollable. See `useCurtainHandleGesture`
// for the full state machine.
// //
// A parallel `useCurtainBodyGesture` bound to the curtain's body // A parallel `useCurtainBodyGesture` bound to the curtain's body
// handles drag from anywhere on the card, but only when the inner // (everything below the handle) handles drag from anywhere on the
// chat list has no scrollable content AND the curtain isn't pinned // card, but only when the inner chat list has no scrollable content
// (unpin is handle-only). Its dynamics are rubber-banded so the // — its dynamics are rubber-banded so the body drag reads as
// body drag reads as physically «heavier» than the handle's crisp // physically «heavier» than the handle's crisp pull.
// pull.
// //
// Size: 32 px tall — enough touch target to land on comfortably with // Size: 32 px tall — enough touch target to land on comfortably with
// a thumb (the visible grabber pill inside is much smaller, see // a thumb (the visible grabber pill inside is much smaller, see
// `StreamHeader.css.ts::handleBar`). The list (or DirectEmpty / the // `StreamHeader.css.ts::handleBar`). The list (or DirectEmpty / the
// equivalent placeholder) starts 32 px below the curtain's top edge // equivalent placeholder) starts 32 px below the curtain's top edge.
// on native; on web the list sits flush at the curtain's top.
export const HANDLE_HEIGHT_PX = 32; export const HANDLE_HEIGHT_PX = 32;

View file

@ -10,11 +10,7 @@ import {
RUBBER_BAND, RUBBER_BAND,
} from './geometry'; } from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState'; import { CurtainSnap, isFormSnap } from './useCurtainState';
import { import { CurtainTransition, resolveCurtainTransition } from './useCurtainHandleGesture';
assertNeverCurtainTransition,
CurtainTransition,
resolveCurtainTransition,
} from './useCurtainHandleGesture';
type Args = { type Args = {
// The curtain element. Touch listeners bind here so anywhere on the // The curtain element. Touch listeners bind here so anywhere on the
@ -29,15 +25,6 @@ type Args = {
// when the touch starts inside the handle's hit-zone (the handle // when the touch starts inside the handle's hit-zone (the handle
// hook has already armed for that touch). // hook has already armed for that touch).
handleRef: MutableRefObject<HTMLDivElement | null>; handleRef: MutableRefObject<HTMLDivElement | null>;
// The `bottomPinned` slot at the bottom of the curtain (hosts
// DirectSelfRow, WorkspaceFooter). These rows open their own bottom
// sheets via vertical drag, so a touch that starts there must NOT
// engage the curtain body — otherwise the
// user's «pull settings up» gesture would also pin the curtain
// and the two motions would visually fight. `null` is fine (the
// surface has no bottomPinned content); the contains() check is
// optional-chained.
bottomPinnedRef: MutableRefObject<HTMLDivElement | null>;
// Scroll viewport of the chat list inside the curtain. The body // Scroll viewport of the chat list inside the curtain. The body
// gesture engages only when this element is NOT scrollable // gesture engages only when this element is NOT scrollable
// (scrollHeight ≤ clientHeight + 1): on long lists the user's // (scrollHeight ≤ clientHeight + 1): on long lists the user's
@ -58,10 +45,9 @@ type Args = {
// Live drag delta sink — feeds the curtain's `top` via React state, // Live drag delta sink — feeds the curtain's `top` via React state,
// no direct DOM writes. // no direct DOM writes.
setLiveDrag: (px: number, dragging: boolean) => void; setLiveDrag: (px: number, dragging: boolean) => void;
// Snap commit (peek / close-peek / form-close). Narrowed to the two // Snap commit (peek / close-peek / form-close). pin/unpin flips
// non-form destinations the hook ever reaches. pin/unpin flips
// `pinned` instead. // `pinned` instead.
commit: (next: 'peek' | 'closed') => void; commit: (next: CurtainSnap) => void;
// Suppress gesture binding entirely. Same conditions as the handle // Suppress gesture binding entirely. Same conditions as the handle
// hook — see StreamHeader's `gestureDisabled`. // hook — see StreamHeader's `gestureDisabled`.
disabled?: boolean; disabled?: boolean;
@ -100,17 +86,9 @@ type Args = {
// Skip the scrollable-bail in that case — the body's visible area is // Skip the scrollable-bail in that case — the body's visible area is
// the strip BELOW the form, and a drag there is unambiguously a // the strip BELOW the form, and a drag there is unambiguously a
// form-close intent (the only valid transition from form-* snap). // form-close intent (the only valid transition from form-* snap).
//
// Pinned override: the body gesture is INERT while the curtain is
// pinned. Unpin is exclusively the handle's contract — the user has
// to grab the dedicated pin-handle to release the lock, so an
// accidental drag anywhere on the visible card doesn't undo it. We
// bail at touchstart so no listener side-effects (preventDefault,
// liveDrag emit, …) can fire either.
export function useCurtainBodyGesture({ export function useCurtainBodyGesture({
curtainRef, curtainRef,
handleRef, handleRef,
bottomPinnedRef,
scrollRef, scrollRef,
snap, snap,
pinned, pinned,
@ -156,20 +134,11 @@ export function useCurtainBodyGesture({
const onTouchStart = (e: TouchEvent) => { const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return; if (e.touches.length !== 1) return;
// Pinned bail — handle owns unpin exclusively. See the «Pinned
// override» note above the hook for the rationale.
if (pinnedRef.current) return;
// Hand off to the handle hook if the touch starts inside the // Hand off to the handle hook if the touch starts inside the
// handle's 32 px hit-zone — the handle's own listener has // handle's 32 px hit-zone — the handle's own listener has
// already armed for this touch. // already armed for this touch.
const target = e.target as Node | null; const target = e.target as Node | null;
if (target && handleRef.current?.contains(target)) return; if (target && handleRef.current?.contains(target)) return;
// Hand off to the bottomPinned region (DirectSelfRow,
// WorkspaceFooter). Those rows host their own drag-to-open
// bottom sheets — engaging the curtain gesture here would pin
// the curtain in parallel with the sheet opening, and the two
// motions would visually fight.
if (target && bottomPinnedRef.current?.contains(target)) return;
// Scroll-aware bail: leave a scrollable chat list to its native // Scroll-aware bail: leave a scrollable chat list to its native
// vertical scroll. Skipped in form-* snaps because the visible // vertical scroll. Skipped in form-* snaps because the visible
// body area there is the strip BELOW the form (where the list // body area there is the strip BELOW the form (where the list
@ -237,26 +206,24 @@ export function useCurtainBodyGesture({
// only the finger pull needed differs. // only the finger pull needed differs.
let atCommit = false; let atCommit = false;
switch (transition) { switch (transition) {
case 'closed-free': case 'pin':
// Rubber-banded free-range drag spanning pin↔closed↔peek // Rubber-banded up, clamped at the safe-top edge.
// in one motion. NO clamps either side — the curtain lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta * RUBBER_BAND));
// follows the finger off-screen upward and continuously atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
// into peek territory downward. Direction-aware atCommit break;
// shows the right commit feedback for whichever side the case 'unpin':
// user is leaning into. Mirrors the handle's `closed-free` // Rubber-banded down, clamped at the closed-resting edge.
// but with 0.65× displacement so the body drag reads as lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta * RUBBER_BAND));
// physically «heavier». atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break;
case 'peek':
// Rubber-banded down. Bounds come from the direction guard
// above plus the snap clamp on touchend, so no extra clamp —
// matches the original list-bound peek feel.
lastDelta = delta * RUBBER_BAND; lastDelta = delta * RUBBER_BAND;
atCommit = atCommit = lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
lastDelta <= 0
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
: lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break; break;
case 'close-peek': case 'close-peek':
// Rubber-banded up. No clamp either side — matches the
// original list-bound peek feel; a downward jitter past the
// peek snap is visually negligible against the rubber-band
// damping.
lastDelta = delta * RUBBER_BAND; lastDelta = delta * RUBBER_BAND;
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break; break;
@ -266,21 +233,8 @@ export function useCurtainBodyGesture({
lastDelta = Math.min(0, delta * RUBBER_BAND); lastDelta = Math.min(0, delta * RUBBER_BAND);
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX; atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
break; break;
case 'pinned-free': default:
// Unreachable on the body — the pinned bail at touchstart
// prevents the hook from ever resolving this transition.
// Kept here so the `never` default below stays exhaustive
// and a future opening of pinned-free on the body would
// need to wire the dispatch explicitly.
break; break;
case null:
// Unreachable: `engaged` is set only after `transition` is
// resolved non-null in the dead-zone block above.
break;
default: {
assertNeverCurtainTransition(transition);
break;
}
} }
setLiveDrag(lastDelta, true); setLiveDrag(lastDelta, true);
emitHandle(true, atCommit); emitHandle(true, atCommit);
@ -295,13 +249,22 @@ export function useCurtainBodyGesture({
return; return;
} }
switch (transition) { switch (transition) {
case 'closed-free': case 'pin':
// Direction-aware commit, sign-exclusive: pin wins UP-side,
// peek wins DOWN-side, below both thresholds spring back to
// closed.
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) { if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(true); setPinnedRef.current(true);
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) { } else {
setLiveDrag(0, false);
}
break;
case 'unpin':
if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(false);
} else {
setLiveDrag(0, false);
}
break;
case 'peek':
if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('peek'); commitRef.current('peek');
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);
@ -321,18 +284,9 @@ export function useCurtainBodyGesture({
setLiveDrag(0, false); setLiveDrag(0, false);
} }
break; break;
case 'pinned-free': default:
case null:
// Both unreachable per the touchmove switch above; the
// setLiveDrag fallback preserves spring-back behaviour if a
// future change exposes either path here.
setLiveDrag(0, false); setLiveDrag(0, false);
break; break;
default: {
assertNeverCurtainTransition(transition);
setLiveDrag(0, false);
break;
}
} }
startX = null; startX = null;
startY = null; startY = null;
@ -363,18 +317,10 @@ export function useCurtainBodyGesture({
curtain.removeEventListener('touchmove', onTouchMove); curtain.removeEventListener('touchmove', onTouchMove);
curtain.removeEventListener('touchend', onTouchEnd); curtain.removeEventListener('touchend', onTouchEnd);
curtain.removeEventListener('touchcancel', onTouchCancel); curtain.removeEventListener('touchcancel', onTouchCancel);
// Same teardown contract as the handle hook — see its cleanup for
// the rationale. If `disabled` flips true while a body drag is in
// flight, the touchend never reaches us and the curtain would stay
// frozen at the finger position until the next touch.
if (engaged) {
setLiveDrag(0, false);
emitHandle(false, false);
}
}; };
// setLiveDrag is stable; the ref args are stable. `snap`, `pinned`, // setLiveDrag is stable; the ref args are stable. `snap`, `pinned`,
// `setPinned` and `commit` are ref-mirrored. Only `disabled` needs // `setPinned` and `commit` are ref-mirrored. Only `disabled` needs
// to tear listeners down — it's the sole effect dep. // to tear listeners down — it's the sole effect dep.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [curtainRef, handleRef, bottomPinnedRef, scrollRef, setLiveDrag, disabled]); }, [curtainRef, handleRef, scrollRef, setLiveDrag, disabled]);
} }

View file

@ -33,10 +33,9 @@ type Args = {
// curtain's `top` re-render — no direct DOM writes. // curtain's `top` re-render — no direct DOM writes.
setLiveDrag: (px: number, dragging: boolean) => void; setLiveDrag: (px: number, dragging: boolean) => void;
// Snap commit. Called on release for peek / close-peek / form-close // Snap commit. Called on release for peek / close-peek / form-close
// (the pin / unpin paths flip `pinned` instead). Narrowed to the // (the pin / unpin paths flip `pinned` instead). Also resets
// two non-form destinations the hook ever reaches. Also resets
// liveDragPx + isDragging atomically inside the parent state. // liveDragPx + isDragging atomically inside the parent state.
commit: (next: 'peek' | 'closed') => void; commit: (next: CurtainSnap) => void;
// Suppress gesture binding entirely. Used to gate motion when a // Suppress gesture binding entirely. Used to gate motion when a
// bottom sheet is open or when this pane is inactive inside the // bottom sheet is open or when this pane is inactive inside the
// swipe pager. // swipe pager.
@ -55,38 +54,7 @@ type Args = {
// on the curtain body) decide how raw finger displacement translates // on the curtain body) decide how raw finger displacement translates
// into curtain motion — see `onTouchMove` here for the 1:1 branches // into curtain motion — see `onTouchMove` here for the 1:1 branches
// and `useCurtainBodyGesture` for the rubber-banded equivalents. // and `useCurtainBodyGesture` for the rubber-banded equivalents.
// export type CurtainTransition = 'pin' | 'unpin' | 'peek' | 'close-peek' | 'form-close';
// `closed-free` is the single free-range transition that spans the
// full pin↔closed↔peek vertical range in one gesture. From the closed
// snap, neither direction is locked at the dead-zone: the user can
// drag up past the safe-top zone OR down through the chip area in
// one motion, and the release decides pin / peek / snap-back based
// on the final position. The earlier pair of one-shot `pin` and
// `peek` transitions used a hard «gate» at the start point (each
// direction was clamped to one side of 0 once the dead-zone resolved
// the direction) and the user reported this as a regression — drag
// up, then back down, ran into an invisible wall at the closed
// position before peek could engage.
//
// `pinned-free` is the symmetric free-range transition for the
// pinned overlay: from pinned + drag DOWN the curtain follows the
// finger all the way through closed into peek territory in one
// motion. On release, peek wins if the finger crossed the absolute
// peek planka (PIN_TRAVEL_PX + COMMIT_THRESHOLD × PEEK_TRAVEL_PX —
// the same visual point peek commits at from closed-free), unpin
// wins if at least the unpin threshold was reached, otherwise snap
// back to pinned. UP is no-op (no destination above pinned). Only
// the handle resolves to `pinned-free` — the body gesture bails at
// touchstart while pinned so unpin remains a deliberate handle pull.
export type CurtainTransition = 'closed-free' | 'pinned-free' | 'close-peek' | 'form-close';
// Exhaustive-check helper. Used in the `default` branch of every
// switch over `CurtainTransition | null` so that adding a fifth
// variant to the union fails typecheck at every dispatch site
// rather than silently no-op'ing through default. The argument is
// prefixed with `_` so eslint's `argsIgnorePattern: '^_'` keeps the
// rule happy without us tagging it `// eslint-disable`.
export const assertNeverCurtainTransition = (_value: never): void => {};
// Decide which transition the gesture arms based on the snap state // Decide which transition the gesture arms based on the snap state
// at direction-resolution time and the finger direction. `null` means // at direction-resolution time and the finger direction. `null` means
@ -95,17 +63,10 @@ export const assertNeverCurtainTransition = (_value: never): void => {};
// owns the touch. // owns the touch.
// //
// Direction guards encoded here: // Direction guards encoded here:
// * pinned + UP → no-op (would push the curtain past safe-top // * pinned + UP → no-op (would push the curtain past safe-top).
// on commit — no destination above pinned). // * pinned + DOWN → unpin.
// * pinned + DOWN → pinned-free (HANDLE-only contract — the body // * closed + UP → pin.
// hook bails entirely while pinned so unpin / // * closed + DOWN → peek.
// peek-from-pinned stays a deliberate handle
// pull. See
// `useCurtainBodyGesture::onTouchStart`).
// * closed (any) → closed-free (single transition spanning the
// whole pin↔closed↔peek range; direction at
// the dead-zone matters only for the
// horizontal-bail check).
// * peek + UP → close-peek (retreat to closed). // * peek + UP → close-peek (retreat to closed).
// * peek + DOWN → no-op (nothing lower to reveal). // * peek + DOWN → no-op (nothing lower to reveal).
// * form-* + UP → form-close. // * form-* + UP → form-close.
@ -115,8 +76,8 @@ export function resolveCurtainTransition(
pinned: boolean, pinned: boolean,
direction: 'up' | 'down' direction: 'up' | 'down'
): CurtainTransition | null { ): CurtainTransition | null {
if (pinned) return direction === 'down' ? 'pinned-free' : null; if (pinned) return direction === 'down' ? 'unpin' : null;
if (snap === 'closed') return 'closed-free'; if (snap === 'closed') return direction === 'up' ? 'pin' : 'peek';
if (snap === 'peek') return direction === 'up' ? 'close-peek' : null; if (snap === 'peek') return direction === 'up' ? 'close-peek' : null;
if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null; if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null;
return null; return null;
@ -127,53 +88,30 @@ export function resolveCurtainTransition(
// desktop. // desktop.
// //
// The handle is the «authoritative» gesture surface — it owns every // The handle is the «authoritative» gesture surface — it owns every
// transition (closed-free, pinned-free, close-peek, form-close) // transition (pin, unpin, peek, close-peek, form-close) with crisp
// with crisp 1:1 finger ↔ curtain tracking regardless of whether // 1:1 finger ↔ curtain tracking regardless of whether the chat list
// the chat list inside the curtain is scrollable. The curtain BODY // inside the curtain is scrollable. The curtain BODY has a parallel
// has a parallel gesture (`useCurtainBodyGesture`) with rubber- // gesture (`useCurtainBodyGesture`) with rubber-banded dynamics that
// banded dynamics that only engages when the body's chat list has // only engages when the body's chat list has no scrollable content —
// no scrollable content — so the user can pull the curtain «from // so the user can pull the curtain «from anywhere» on empty / short
// anywhere» on empty / short lists but a real list-scroll is never // lists but a real list-scroll is never hijacked under their finger.
// hijacked under their finger. The body is also fully inert while // History note: an earlier `useCurtainGesture` bound the peek /
// pinned, so unpin (and unpin → peek overshoot) stays a deliberate // form-close paths to the list scroll viewport directly. That coupling
// handle pull. // produced repeating «drag-up at scrollTop=0 hijacks for pin» / «drag-
// down at scrollTop=0 hijacks for peek» bugs and was removed when
// pin / unpin moved here.
// //
// Design rationale: gestures used to bind to the chat list's scroll // All five transitions track the finger 1:1, clamped at the relevant
// viewport directly, which produced repeating «drag-at-scrollTop=0 // snap edge so jitter past the destination doesn't visually overshoot:
// hijacks for pin/peek» bugs. Moving every transition onto a // * pin / unpin — clamp ±PIN_TRAVEL_PX, commit at
// dedicated handle (plus an opt-in body surface that bails on // PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX
// scrollable lists) removes the scroll/gesture race entirely. // («дотянул прям до самого верха»).
// // * peek / close-peek — clamp ±PEEK_TRAVEL_PX, commit at
// Per-transition dynamics — all track the finger 1:1, but the clamp
// shapes differ to keep on-screen motion sensible while preserving
// the «drag up off-screen from anywhere» feel the user explicitly
// asked for:
// * closed-free — NO clamps either side. Finger goes off-
// screen up → curtain follows past safe-top;
// finger crosses back below the start point →
// curtain continues into peek territory in
// the same gesture. Direction-aware commit
// on release: pin if pulled UP past
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, peek
// if pulled DOWN past COMMIT_THRESHOLD ×
// PEEK_TRAVEL_PX, else snap back to closed.
// * pinned-free — DOWN-only free-range drag from pinned.
// Clamped at 0 below (no destination above
// pinned), NO upper clamp — the finger can
// carry the curtain through closed into
// peek territory in one motion. Release
// decides peek (lastDelta ≥ PIN_TRAVEL_PX +
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX), unpin
// (lastDelta ≥ PIN_COMMIT_THRESHOLD ×
// PIN_TRAVEL_PX), or snap back to pinned.
// * close-peek — capped at 0 below (no transition lower
// than peek), NO upper clamp (drag past
// closed into safe-top freely). Commit at
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX. // COMMIT_THRESHOLD × PEEK_TRAVEL_PX.
// * form-close — capped at 0 so a downward jitter can't // * form-close — capped at 0 so a downward jitter can't push
// push the curtain below its form-snap top, // the curtain below its form-snap position.
// NO upper clamp. Commit at // Commit at ACTIVE_CLOSE_THRESHOLD_PX
// ACTIVE_CLOSE_THRESHOLD_PX (absolute). // (absolute distance, not a fraction).
// //
// Handle visual: emitHandle(true, atCommit) fires on every transition // Handle visual: emitHandle(true, atCommit) fires on every transition
// during touchmove so the grabber pill animates Primary-blue + // during touchmove so the grabber pill animates Primary-blue +
@ -285,47 +223,36 @@ export function useCurtainHandleGesture({
engaged = true; engaged = true;
e.preventDefault(); e.preventDefault();
// Clamp the raw finger delta into the live curtain displacement // Clamp / rubber-band the raw finger delta into the live curtain
// (`lastDelta`). Stored separately because the commit math on // displacement (`lastDelta`). Stored separately because the
// release needs the same value the curtain was visually showing. // commit math on release needs the same value the curtain was
// visually showing.
let atCommit = false; let atCommit = false;
switch (transition) { switch (transition) {
case 'closed-free': case 'pin':
// Single free-range drag spanning pin↔closed↔peek. 1:1 with // 1:1 up, clamped so the curtain doesn't enter the
// NO clamps either side: the curtain follows the finger off- // system-tray safe-top zone.
// screen upward (past safe-top) and continuously into peek lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta));
// territory downward in the same gesture. The release decides atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
// pin / peek / snap-back from the final lastDelta.
lastDelta = delta;
// Direction-aware atCommit so the grabber pill stretches
// whichever way the user is committing. Pin and peek are
// sign-exclusive (one branch can't fire simultaneously with
// the other) so a simple ternary on `lastDelta` suffices.
atCommit =
lastDelta <= 0
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
: lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break; break;
case 'pinned-free': case 'unpin':
// 1:1 down from pinned. Clamped at 0 below (a downward // 1:1 down, clamped so the curtain doesn't descend past its
// jitter past the start mustn't push the curtain into // `closed` resting top during the drag.
// safe-top — there's no destination above pinned), NO lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta));
// upper clamp — the curtain follows the finger through
// closed into peek territory in one motion.
lastDelta = Math.max(0, delta);
// atCommit fires as soon as ANY commit qualifies (the
// grabber pill stretches to signal «release works here»);
// it stays true past the unpin threshold all the way
// through peek, since both are valid landing zones.
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD; atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break; break;
case 'peek':
// 1:1 down, clamped at +PEEK_TRAVEL_PX so a long pull past
// the peek snap doesn't visually overshoot. Math.max(0,…)
// guards against a momentary direction reversal nudging the
// curtain above the closed origin while transition is still
// armed for «down».
lastDelta = Math.max(0, Math.min(PEEK_TRAVEL_PX, delta));
atCommit = lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
case 'close-peek': case 'close-peek':
// 1:1 up; delta is negative. Lower-capped at 0 (a downward // 1:1 up; delta is negative. Symmetric clamp to peek above.
// jitter shouldn't push past the peek snap), NO upper clamp lastDelta = Math.min(0, Math.max(-PEEK_TRAVEL_PX, delta));
// — the curtain follows the finger off-screen freely in the
// safe-top direction, matching the «drag up off-screen from
// anywhere» expectation.
lastDelta = Math.min(0, delta);
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break; break;
case 'form-close': case 'form-close':
@ -335,19 +262,10 @@ export function useCurtainHandleGesture({
lastDelta = Math.min(0, delta); lastDelta = Math.min(0, delta);
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX; atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
break; break;
case null: default:
// Unreachable: `engaged` is set only after `transition` is // Unreachable — transition is non-null past the dead-zone
// resolved non-null in the dead-zone block above; reaching // resolution above and is never cleared mid-gesture.
// this case would imply the gesture engaged without a
// transition, which the control flow above forbids.
break; break;
default: {
// Exhaustive guard. The `never` cast turns a future addition
// to `CurtainTransition` into a compile error here — adding
// a fifth member without wiring its dispatch fails typecheck.
assertNeverCurtainTransition(transition);
break;
}
} }
setLiveDrag(lastDelta, true); setLiveDrag(lastDelta, true);
emitHandle(true, atCommit); emitHandle(true, atCommit);
@ -367,38 +285,23 @@ export function useCurtainHandleGesture({
// transition re-enabled. Non-commit paths drop the live drag back // transition re-enabled. Non-commit paths drop the live drag back
// to 0 with transition active so the curtain springs back. // to 0 with transition active so the curtain springs back.
switch (transition) { switch (transition) {
case 'closed-free': case 'pin':
// Direction-aware commit from the free-range drag. Pin
// wins over peek if both somehow qualified (sign-exclusive
// in practice — lastDelta can't be simultaneously <0 and
// >0). Below either threshold, spring back to closed.
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) { if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(true); setPinnedRef.current(true);
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('peek');
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);
} }
break; break;
case 'pinned-free': case 'unpin':
// Two-tier commit: peek wins if the finger crossed the if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
// absolute peek planka (matches the visual point peek
// commits at from closed-free — PIN_TRAVEL_PX to get to
// closed + COMMIT_THRESHOLD × PEEK_TRAVEL_PX through the
// chip area); otherwise unpin if at least the unpin
// threshold was reached; else snap back to pinned.
//
// The peek branch MUST clear `pinned` before committing
// the snap. The curtain's resting top is
// `pinned ? 0 : snapTopPx(snap)` — so commit('peek')
// alone would set snap='peek' yet leave the curtain
// visually at top=0 (the pin overlay wins). Both updates
// batch into one render inside this touchend handler.
if (lastDelta >= PIN_TRAVEL_PX + COMMIT_THRESHOLD * PEEK_TRAVEL_PX) {
setPinnedRef.current(false); setPinnedRef.current(false);
} else {
setLiveDrag(0, false);
}
break;
case 'peek':
if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('peek'); commitRef.current('peek');
} else if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(false);
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);
} }
@ -417,19 +320,9 @@ export function useCurtainHandleGesture({
setLiveDrag(0, false); setLiveDrag(0, false);
} }
break; break;
case null: default:
// Unreachable: `engaged` is set only after `transition` is
// resolved non-null. Mirrors the touchmove switch.
setLiveDrag(0, false); setLiveDrag(0, false);
break; break;
default: {
// Exhaustive guard — see the touchmove switch for the same
// pattern. setLiveDrag fallback preserves spring-back if a
// future transition lands here unhandled at runtime.
assertNeverCurtainTransition(transition);
setLiveDrag(0, false);
break;
}
} }
startX = null; startX = null;
startY = null; startY = null;
@ -461,16 +354,6 @@ export function useCurtainHandleGesture({
handle.removeEventListener('touchmove', onTouchMove); handle.removeEventListener('touchmove', onTouchMove);
handle.removeEventListener('touchend', onTouchEnd); handle.removeEventListener('touchend', onTouchEnd);
handle.removeEventListener('touchcancel', onTouchCancel); handle.removeEventListener('touchcancel', onTouchCancel);
// If `disabled` flips true while a drag is in flight, the touchend
// we'd normally rely on for snap-back never reaches us (the listener
// is gone). Without an explicit reset the curtain stays frozen at
// the finger position with `transition: none` and the grabber pill
// stuck Primary-blue until the user starts a new touch — visible as
// a half-open curtain after, say, a sheet opens mid-drag.
if (engaged) {
setLiveDrag(0, false);
emitHandle(false, false);
}
}; };
// setLiveDrag is a stable useCallback; handleRef is stable. `snap`, // setLiveDrag is a stable useCallback; handleRef is stable. `snap`,
// `pinned`, `setPinned` and `commit` are mirrored via the refs // `pinned`, `setPinned` and `commit` are mirrored via the refs

View file

@ -35,7 +35,9 @@ export type CurtainState = {
// the consumer-supplied `pinKey` so the lock survives the route- // the consumer-supplied `pinKey` so the lock survives the route-
// driven listing-pane unmount when the user taps into a Room and // driven listing-pane unmount when the user taps into a Room and
// back. Each tab keeps its own pin (Direct/Channels/Bots are // back. Each tab keeps its own pin (Direct/Channels/Bots are
// independent). // independent). If no `pinKey` is provided, the pin lives in a
// local useState that resets on unmount — fine for non-listing
// surfaces where pinning isn't expected anyway.
pinned: boolean; pinned: boolean;
// Setter for the pinned overlay. Called by the gesture hook on // Setter for the pinned overlay. Called by the gesture hook on
// commit (drag-up-from-closed past threshold sets true; drag-down- // commit (drag-up-from-closed past threshold sets true; drag-down-
@ -67,10 +69,7 @@ export type CurtainState = {
close: () => void; close: () => void;
// Commit a snap stop directly. Used by the touch gesture on release. // Commit a snap stop directly. Used by the touch gesture on release.
// Also resets `liveDragPx` and `isDragging` in one batched update. // Also resets `liveDragPx` and `isDragging` in one batched update.
// Narrowed to the two non-form destinations the gesture hooks ever commit: (next: CurtainSnap) => void;
// reach — peek-reveal and close. Form snaps are entered through
// `open()` which sets `activeForm` synchronously alongside the snap.
commit: (next: 'peek' | 'closed') => void;
// Setter for the live drag delta — called from the touch gesture on // Setter for the live drag delta — called from the touch gesture on
// every touchmove. Updates are batched by React inside event handlers. // every touchmove. Updates are batched by React inside event handlers.
setLiveDrag: (px: number, dragging: boolean) => void; setLiveDrag: (px: number, dragging: boolean) => void;
@ -102,30 +101,35 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
} }
} }
export function useCurtainState(pinKey: string): CurtainState { export function useCurtainState(pinKey?: string): CurtainState {
const [snap, setSnap] = useState<CurtainSnap>('closed'); const [snap, setSnap] = useState<CurtainSnap>('closed');
const [activeForm, setActiveForm] = useState<ActiveForm>(null); const [activeForm, setActiveForm] = useState<ActiveForm>(null);
const [formHeightPx, setFormHeightPx] = useState<number | null>(null); const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
const [liveDragPx, setLiveDragPx] = useState(0); const [liveDragPx, setLiveDragPx] = useState(0);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
// Per-tab pin lives in `curtainPinnedByTabAtom` so the lock survives // Pin storage split: atom-backed when `pinKey` is supplied (survives
// the route-driven listing-pane unmount that happens when the user // listing-pane remount on Room navigate-back), local useState
// taps into a Room and back. The atom outlives any individual // fallback when no key is supplied (web/non-listing mounts where
// StreamHeader instance. // pinning isn't expected).
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom); const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
const pinned = !!pinnedMap[pinKey]; const [pinnedLocal, setPinnedLocal] = useState(false);
const pinned = pinKey ? !!pinnedMap[pinKey] : pinnedLocal;
const formMeasureRef = useRef<HTMLDivElement>(null); const formMeasureRef = useRef<HTMLDivElement>(null);
const setPinned = useCallback( const setPinned = useCallback(
(next: boolean) => { (next: boolean) => {
setPinnedMap((prev) => { if (pinKey) {
// Compare-and-skip so we don't allocate a fresh object (and setPinnedMap((prev) => {
// re-render every other subscriber of the atom) when nothing // Compare-and-skip so we don't allocate a fresh object (and
// actually changes. // re-render every other subscriber of the atom) when nothing
if (!!prev[pinKey] === next) return prev; // actually changes.
return { ...prev, [pinKey]: next }; if (!!prev[pinKey] === next) return prev;
}); return { ...prev, [pinKey]: next };
});
} else {
setPinnedLocal(next);
}
// Drop any in-flight live drag on commit so the curtain renders // Drop any in-flight live drag on commit so the curtain renders
// at the new pinned-derived top without a residual finger offset. // at the new pinned-derived top without a residual finger offset.
setLiveDragPx(0); setLiveDragPx(0);
@ -141,12 +145,12 @@ export function useCurtainState(pinKey: string): CurtainState {
setLiveDragPx(0); setLiveDragPx(0);
setIsDragging(false); setIsDragging(false);
// Safety net: clear pin so the form is visible. In practice the // Safety net: clear pin so the form is visible. In practice the
// visible openers (static pager header icons, in-pane chips on // static pager header's icons (the only call site of open()) are
// non-pager surfaces) are all covered by the curtain when pinned, // covered by the curtain when pinned, so the user can't trigger
// so the user can't trigger this directly — but a future // this directly — but a future programmatic open() or a per-pane
// programmatic open() would otherwise mount the form behind the // tabsRow that escapes the pager-mode visibility:hidden gate
// still-pinned curtain at curtainTop=0 and present an invisible // would otherwise mount the form behind the still-pinned curtain
// form. // at curtainTop=0 and the user would see an invisible form.
setPinned(false); setPinned(false);
}, },
[setPinned] [setPinned]
@ -158,14 +162,17 @@ export function useCurtainState(pinKey: string): CurtainState {
setIsDragging(false); setIsDragging(false);
}, []); }, []);
const commit = useCallback((next: 'peek' | 'closed') => { const commit = useCallback((next: CurtainSnap) => {
setSnap(next); setSnap(next);
setLiveDragPx(0); setLiveDragPx(0);
setIsDragging(false); setIsDragging(false);
// `activeForm` is intentionally NOT cleared here — it stays set if (isFormSnap(next)) {
// so the closing transition has form content beneath the curtain setActiveForm(next === 'form-search' ? 'search' : 'chat');
// as it slides up. `acknowledgeClosed` clears it once the snap }
// settles at `closed`. // Note: when committing to a non-form snap (peek*/closed) we do
// NOT clear `activeForm` here — it stays set so the closing
// transition has form content beneath. `acknowledgeClosed` clears
// it once the curtain settles at `closed`.
}, []); }, []);
const setLiveDrag = useCallback((px: number, dragging: boolean) => { const setLiveDrag = useCallback((px: number, dragging: boolean) => {

View file

@ -18,7 +18,6 @@ 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,
@ -35,14 +34,6 @@ 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}`;
@ -223,30 +214,22 @@ export class BotWidgetEmbed {
this.feedStateUpdate(ev); this.feedStateUpdate(ev);
}; };
// Side-channel postMessage handler for the widget's Vojo-extension // Side-channel postMessage handler for the widget's `openExternalUrl`
// actions. Distinct from matrix-widget-api's `fromWidget` channel // call. 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.
// //
// Two actions today: // 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.
// //
// * `open-external-url` — forwards an https:// URL to the host's // Security gates (defence in depth):
// `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
@ -259,17 +242,11 @@ 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
// Per-action URL validation (NOT shared, but each branch enforces): // https-only because no shipped widget content links over plain
// * `open-external-url` — requires `https:` protocol, rejecting plain // http; rejecting http closes a cleartext-redirect vector via
// http, javascript:, data:, file:, etc. We tightened from http+https // Capacitor `Browser.open` on Android.
// to https-only because no shipped widget content links over plain // 4. javascript:, data:, file:, etc. are implicitly rejected by (3).
// 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) => { 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;
@ -278,38 +255,18 @@ 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;
try {
if (msg.action === 'open-external-url') { const parsed = new URL(url);
try { if (parsed.protocol !== 'https:') return;
const parsed = new URL(url); } catch {
if (parsed.protocol !== 'https:') return;
} catch {
return;
}
openExternalUrl(url).catch(() => {
/* fire-and-forget: log handled inside openExternalUrl */
});
return; return;
} }
openExternalUrl(url).catch(() => {
if (msg.action === 'open-matrix-to') { /* fire-and-forget: log handled inside openExternalUrl */
// 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) {

View file

@ -1,16 +1,8 @@
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';
@ -42,46 +34,15 @@ 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 navigate = useNavigate(); const { ready } = useBotWidgetEmbed({ containerRef, preset, room, onError });
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. Reuses `mx` from the // connection-state surface; the top one defers.
// navigate-callback block above — single hook call per render. const mx = useMatrixClient();
const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState()); const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState());
useSyncState( useSyncState(
mx, mx,
@ -145,7 +106,10 @@ 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 (hideReason === 'sync' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { if (
hideReason === 'sync' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
setVisible(false); setVisible(false);
setPendingHide(false); setPendingHide(false);
return undefined; return undefined;

View file

@ -3,7 +3,6 @@ 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';
@ -12,9 +11,6 @@ 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 = {
@ -34,7 +30,6 @@ 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();
@ -48,12 +43,6 @@ 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)
@ -83,9 +72,6 @@ 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) {

View file

@ -22,11 +22,7 @@ export function BotStatePage({ title, description, icon, children }: BotStatePag
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
return ( return (
// Safe-top on the wrapper, not on `PageHeader`: the recipe is a <Page>
// folds `Header size="600"` with fixed height, so inner padding
// would clip its content (same constraint as PageNav, Page.tsx).
// No-op on web / inside Modal500 where `--vojo-safe-top` is 0px.
<Page style={{ paddingTop: 'var(--vojo-safe-top, 0px)' }}>
{screenSize === ScreenSize.Mobile && ( {screenSize === ScreenSize.Mobile && (
<PageHeader balance outlined={false}> <PageHeader balance outlined={false}>
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">

View file

@ -21,12 +21,9 @@ function BotRow({ preset }: { preset: BotPreset }) {
export function Bots() { export function Bots() {
const bots = useBotPresets(); const bots = useBotPresets();
// `scrollRef` is forwarded so the curtain body gesture can check // `scrollRef` is passed to the header so the touch gesture (native
// whether the list is scrollable and bail to native scroll on long // only) can recognise list scrollTop=0 and engage the curtain peek.
// lists. Short / empty lists let the curtain body itself drive the // Icons + click flows work on every platform regardless.
// gesture. The dedicated 32 px drag-handle on the curtain works
// regardless of this ref. Native-only — desktop / Electron have
// no curtain gestures.
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
// Skip PageNav surface in pager mode — see Direct.tsx for the // Skip PageNav surface in pager mode — see Direct.tsx for the
// rationale; the static header behind the strip owns the visible // rationale; the static header behind the strip owns the visible

View file

@ -0,0 +1,57 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar, Box, Icon, Icons, Text, color, config, toRem } from 'folds';
import { Room } from 'matrix-js-sdk';
import { NavButton, NavItem, NavItemContent } from '../../../components/nav';
import { useOpenCreateRoomModal } from '../../../state/hooks/createRoomModal';
import { CreateRoomType } from '../../../components/create-room/types';
const ROW_MIN_HEIGHT = toRem(56);
type ChannelCreateRowProps = {
space: Room;
};
export function ChannelCreateRow({ space }: ChannelCreateRowProps) {
const { t } = useTranslation();
const openCreateRoomModal = useOpenCreateRoomModal();
return (
<Box
style={{
padding: `${toRem(6)} ${config.space.S100}`,
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
<NavItem variant="Background" radii="400" style={{ minHeight: ROW_MIN_HEIGHT }}>
<NavButton
onClick={() => openCreateRoomModal(space.roomId, CreateRoomType.TextRoom)}
aria-label={t('Channels.create_channel')}
>
<NavItemContent>
<Box
as="span"
grow="Yes"
alignItems="Center"
gap="300"
style={{
minHeight: ROW_MIN_HEIGHT,
boxSizing: 'border-box',
padding: `${toRem(6)} 0`,
}}
>
<Avatar size="300" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes" style={{ minWidth: 0 }}>
<Text as="span" size="T300" truncate style={{ fontWeight: 600 }}>
{t('Channels.create_channel')}
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
</Box>
);
}

View file

@ -1,20 +1,14 @@
import React, { useEffect, useMemo, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useSetAtom } from 'jotai';
import { Icons } from 'folds';
import { useSpace } from '../../../hooks/useSpace'; import { useSpace } from '../../../hooks/useSpace';
import { PageNav, PageNavContent } from '../../../components/page'; import { PageNav, PageNavContent } from '../../../components/page';
import { StreamHeader } from '../../../components/stream-header'; import { StreamHeader } from '../../../components/stream-header';
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext'; import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
import { StreamHeaderPrimaryAction } from '../../../state/mobilePagerHeader';
import { useOpenCreateRoomModal } from '../../../state/hooks/createRoomModal';
import { useOpenCreateSpaceModal } from '../../../state/hooks/createSpaceModal';
import { activeChannelsSpaceAtom } from '../../../state/activeChannelsSpace';
import { CreateRoomType } from '../../../components/create-room/types';
import { ChannelsList } from './ChannelsList'; import { ChannelsList } from './ChannelsList';
import { ChannelCreateRow } from './ChannelCreateRow';
import { ChannelsLanding } from './ChannelsLanding'; import { ChannelsLanding } from './ChannelsLanding';
import { ChannelsWorkspaceHorseshoe } from './ChannelsWorkspaceHorseshoe'; import { ChannelsWorkspaceHorseshoe } from './ChannelsWorkspaceHorseshoe';
import { WorkspaceFooter } from './WorkspaceFooter'; import { WorkspaceFooter } from './WorkspaceFooter';
import { ACTIVE_SPACE_KEY } from './useActiveSpace';
// Index route at /channels/ (no space selected). Renders the shared // Index route at /channels/ (no space selected). Renders the shared
// StreamHeader (segment switcher) plus the resolve-active-space-or- // StreamHeader (segment switcher) plus the resolve-active-space-or-
@ -31,34 +25,19 @@ import { WorkspaceFooter } from './WorkspaceFooter';
// it in `PageNavContent` (block layout inside Scroll) would collapse // it in `PageNavContent` (block layout inside Scroll) would collapse
// the centering to top-aligned. Same idiom as `Direct.tsx::DirectEmpty`. // the centering to top-aligned. Same idiom as `Direct.tsx::DirectEmpty`.
export function ChannelsRootNav() { export function ChannelsRootNav() {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const openCreateSpaceModal = useOpenCreateSpaceModal();
// Skip PageNav surface in pager mode so the pager's static header // Skip PageNav surface in pager mode so the pager's static header
// tabs (sitting behind the swipe strip in DOM order) show through // tabs (sitting behind the swipe strip in DOM order) show through
// until covered by the rising curtain. See Direct.tsx for the same // until covered by the rising curtain. See Direct.tsx for the same
// pattern and `mobile-tabs-pager/style.css.ts::pagerStaticHeader` // pattern and `mobile-tabs-pager/style.css.ts::pagerStaticHeader`
// for the full overlay contract. // for the full overlay contract.
const inPagerMode = useMobilePagerPane() !== null; const inPagerMode = useMobilePagerPane() !== null;
// Landing has no active workspace yet, so the Plus slot launches
// workspace creation — mirrors the landing's own CTA, just promoted
// into the tabs row so the action is reachable without scrolling
// and matches the workspace-selected variant's «one Plus on the tab»
// contract.
const primaryAction = useMemo<StreamHeaderPrimaryAction>(
() => ({
iconSrc: Icons.Plus,
label: t('Channels.workspace_switcher_create_space'),
onClick: () => openCreateSpaceModal(),
}),
[t, openCreateSpaceModal]
);
return ( return (
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}> <PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
{/* Shared `pinKey` with the workspace-listing `Channels` below {/* Shared `pinKey` with the workspace-listing `Channels` below
so pin/unpin persists when the user toggles between the so pin/unpin persists when the user toggles between the
landing CTA and a chosen workspace within the Channels tab. */} landing CTA and a chosen workspace within the Channels tab. */}
<StreamHeader scrollRef={scrollRef} pinKey="channels" primaryAction={primaryAction}> <StreamHeader scrollRef={scrollRef} pinKey="channels">
<ChannelsLanding /> <ChannelsLanding />
</StreamHeader> </StreamHeader>
</PageNav> </PageNav>
@ -75,33 +54,21 @@ export function ChannelsRootNav() {
// global rail's «click avatar → resume your last in-space path» stays // global rail's «click avatar → resume your last in-space path» stays
// well-defined. Channels has its own segment-level navigation. // well-defined. Channels has its own segment-level navigation.
export function Channels() { export function Channels() {
const { t } = useTranslation();
const space = useSpace(); const space = useSpace();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const inPagerMode = useMobilePagerPane() !== null; const inPagerMode = useMobilePagerPane() !== null;
const openCreateRoomModal = useOpenCreateRoomModal();
const setActiveSpace = useSetAtom(activeChannelsSpaceAtom);
// Persist URL-driven active space so cold-starts at /channels/ resume on // Persist URL-driven active space so cold-starts at /channels/ resume on
// the same workspace. `useActiveSpace` (in ChannelsLanding and // the same workspace. `useActiveSpace` (in ChannelsLanding) reads the
// MobileTabsPager) subscribes to the same atom, so this write // value but never writes it because the index route has no
// immediately invalidates their cached reads — without that, the pager // :spaceIdOrAlias param — the write happens here.
// would serve a stale `destinationFor('channels')` after a switcher
// pick + tab swap on native.
useEffect(() => { useEffect(() => {
setActiveSpace(space.roomId); try {
}, [setActiveSpace, space.roomId]); localStorage.setItem(ACTIVE_SPACE_KEY, space.roomId);
} catch {
// Plus on the tabs row creates a channel inside the active workspace. /* private mode / quota — non-fatal */
// Single entry point for «create» on the Channels tab. }
const primaryAction = useMemo<StreamHeaderPrimaryAction>( }, [space.roomId]);
() => ({
iconSrc: Icons.Plus,
label: t('Channels.create_channel'),
onClick: () => openCreateRoomModal(space.roomId, CreateRoomType.TextRoom),
}),
[t, openCreateRoomModal, space.roomId]
);
return ( return (
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}> <PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
@ -109,8 +76,12 @@ export function Channels() {
<StreamHeader <StreamHeader
scrollRef={scrollRef} scrollRef={scrollRef}
pinKey="channels" pinKey="channels"
primaryAction={primaryAction} bottomPinned={
bottomPinned={<WorkspaceFooter space={space} />} <>
<ChannelCreateRow space={space} />
<WorkspaceFooter space={space} />
</>
}
> >
<PageNavContent scrollRef={scrollRef}> <PageNavContent scrollRef={scrollRef}>
<ChannelsList scrollRef={scrollRef} /> <ChannelsList scrollRef={scrollRef} />

View file

@ -41,8 +41,8 @@ export const container = style({
marginTop: 'calc(-1 * var(--vojo-safe-top, 0px))', marginTop: 'calc(-1 * var(--vojo-safe-top, 0px))',
}); });
// Wrapped children (StreamHeader → ChannelsList → WorkspaceFooter). // Wrapped children (StreamHeader → ChannelsList → ChannelCreateRow →
// Stays put — the bottom is carved away by an animated // WorkspaceFooter). Stays put — the bottom is carved away by an animated
// `clip-path: inset(...)` with rounded BL/BR. `backgroundColor` is // `clip-path: inset(...)` with rounded BL/BR. `backgroundColor` is
// load-bearing: the container is painted with the void colour when the // load-bearing: the container is painted with the void colour when the
// sheet is active, so without an opaque bg the void would bleed through // sheet is active, so without an opaque bg the void would bleed through

View file

@ -70,13 +70,11 @@ type SpaceRowProps = {
}; };
// One space row in the sheet. Mirrors the DM-row visual: avatar 40, // One space row in the sheet. Mirrors the DM-row visual: avatar 40,
// two text lines (name + member-count subtitle). Variant is // two text lines (name + member-count subtitle). The active community
// `SurfaceVariant` so the inactive row bg (`SurfaceVariant.Container` // is signalled solely via `aria-selected` — Folds NavItem paints
// = #181a20) matches the sheet silhouette and blends into it — same // `ContainerActive` on `&[aria-selected=true]`, which is enough on
// treatment as `CreateCommunityRow` above. The active community is // this surface tier; an explicit trailing label was tried and
// signalled solely via `aria-selected` → `ContainerActive` (#2a2d36), // dropped per product call.
// which lifts cleanly off the sheet without a card outline; an
// explicit trailing label was tried and dropped per product call.
// //
// `useRoomName` lives here so the per-space subscription stays scoped // `useRoomName` lives here so the per-space subscription stays scoped
// (Rules of Hooks) and admin renames reflect in the dropdown without // (Rules of Hooks) and admin renames reflect in the dropdown without
@ -88,7 +86,7 @@ function SpaceRow({ space, isActive, onPick }: SpaceRowProps) {
return ( return (
<NavItem <NavItem
variant="SurfaceVariant" variant="Background"
radii="400" radii="400"
aria-selected={isActive} aria-selected={isActive}
style={{ minHeight: ROW_MIN_HEIGHT }} style={{ minHeight: ROW_MIN_HEIGHT }}

View file

@ -1,10 +1,18 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { activeChannelsSpaceAtom } from '../../../state/activeChannelsSpace';
import { getCanonicalAliasRoomId, isRoomAlias } from '../../../utils/matrix'; import { getCanonicalAliasRoomId, isRoomAlias } from '../../../utils/matrix';
export const ACTIVE_SPACE_KEY = 'vojo.activeSpaceId';
const readPersisted = (): string | null => {
try {
return localStorage.getItem(ACTIVE_SPACE_KEY);
} catch {
return null;
}
};
const safeDecode = (raw: string): string | undefined => { const safeDecode = (raw: string): string | undefined => {
try { try {
return decodeURIComponent(raw); return decodeURIComponent(raw);
@ -15,22 +23,16 @@ const safeDecode = (raw: string): string | undefined => {
// Resolves the active Space for the channels segment. Priority: // Resolves the active Space for the channels segment. Priority:
// 1. URL `:spaceIdOrAlias` param (if joined-orphan) // 1. URL `:spaceIdOrAlias` param (if joined-orphan)
// 2. `activeChannelsSpaceAtom` (= localStorage, reactive) (if joined-orphan) // 2. localStorage['vojo.activeSpaceId'] (if joined-orphan)
// 3. first joined-orphan Space // 3. first joined-orphan Space
// Returns undefined when the user has 0 joined orphan spaces. Persistence // Returns undefined when the user has 0 joined orphan spaces. Persistence
// (writing the atom) lives in `Channels.tsx`, where the inner route // (writing to localStorage) lives in `Channels.tsx`, where the inner
// already has the resolved space context — at the index `/channels/` // route already has the resolved space context — at the index `/channels/`
// route, useParams().spaceIdOrAlias is always undefined. // route, useParams().spaceIdOrAlias is always undefined.
//
// The persisted value is read via Jotai so writes from `Channels.tsx`
// immediately invalidate every consumer (notably `MobileTabsPager`,
// which stays mounted across tab swipes and would otherwise serve a
// stale cached value to its `destinationFor('channels')`).
export const useActiveSpace = (orphanSpaceIds: string[]): string | undefined => { export const useActiveSpace = (orphanSpaceIds: string[]): string | undefined => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const { spaceIdOrAlias } = useParams(); const { spaceIdOrAlias } = useParams();
const orphanSet = useMemo(() => new Set(orphanSpaceIds), [orphanSpaceIds]); const orphanSet = useMemo(() => new Set(orphanSpaceIds), [orphanSpaceIds]);
const persisted = useAtomValue(activeChannelsSpaceAtom);
const urlSpaceId = useMemo(() => { const urlSpaceId = useMemo(() => {
if (!spaceIdOrAlias) return undefined; if (!spaceIdOrAlias) return undefined;
@ -40,10 +42,10 @@ export const useActiveSpace = (orphanSpaceIds: string[]): string | undefined =>
return resolved && orphanSet.has(resolved) ? resolved : undefined; return resolved && orphanSet.has(resolved) ? resolved : undefined;
}, [mx, spaceIdOrAlias, orphanSet]); }, [mx, spaceIdOrAlias, orphanSet]);
const persistedSpaceId = useMemo( const persistedSpaceId = useMemo(() => {
() => (persisted && orphanSet.has(persisted) ? persisted : undefined), const stored = readPersisted();
[persisted, orphanSet] return stored && orphanSet.has(stored) ? stored : undefined;
); }, [orphanSet]);
return urlSpaceId ?? persistedSpaceId ?? orphanSpaceIds[0]; return urlSpaceId ?? persistedSpaceId ?? orphanSpaceIds[0];
}; };

View file

@ -41,43 +41,20 @@ 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 = tryDecodeHref(href).match(MATRIX_TO_USER); const match = 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 = tryDecodeHref(href).match(MATRIX_TO_ROOM); const match = href.match(MATRIX_TO_ROOM);
if (!match) return undefined; if (!match) return undefined;
const roomIdOrAlias = match[1]; const roomIdOrAlias = match[1];
@ -91,7 +68,7 @@ export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => {
}; };
export const parseMatrixToRoomEvent = (href: string): MatrixToRoomEvent | undefined => { export const parseMatrixToRoomEvent = (href: string): MatrixToRoomEvent | undefined => {
const match = tryDecodeHref(href).match(MATRIX_TO_ROOM_EVENT); const match = href.match(MATRIX_TO_ROOM_EVENT);
if (!match) return undefined; if (!match) return undefined;
const roomIdOrAlias = match[1]; const roomIdOrAlias = match[1];

View file

@ -1,37 +0,0 @@
import { atomWithLocalStorage } from './utils/atomWithLocalStorage';
// Persisted across reloads and tabs as a plain string (not JSON-encoded)
// to stay backwards-compatible with the legacy `localStorage.setItem(key, roomId)`
// writes that shipped before this atom existed.
export const ACTIVE_CHANNELS_SPACE_KEY = 'vojo.activeSpaceId';
const getRawString = (key: string): string | undefined => {
try {
return localStorage.getItem(key) ?? undefined;
} catch {
return undefined;
}
};
const setRawString = (key: string, value: string | undefined) => {
try {
if (value === undefined) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, value);
}
} catch {
/* private mode / quota — non-fatal */
}
};
// Single source of truth for the channels-tab «active workspace» selection.
// Subscribed reads (via `useAtomValue`) re-render when the atom is written
// in the same tab and when another tab updates localStorage — fixes the
// stale-memo bug where `MobileTabsPager` cached the persisted value at
// first render and never refreshed after the user picked a new workspace.
export const activeChannelsSpaceAtom = atomWithLocalStorage<string | undefined>(
ACTIVE_CHANNELS_SPACE_KEY,
getRawString,
setRawString
);

View file

@ -1,17 +1,4 @@
import { atom } from 'jotai'; import { atom } from 'jotai';
import { IconSrc } from 'folds';
// Per-tab «primary action» published by a pane's StreamHeader. When
// present it replaces the default Plus / «new chat» button in the
// static header (and the matching peek-chip in the per-pane StreamHeader).
// Channels uses this to surface «create channel» (inside a workspace)
// or «create community» (on the landing) instead of the DM-creation
// path that Direct keeps as default.
export type StreamHeaderPrimaryAction = {
iconSrc: IconSrc;
label: string;
onClick: () => void;
};
// Controls exposed by the active pane's curtain to the shared static // Controls exposed by the active pane's curtain to the shared static
// tabs row at the top of MobileTabsPager. The pager hoists the tabs + // tabs row at the top of MobileTabsPager. The pager hoists the tabs +
@ -30,10 +17,6 @@ export type MobilePagerCurtainControls = {
openChat: () => void; openChat: () => void;
closeForm: () => void; closeForm: () => void;
isFormActive: boolean; isFormActive: boolean;
// Optional tab-specific override for the Plus button. When null the
// static header renders the default «new chat» Plus that triggers
// `openChat`.
primaryAction: StreamHeaderPrimaryAction | null;
}; };
export const mobilePagerCurtainAtom = atom<MobilePagerCurtainControls | null>(null); export const mobilePagerCurtainAtom = atom<MobilePagerCurtainControls | null>(null);