Compare commits

...

10 commits

Author SHA1 Message Date
v.lagerev
4a2e29fdb5 fix(channels): back active-workspace persistence with a jotai atom so the native pager sees switcher picks instead of a stale memoized localStorage read 2026-05-22 01:18:15 +03:00
v.lagerev
f104bdfe8b feat(discord-widget): render Open-in-Channels card after login via VOJO-LOGIN-SPACE-V1 sentinel and generic open-matrix-to widget action 2026-05-21 14:08:50 +03:00
v.lagerev
a62761af06 chore(eslint): give widget apps their own Preact-aware root config so host airbnb and react rules stop flagging valid Preact code in pre-commit 2026-05-21 14:08:21 +03:00
v.lagerev
2720830cb0 fix(telegram-widget): stack password row column on mobile so the show toggle does not overflow off the right edge 2026-05-21 13:43:11 +03:00
v.lagerev
1d788ee20f fix(workspace-switcher): switch space rows to SurfaceVariant so inactive bg blends with the sheet silhouette instead of reading as dark cards 2026-05-21 01:05:34 +03:00
v.lagerev
298669a084 feat(stream-header): flatten web curtain to a tabsRow-border divider pixel-aligned with PageHeader at WEB_TABS_ROW_PX=54 and gate keyboard probe to native 2026-05-20 23:20:17 +03:00
v.lagerev
ab75a178e4 fix(bots): pad BotStatePage wrapper with safe-top so the mobile back-arrow header clears the Android status bar on the connect-bot empty state 2026-05-20 02:39:21 +03:00
v.lagerev
94ec309120 feat(stream-header): contextual Plus on Channels opens create-channel inside workspace and create-community on landing via StreamHeader.primaryAction 2026-05-20 01:59:04 +03:00
v.lagerev
23cebcd38f refactor(stream-header): reset live drag on gesture teardown, drop dead pinned-local fallback, narrow commit() to peek|closed, add exhaustive transition guards and align stale comments 2026-05-20 00:59:17 +03:00
v.lagerev
16cb9c5b26 feat(stream-header): free-range curtain drag through full pin↔closed↔peek range with bottomPinned-aware body bail and native-only handle 2026-05-20 00:26:10 +03:00
30 changed files with 1106 additions and 430 deletions

60
apps/.eslintrc.cjs Normal file
View file

@ -0,0 +1,60 @@
// 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,6 +95,18 @@ const LinkIcon = () => (
</svg> </svg>
); );
// 2×2 grid of rounded squares — leads the OpenSpaceCard. Reads as
// «space with channels inside»; consistent visual vocabulary with the
// channels-tab workspace grid affordances on the host side.
const SpaceGridIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="3.5" y="3.5" width="5.5" height="5.5" rx="1.4" />
<rect x="11" y="3.5" width="5.5" height="5.5" rx="1.4" />
<rect x="3.5" y="11" width="5.5" height="5.5" rx="1.4" />
<rect x="11" y="11" width="5.5" height="5.5" rx="1.4" />
</svg>
);
// Linkifier — same heuristic as TG widget. // Linkifier — same heuristic as TG widget.
const URL_RE = /https?:\/\/[^\s)]+/g; const URL_RE = /https?:\/\/[^\s)]+/g;
@ -388,6 +400,13 @@ const loadHCaptcha = (): Promise<HCaptchaApi> => {
`script[src^="https://js.hcaptcha.com/1/api.js"]` `script[src^="https://js.hcaptcha.com/1/api.js"]`
) as HTMLScriptElement | null; ) as HTMLScriptElement | null;
// `timeoutHandle` is read in the `settle` closure declared below
// BEFORE the assignment at the bottom of this function. ESLint's
// flow analysis can't see the deferred assignment through the
// closure and flags this as never-reassigned; in practice the value
// IS reassigned and using `const` here would break the hcaptcha
// script-load timeout path.
// eslint-disable-next-line prefer-const
let timeoutHandle: number | undefined; let timeoutHandle: number | undefined;
let settled = false; let settled = false;
const settle = (action: () => void) => { const settle = (action: () => void) => {
@ -599,9 +618,7 @@ const CaptchaPanel = ({ state, t, onSolved, onCancel, onExpired }: CaptchaPanelP
<div class="auth-card-hint">{t('auth-card.captcha.hint')}</div> <div class="auth-card-hint">{t('auth-card.captcha.hint')}</div>
<div class="auth-card-captcha-frame"> <div class="auth-card-captcha-frame">
<div ref={containerRef} class="auth-card-captcha-host" /> <div ref={containerRef} class="auth-card-captcha-host" />
{loadError ? ( {loadError ? <div class="auth-card-error">{t('auth-card.captcha.load-error')}</div> : null}
<div class="auth-card-error">{t('auth-card.captcha.load-error')}</div>
) : null}
</div> </div>
<div class="auth-card-row"> <div class="auth-card-row">
<button type="button" class="btn-text" onClick={onCancel}> <button type="button" class="btn-text" onClick={onCancel}>
@ -764,6 +781,40 @@ const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => {
); );
}; };
// --------------------------------------------------------------------------
// Open-space card — Vojo extension
// --------------------------------------------------------------------------
type OpenSpaceCardProps = {
t: T;
matrixToUrl: string;
onOpen: (url: string) => void;
};
// Surfaces the personal Discord space the bridge auto-created at login.
// Renders only when `state.spaceMatrixToUrl` is populated — i.e. against a
// Vojo-patched bridge that emitted `VOJO-LOGIN-SPACE-V1`. Against an
// upstream/unpatched bridge the card is absent (no sentinel, no URL, the
// `space_ready` reducer case never fires).
//
// Click hands the URL to the host via the `io.vojo.bot-widget`
// side-channel (api.openMatrixToUrl) — the widget is sandboxed and
// can't navigate cinny itself. Host validates and routes.
const OpenSpaceCard = ({ t, matrixToUrl, onOpen }: OpenSpaceCardProps) => (
<button class="command-card" type="button" onClick={() => onOpen(matrixToUrl)}>
<span class="command-card-lead-icon" aria-hidden="true">
<SpaceGridIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.open-space.name')}</div>
<div class="command-card-desc">{t('card.open-space.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
);
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Main App // Main App
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -927,6 +978,15 @@ export function App({ bootstrap, api }: Props) {
// hydrate too; the live path treats it identically. // hydrate too; the live path treats it identically.
append({ kind: 'diag', text: t('diag.captcha-issued') }); append({ kind: 'diag', text: t('diag.captcha-issued') });
appendedAnyHistory = true; appendedAnyHistory = true;
} else if (parsed.kind === 'space_ready') {
// VOJO-LOGIN-SPACE-V1 sentinel body is a JSON blob —
// machine-readable, never user-readable. Suppress the raw
// body from the transcript and emit a diag breadcrumb
// instead so a reload-replay shows «space ready» rather
// than `VOJO-LOGIN-SPACE-V1 {"matrix_to_url":"…"}` ugly
// verbatim. Same discipline as the captcha branch above.
append({ kind: 'diag', text: t('diag.space-ready') });
appendedAnyHistory = true;
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') { } else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
// m.text / m.notice — body is safe to replay verbatim, // m.text / m.notice — body is safe to replay verbatim,
// BUT we still scrub any login-URL-shaped substring as // BUT we still scrub any login-URL-shaped substring as
@ -989,10 +1049,7 @@ export function App({ bootstrap, api }: Props) {
append({ kind: 'diag', text: t('diag.qr-issued') }); append({ kind: 'diag', text: t('diag.qr-issued') });
} else if (event.kind === 'qr_redacted') { } else if (event.kind === 'qr_redacted') {
const liveState = stateRef.current; const liveState = stateRef.current;
if ( if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
liveState.kind === 'awaiting_qr_scan' &&
liveState.qrEventId === event.redactsEventId
) {
append({ kind: 'diag', text: t('diag.qr-consumed') }); append({ kind: 'diag', text: t('diag.qr-consumed') });
} }
} else if (event.kind === 'captcha_challenge') { } else if (event.kind === 'captcha_challenge') {
@ -1001,6 +1058,12 @@ export function App({ bootstrap, api }: Props) {
// transcript DOM (where screenshots / accessibility tools could // transcript DOM (where screenshots / accessibility tools could
// leak them). Diag-only display. // leak them). Diag-only display.
append({ kind: 'diag', text: t('diag.captcha-issued') }); append({ kind: 'diag', text: t('diag.captcha-issued') });
} else if (event.kind === 'space_ready') {
// Sentinel body is the JSON `{"matrix_to_url":"…"}` — not human-
// readable and pointless to show verbatim. Emit a diag breadcrumb;
// the actual «Open in Channels» card is rendered by the reducer
// attaching `spaceMatrixToUrl` to the connected state.
append({ kind: 'diag', text: t('diag.space-ready') });
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') { } else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
const body = ev.content.body ?? ''; const body = ev.content.body ?? '';
append({ kind: 'from-bot', text: `${scrubLoginSecret(body)}` }); append({ kind: 'from-bot', text: `${scrubLoginSecret(body)}` });
@ -1185,9 +1248,7 @@ export function App({ bootstrap, api }: Props) {
// entry, but a manual disconnect path could leave us in connected // entry, but a manual disconnect path could leave us in connected
// and trigger reconnect from there). // and trigger reconnect from there).
const handle = const handle =
state.kind === 'connected_dead' || state.kind === 'connected' state.kind === 'connected_dead' || state.kind === 'connected' ? state.handle : undefined;
? state.handle
: undefined;
dispatch({ kind: 'request_reconnect', handle }); dispatch({ kind: 'request_reconnect', handle });
try { try {
await sendBare('reconnect'); await sendBare('reconnect');
@ -1353,6 +1414,17 @@ export function App({ bootstrap, api }: Props) {
} }
/> />
<div class="command-grid"> <div class="command-grid">
{/* Open-space CTA only against a Vojo-patched bridge that
* emitted the VOJO-LOGIN-SPACE-V1 sentinel. Listed first so a
* fresh post-login user sees «next step» before the Logout
* destructive action. */}
{state.spaceMatrixToUrl ? (
<OpenSpaceCard
t={t}
matrixToUrl={state.spaceMatrixToUrl}
onOpen={(url) => api.openMatrixToUrl(url)}
/>
) : null}
<LogoutCard t={t} onConfirm={onConfirmLogout} /> <LogoutCard t={t} onConfirm={onConfirmLogout} />
<AboutCard t={t} onOpen={() => setAboutOpen(true)} /> <AboutCard t={t} onOpen={() => setAboutOpen(true)} />
</div> </div>

View file

@ -56,6 +56,15 @@ const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i;
const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1'; const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1';
const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/; const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/;
// Vojo-patched bridge emits this sentinel right after «Successfully logged
// in as @user» (commands_login_space.go::sendLoginSpaceNotice). Carries the
// matrix.to URL of the user's personal Discord space so the widget can
// render a CTA. Same markdown-inert + structured-JSON discipline as the
// captcha sentinel above; the bridge sends this via SendMessageEvent to
// bypass goldmark round-trip.
const LOGIN_SPACE_SENTINEL_PREFIX = 'VOJO-LOGIN-SPACE-V1';
const LOGIN_SPACE_SENTINEL_RE = /^VOJO-LOGIN-SPACE-V1\s+(\{[\s\S]*\})\s*$/;
// Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED // Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login // upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
// instead». Kept so a deployment running unpatched bridge still produces a // instead». Kept so a deployment running unpatched bridge still produces a
@ -160,6 +169,28 @@ export const parseLegacyV076Body = (rawBody: string): LoginEvent => {
} }
if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' }; if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' };
// Vojo login-space sentinel: structured JSON with the personal Discord
// space's matrix.to URL. Checked alongside the captcha sentinel —
// markdown-inert prefix means it lands verbatim from the bridge, parsed
// into a `space_ready` event for the reducer to attach to connected state.
// Malformed payload (missing/empty `matrix_to_url`, JSON parse failure) is
// silently dropped as `unknown` rather than surfacing a stale CTA.
if (body.startsWith(LOGIN_SPACE_SENTINEL_PREFIX)) {
const match = LOGIN_SPACE_SENTINEL_RE.exec(body);
if (match) {
try {
const payload = JSON.parse(match[1]) as Record<string, unknown>;
const matrixToUrl = typeof payload.matrix_to_url === 'string' ? payload.matrix_to_url : '';
if (matrixToUrl) {
return { kind: 'space_ready', matrixToUrl };
}
} catch {
// fall through — malformed payload is treated as unknown
}
}
return { kind: 'unknown' };
}
const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body); const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body);
if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() }; if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() };
@ -247,8 +278,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 };
} }
@ -330,20 +361,11 @@ function runSanityChecks(): void {
// Login success (post-QR scan). No snowflake in this line; App fires // Login success (post-QR scan). No snowflake in this line; App fires
// `ping` afterwards to pick up the discordId. // `ping` afterwards to pick up the discordId.
[ ['Successfully logged in as @example', { kind: 'login_success', handle: 'example' }],
'Successfully logged in as @example', ['Successfully logged in as @user.name', { kind: 'login_success', handle: 'user.name' }],
{ kind: 'login_success', handle: 'example' },
],
[
'Successfully logged in as @user.name',
{ kind: 'login_success', handle: 'user.name' },
],
// Login failure paths. // Login failure paths.
[ ['Error logging in: rate limited 429', { kind: 'login_failed', reason: 'rate limited 429' }],
'Error logging in: rate limited 429',
{ kind: 'login_failed', reason: 'rate limited 429' },
],
// CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on // CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on
// unpatched upstream v0.7.6. // unpatched upstream v0.7.6.
[ [
@ -387,10 +409,7 @@ function runSanityChecks(): void {
// Logout. // Logout.
['Logged out successfully.', { kind: 'logout_ok' }], ['Logged out successfully.', { kind: 'logout_ok' }],
[ ["You weren't logged in, but data was re-cleared just to be safe.", { kind: 'logout_no_op' }],
"You weren't logged in, but data was re-cleared just to be safe.",
{ kind: 'logout_no_op' },
],
// Disconnect / reconnect. // Disconnect / reconnect.
['Successfully disconnected', { kind: 'disconnect_ok' }], ['Successfully disconnected', { kind: 'disconnect_ok' }],
@ -521,7 +540,9 @@ function runSanityChecks(): void {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected }); console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected });
throw new Error( throw new Error(
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? '<none>'}` `legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${
event.content?.msgtype ?? '<none>'
}`
); );
} }
} }

View file

@ -113,6 +113,15 @@ export type LoginEvent =
| { kind: 'reconnect_no_op' } | { kind: 'reconnect_no_op' }
| { kind: 'reconnect_failed'; reason?: string } | { kind: 'reconnect_failed'; reason?: string }
// --- Vojo: bridge-managed personal space ---------------------------------
// Vojo-patched bridge emits the sentinel `VOJO-LOGIN-SPACE-V1 {...}` as a
// separate m.notice right after the «Successfully logged in» line. Carries
// a `matrix.to` URL pointing at the user's auto-created Discord space
// (user.go::GetSpaceRoom on the bridge side). The widget surfaces this as
// an «Open in Channels» card; click → host navigates cinny to the space.
// See vojo-mautrix-discord/commands_login_space.go for the wire format.
| { kind: 'space_ready'; matrixToUrl: string }
// --- bridge-side errors -------------------------------------------------- // --- bridge-side errors --------------------------------------------------
// Generic «I don't know that command» — should not happen since we only // Generic «I don't know that command» — should not happen since we only
// ship known commands, but visible if the bridge image is misconfigured // ship known commands, but visible if the bridge image is misconfigured

View file

@ -55,13 +55,11 @@ export const EN: Record<StringKey, string> = {
'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the 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': 'auth-error.captcha-expired': 'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
'auth-error.login-failed': 'Sign-in failed: {reason}', 'auth-error.login-failed': 'Sign-in failed: {reason}',
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}', 'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}',
'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}', 'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}',
'auth-error.connect-after-login-failed': 'auth-error.connect-after-login-failed': 'Signed in, but could not connect to Discord: {reason}',
'Signed in, but could not connect to Discord: {reason}',
'auth-error.already-logged-in': 'You are already signed in to Discord — refresh status.', 'auth-error.already-logged-in': 'You are already signed in to Discord — refresh status.',
'auth-error.unknown-command': 'auth-error.unknown-command':
'The bot does not recognise this command — check the prefix in config.json.', 'The bot does not recognise this command — check the prefix in config.json.',
@ -73,6 +71,9 @@ export const EN: Record<StringKey, string> = {
'card.logout.confirm-prompt': 'Sign out for real?', 'card.logout.confirm-prompt': 'Sign out for real?',
'card.logout.confirm-yes': 'Sign out', 'card.logout.confirm-yes': 'Sign out',
'card.logout.confirm-no': 'Cancel', 'card.logout.confirm-no': 'Cancel',
'card.open-space.name': 'Open in Channels',
'card.open-space.desc': 'Jump to your Discord space with all chats and servers',
'diag.space-ready': 'Discord space ready to open.',
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.', 'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
'diag.ready': 'Ready to send commands.', 'diag.ready': 'Ready to send commands.',
'diag.checking-status': 'Checking connection status…', 'diag.checking-status': 'Checking connection status…',

View file

@ -86,8 +86,7 @@ export const RU = {
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.', 'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
'auth-error.captcha-send-failed': 'auth-error.captcha-send-failed':
'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.', 'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.',
'auth-error.captcha-expired': 'auth-error.captcha-expired': 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
'auth-error.login-failed': 'Не удалось войти: {reason}', 'auth-error.login-failed': 'Не удалось войти: {reason}',
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}', 'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}', 'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
@ -106,7 +105,11 @@ export const RU = {
'card.logout.confirm-prompt': 'Точно выйти?', 'card.logout.confirm-prompt': 'Точно выйти?',
'card.logout.confirm-yes': 'Выйти', 'card.logout.confirm-yes': 'Выйти',
'card.logout.confirm-no': 'Отмена', 'card.logout.confirm-no': 'Отмена',
// --- Open Discord space (Vojo bridge sentinel) ------------------------
'card.open-space.name': 'Открыть в Каналах',
'card.open-space.desc': 'Перейти в спейс Discord со списком чатов и серверов',
// --- Diagnostics in transcript ---------------------------------------- // --- Diagnostics in transcript ----------------------------------------
'diag.space-ready': 'Discord-спейс готов к открытию.',
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.', 'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
'diag.ready': 'Готов отправлять команды.', 'diag.ready': 'Готов отправлять команды.',
'diag.checking-status': 'Проверяю статус подключения…', 'diag.checking-status': 'Проверяю статус подключения…',

View file

@ -104,8 +104,13 @@ export type LoginState =
| { kind: 'reconnecting'; handle?: string } | { kind: 'reconnecting'; handle?: string }
// Live session — ping or login_success confirmed. Discord legacy bridge // Live session — ping or login_success confirmed. Discord legacy bridge
// doesn't have a per-account loginId concept (single Discord account // doesn't have a per-account loginId concept (single Discord account
// per Matrix user), so logout doesn't need an id. // per Matrix user), so logout doesn't need an id. `spaceMatrixToUrl`
| { kind: 'connected'; handle: string; discordId?: string } // is populated from the Vojo `VOJO-LOGIN-SPACE-V1` sentinel that lands
// right after login_success; it survives the post-login re-ping and the
// reconnect-ok transitions so the «Open in Channels» card stays visible
// until logout. Absent until the sentinel arrives (and absent forever
// against an UNPATCHED bridge — the card simply never appears).
| { kind: 'connected'; handle: string; discordId?: string; spaceMatrixToUrl?: string }
// ping says we have a token but the connection's down. Status pill: // ping says we have a token but the connection's down. Status pill:
// green-ish but with a Reconnect recovery action exposed. The reducer // green-ish but with a Reconnect recovery action exposed. The reducer
// distinguishes `connection_dead` (Discord WS dropped) from `token_stored` // distinguishes `connection_dead` (Discord WS dropped) from `token_stored`
@ -120,10 +125,7 @@ export type LoginState =
// staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but // staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but
// often valid for a couple of minutes — fresh enough to reuse). Other // often valid for a couple of minutes — fresh enough to reuse). Other
// transient states (logging_out, reconnecting) deliberately don't survive. // transient states (logging_out, reconnecting) deliberately don't survive.
export type HydrateRestoredState = export type HydrateRestoredState = PendingFormState | CaptchaSolveState | { kind: 'qr_verifying' };
| PendingFormState
| CaptchaSolveState
| { kind: 'qr_verifying' };
// Outbound user actions the App dispatches. Form-submit actions clear any // Outbound user actions the App dispatches. Form-submit actions clear any
// pending lastError; structural transitions optimistically advance state — // pending lastError; structural transitions optimistically advance state —
@ -169,9 +171,7 @@ const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiti
const isCaptchaAcceptingState = ( const isCaptchaAcceptingState = (
s: LoginState s: LoginState
): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState => ): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState =>
s.kind === 'awaiting_qr_scan' || s.kind === 'awaiting_qr_scan' || s.kind === 'qr_verifying' || s.kind === 'awaiting_captcha_solve';
s.kind === 'qr_verifying' ||
s.kind === 'awaiting_captcha_solve';
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => { export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
if (action.kind === 'hydrate') { if (action.kind === 'hydrate') {
@ -266,11 +266,14 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
case 'logged_in': case 'logged_in':
// Authoritative source — accept from any state. Used by both the // Authoritative source — accept from any state. Used by both the
// initial ping AND the post-`login_success` re-ping that picks up // initial ping AND the post-`login_success` re-ping that picks up
// the discordId snowflake. // the discordId snowflake. Preserve `spaceMatrixToUrl` from a prior
// `connected` so the post-login_success re-ping doesn't blank the
// CTA before the user gets a chance to click it.
return { return {
kind: 'connected', kind: 'connected',
handle: event.handle, handle: event.handle,
discordId: event.discordId, discordId: event.discordId,
spaceMatrixToUrl: state.kind === 'connected' ? state.spaceMatrixToUrl : undefined,
}; };
case 'connection_dead': case 'connection_dead':
@ -492,12 +495,28 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
// green with an empty handle, which the UI's // green with an empty handle, which the UI's
// `state.handle ? connected-as : connected` ternary tolerates. // `state.handle ? connected-as : connected` ternary tolerates.
// This avoids the `unknown` flap that the previous draft would // This avoids the `unknown` flap that the previous draft would
// produce when no handle was stashed. // produce when no handle was stashed. spaceMatrixToUrl is not
// restorable from connected_dead (the dead state never carried it),
// so the CTA stays hidden until a fresh sentinel arrives — bridge
// does NOT re-emit on reconnect, but the card returns once the user
// explicitly re-logs in.
if (state.kind === 'reconnecting' || state.kind === 'connected_dead') { if (state.kind === 'reconnecting' || state.kind === 'connected_dead') {
return { kind: 'connected', handle: state.handle ?? '' }; return { kind: 'connected', handle: state.handle ?? '' };
} }
return state; return state;
case 'space_ready':
// Vojo-patched bridge surfaced the personal Discord space — attach
// its matrix.to URL to the connected state so the «Open in Channels»
// card renders. Late-arriving sentinels from an abandoned flow drop
// here silently (e.g. a sentinel that lands during `logging_out`
// mustn't resurrect a connected state). Honour only from the
// canonical alive states.
if (state.kind === 'connected') {
return { ...state, spaceMatrixToUrl: event.matrixToUrl };
}
return state;
case 'reconnect_failed': case 'reconnect_failed':
if (state.kind !== 'reconnecting') return state; if (state.kind !== 'reconnecting') return state;
// Roll back to connected_dead carrying the previous handle. The // Roll back to connected_dead carrying the previous handle. The
@ -565,10 +584,7 @@ type HydrateAccumulator = {
terminated: boolean; terminated: boolean;
}; };
const stepHydrate = ( const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateAccumulator => {
prevAcc: HydrateAccumulator,
input: HydrateInput
): HydrateAccumulator => {
const { ev, ts } = input; const { ev, ts } = input;
// After a terminal event we normally stop — except if a fresh // After a terminal event we normally stop — except if a fresh
@ -693,9 +709,12 @@ const stepHydrate = (
case 'already_logged_in': case 'already_logged_in':
case 'unknown': case 'unknown':
case 'space_ready':
// Soft no-op for hydrate. already_logged_in is a live-flow warning // Soft no-op for hydrate. already_logged_in is a live-flow warning
// that doesn't reflect persistent state; unknown is a wording-drift // that doesn't reflect persistent state; unknown is a wording-drift
// catch-all. // catch-all; space_ready is a post-terminal sentinel — hydrate
// terminates on login_success and lets live ping reconcile, so
// the URL gets attached on the live path, not here.
return acc; return acc;
default: { default: {

View file

@ -125,6 +125,27 @@ export class WidgetApi {
); );
} }
// Ask the host to navigate to a matrix.to URL inside the cinny app
// (room or space). Same side-channel pattern as `openExternalUrl` —
// distinct from matrix-widget-api's `fromWidget` so the SDK stays
// ignorant of this Vojo extension. The host validates the URL via
// `parseMatrixToRoom` (rejecting non-room URLs, javascript:/data:, etc.)
// BEFORE routing into the react-router; sending anything that isn't a
// matrix.to/#/!roomId or matrix.to/#/#alias URL silently no-ops on the
// host side. The widget is responsible for only invoking this when it
// genuinely has a matrix.to room URL (e.g. parsed from a bridge
// sentinel).
public openMatrixToUrl(url: string): void {
window.parent.postMessage(
{
api: 'io.vojo.bot-widget',
action: 'open-matrix-to',
data: { url },
},
this.bootstrap.parentOrigin
);
}
// Always prefix outbound commands with `<commandPrefix> ` (trailing space). // Always prefix outbound commands with `<commandPrefix> ` (trailing space).
// Legacy mautrix-discord routes management-room commands through the // Legacy mautrix-discord routes management-room commands through the
// bridge.commands.Processor in mautrix/go bridge/commands; outside the // bridge.commands.Processor in mautrix/go bridge/commands; outside the

View file

@ -742,6 +742,21 @@ 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,6 +81,13 @@ 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
@ -171,14 +178,19 @@ export function MobileTabsPagerHeader({
fill="None" fill="None"
size="400" size="400"
radii="Pill" radii="Pill"
onClick={openChat} onClick={primaryAction ? primaryAction.onClick : openChat}
aria-label={t('Direct.create_chat')} aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
aria-controls={INLINE_FORM_ID} // See StreamHeader's matching IconButton: drop only
// `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={Icons.Plus} /> <Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
</IconButton> </IconButton>
<IconButton <IconButton
variant="SurfaceVariant" variant="SurfaceVariant"

View file

@ -143,8 +143,11 @@ 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 visibility:hidden), and PageNav's inner // painted invisible via `opacity: 0` — load-bearing because
// column reserves the status-bar safe-area inset via its own // `visibility: hidden` would remove the row from hit-testing and
// 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, toRem } from 'folds'; import { color, config, toRem } from 'folds';
import { import {
CHIP_GAP_PX, CHIP_GAP_PX,
CURTAIN_BREATHER_PX, CURTAIN_BREATHER_PX,
@ -9,6 +9,7 @@ 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
@ -63,13 +64,31 @@ 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`. // because the curtain's `top` floor equals `TABS_ROW_PX` on native
// (`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({
@ -89,20 +108,32 @@ 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 / ChannelCreateRow / WorkspaceFooter) stays // child (DirectSelfRow / WorkspaceFooter) stays glued to the visible
// glued to the visible viewport bottom regardless of where the // viewport bottom regardless of where the curtain's top is.
// curtain's top is.
// //
// Only the TOP corners are rounded: the bottom is meant to read as // On native, only the TOP corners are rounded: the bottom is meant
// continuous with the always-visible bottomPinned row (DirectSelfRow // to read as continuous with the always-visible bottomPinned row
// is the curtain's last flex child) — adding `borderBottomRadius` // (DirectSelfRow is the curtain's last flex child) — adding
// would crop the row's corners against the curtain's // `borderBottomRadius` would crop the row's corners against the
// `overflow: hidden`, which visually reads as «a light-blue strip // curtain's `overflow: hidden`, which visually reads as «a light-
// cuts into the row». // blue strip 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,
@ -120,6 +151,12 @@ 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
@ -182,20 +219,17 @@ 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
// applies a `transform: translateY(keyboardH)` to this element when // collapses this slot to `{ height: 0, overflow: hidden }` when the
// the on-screen keyboard rises (via `VisualViewport.height` shrink) // on-screen keyboard rises (via `VisualViewport.height` shrink) so
// so the row stays at its ORIGINAL viewport-bottom position — under // the row neither paints nor claims flex space above the keyboard.
// the keyboard, clipped by the curtain's `overflow: hidden`. Without // Without this compensation, `interactive-widget=resizes-content`
// this compensation, `interactive-widget=resizes-content` (global // (global viewport meta — load-bearing for the room composer)
// meta — load-bearing for the room composer) shrinks the layout // shrinks the layout viewport, dragging every `bottom: 0` element
// viewport, dragging every `bottom: 0` element up over the inline // up over the inline form. The DirectSelfRow ending up immediately
// form. The DirectSelfRow ending up immediately above the keyboard // above the keyboard would block the user's view of the form they're
// blocks the user's view of the form they're typing into. // 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,7 +16,12 @@ 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 { MobilePagerCurtainControls, mobilePagerCurtainAtom } from '../../state/mobilePagerHeader'; import {
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';
@ -45,21 +50,34 @@ 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,
// ChannelCreateRow, WorkspaceFooter). Hidden while a form is active // WorkspaceFooter). Hidden while a form is active so the on-screen
// so the on-screen keyboard's viewport resize doesn't push them up // keyboard's viewport resize doesn't push them up over the form
// over the form (see commit 14ed080). // (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). When provided, pin state is // which unmounts the listing pane). Pin state is stored in
// stored in `curtainPinnedByTabAtom[pinKey]`; without it, pin lives // `curtainPinnedByTabAtom[pinKey]` so it outlives any individual
// in a local useState that resets on unmount. Listing surfaces // StreamHeader instance. Each listing tab (Direct/Channels/Bots)
// wired into the mobile pager (Direct / Channels / Bots) all pass // passes its own key; the Channels landing CTA and workspace
// a key; other consumers can omit it. // listing share `"channels"` so pin survives the toggle between
pinKey?: string; // empty state and a chosen workspace.
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({ scrollRef, children, bottomPinned, pinKey }: StreamHeaderProps) { export function StreamHeader({
scrollRef,
children,
bottomPinned,
pinKey,
primaryAction,
}: StreamHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const bots = useBotPresets(); const bots = useBotPresets();
@ -129,20 +147,24 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
// 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 on // at the top of the curtain. Crisp 1:1 finger ↔ curtain. From
// every transition (pin, unpin, peek, close-peek, form-close). // closed the gesture is a free-range drag spanning pin↔closed↔
// 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. // scroll. Only rendered on native (`isNativePlatform()`).
// //
// * `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).
// bottom-pinned row). Rubber-banded (0.65) on every transition // Rubber-banded (0.65) for all transitions, so the body drag
// so the body drag reads as physically «heavier» than the // reads as physically «heavier» than the handle's crisp pull.
// handle's crisp pull. Engages ONLY when the chat list has no // Engages only when the chat list has no scrollable content;
// scrollable content — long lists keep native vertical scroll; // additionally bails on touches that start inside the bottom-
// short / empty lists let the user pull the curtain «from // pinned slot (DirectSelfRow / WorkspaceFooter have their own
// anywhere». // drag-to-open bottom sheets) and on touches that start while
// 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;
@ -154,6 +176,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
// 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,
@ -171,6 +194,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
useCurtainBodyGesture({ useCurtainBodyGesture({
curtainRef, curtainRef,
handleRef, handleRef,
bottomPinnedRef,
scrollRef, scrollRef,
snap: curtain.snap, snap: curtain.snap,
pinned: curtain.pinned, pinned: curtain.pinned,
@ -196,8 +220,9 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
openChat, openChat,
closeForm: close, closeForm: close,
isFormActive: isActive, isFormActive: isActive,
primaryAction: primaryAction ?? null,
}), }),
[openSearch, openChat, close, isActive] [openSearch, openChat, close, isActive, primaryAction]
); );
const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom); const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom);
@ -223,9 +248,20 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
// 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) + curtain.liveDragPx; : snapTopPx(curtain.snap, curtain.formHeightPx) + platformOffset + 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
@ -262,6 +298,15 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
// 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;
@ -284,7 +329,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
}, []); }, []);
return ( return (
<div className={css.stage}> <div className={css.stage} data-platform={isNativePlatform() ? undefined : 'web'}>
<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
@ -353,13 +398,19 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
fill="None" fill="None"
size="400" size="400"
radii="Pill" radii="Pill"
onClick={openChat} onClick={primaryAction ? primaryAction.onClick : openChat}
aria-label={t('Direct.create_chat')} aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
aria-controls={INLINE_FORM_ID} // `aria-controls` points at the curtain-mounted form
// 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={Icons.Plus} /> <Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
</IconButton> </IconButton>
<IconButton <IconButton
variant="SurfaceVariant" variant="SurfaceVariant"
@ -422,9 +473,9 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
</div> </div>
<div className={css.chipRow}> <div className={css.chipRow}>
<Chip <Chip
iconSrc={Icons.Plus} iconSrc={primaryAction ? primaryAction.iconSrc : Icons.Plus}
label={t('Direct.create_chat')} label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
onClick={openChat} onClick={primaryAction ? primaryAction.onClick : openChat}
hidden={curtain.snap !== 'peek'} hidden={curtain.snap !== 'peek'}
/> />
</div> </div>
@ -448,14 +499,17 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
}} }}
onTransitionEnd={onCurtainTransitionEnd} onTransitionEnd={onCurtainTransitionEnd}
> >
{/* Drag handle (native-only behaviour, but rendered on all {/* Drag handle native-only. On web (desktop browsers,
platforms so the layout stays identical the gesture hook Electron) the curtain has no interactive snap states, so
short-circuits off-native). Hosts the entire curtain the handle would be pure decoration with no behaviour
gesture surface pin, unpin, peek, close-peek and behind it; rendering it conditionally drops the 32 px
form-close all bind here, leaving the chat list to native grabber strip on those surfaces and lets the chat list
scroll. Stays mounted across snap transitions so the sit flush against the curtain's rounded top.
gesture surface is always reachable when there is one to
make. On native the handle hosts the authoritative curtain
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
@ -463,18 +517,20 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
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. */}
<div {isNativePlatform() && (
ref={handleRef} <div
className={css.handle} ref={handleRef}
data-dragging={handleVisual.dragging || undefined} className={css.handle}
data-at-commit={handleVisual.atCommit || undefined} data-dragging={handleVisual.dragging || undefined}
aria-hidden data-at-commit={handleVisual.atCommit || undefined}
> aria-hidden
<div className={css.handleBar} /> >
</div> <div className={css.handleBar} />
</div>
)}
{children} {children}
{/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is {/* `bottomPinned` (DirectSelfRow, WorkspaceFooter) is kept
kept mounted across snaps so the curtain reads as a self- 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
@ -482,6 +538,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
`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,6 +34,26 @@
// 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;
@ -97,30 +117,46 @@ 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.
// //
// With 1:1 finger ↔ curtain tracking (no rubber-band on pin / unpin — // On the handle the up direction is 1:1 with no upper clamp (the
// see `useCurtainHandleGesture`), the committing finger pull is // «closed-free» transition spans the full pin↔closed↔peek range in
// 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». The anti-accidental // the curtain across the full tabs-row height». On the body the same
// gate is provided by the dedicated handle hit-zone (intentional // displacement is reached with a longer finger pull because the body
// surface) — the chat list under the curtain is left to native // path is rubber-banded (×0.65).
// scroll and never engages a pin path, so there's no scroll-vs-pin //
// ambiguity to disambiguate. // Unpin's clamp is asymmetric — `pinned-free` lower-bounds the live
// 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. The handle is the // Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the
// AUTHORITATIVE gesture surface — pin, unpin, peek, close-peek and // handle is rendered only when `isNativePlatform()` is true (see
// form-close all bind here with 1:1 finger ↔ curtain tracking, no // StreamHeader.tsx) — on web (desktop / Electron) the curtain has
// matter whether the chat list inside the curtain is scrollable. See // no interactive snap states, so the handle would be pure
// `useCurtainHandleGesture` for the full state machine. // decoration and is omitted entirely.
//
// 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
// (everything below the handle) handles drag from anywhere on the // handles drag from anywhere on the card, but only when the inner
// card, but only when the inner chat list has no scrollable content // chat list has no scrollable content AND the curtain isn't pinned
// — its dynamics are rubber-banded so the body drag reads as // (unpin is handle-only). Its dynamics are rubber-banded so the
// physically «heavier» than the handle's crisp pull. // body drag reads as physically «heavier» than the handle's crisp
// 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,7 +10,11 @@ import {
RUBBER_BAND, RUBBER_BAND,
} from './geometry'; } from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState'; import { CurtainSnap, isFormSnap } from './useCurtainState';
import { CurtainTransition, resolveCurtainTransition } from './useCurtainHandleGesture'; import {
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
@ -25,6 +29,15 @@ 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
@ -45,9 +58,10 @@ 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). pin/unpin flips // Snap commit (peek / close-peek / form-close). Narrowed to the two
// non-form destinations the hook ever reaches. pin/unpin flips
// `pinned` instead. // `pinned` instead.
commit: (next: CurtainSnap) => void; commit: (next: 'peek' | 'closed') => 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;
@ -86,9 +100,17 @@ 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,
@ -134,11 +156,20 @@ 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
@ -206,24 +237,26 @@ 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 'pin': case 'closed-free':
// Rubber-banded up, clamped at the safe-top edge. // Rubber-banded free-range drag spanning pin↔closed↔peek
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta * RUBBER_BAND)); // in one motion. NO clamps either side — the curtain
atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD; // follows the finger off-screen upward and continuously
break; // into peek territory downward. Direction-aware atCommit
case 'unpin': // shows the right commit feedback for whichever side the
// Rubber-banded down, clamped at the closed-resting edge. // user is leaning into. Mirrors the handle's `closed-free`
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta * RUBBER_BAND)); // but with 0.65× displacement so the body drag reads as
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD; // physically «heavier».
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 = lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; atCommit =
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;
@ -233,8 +266,21 @@ 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;
default: case 'pinned-free':
// 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);
@ -249,22 +295,13 @@ export function useCurtainBodyGesture({
return; return;
} }
switch (transition) { switch (transition) {
case 'pin': case 'closed-free':
// 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 { } else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
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);
@ -284,9 +321,18 @@ export function useCurtainBodyGesture({
setLiveDrag(0, false); setLiveDrag(0, false);
} }
break; break;
default: case 'pinned-free':
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;
@ -317,10 +363,18 @@ 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, scrollRef, setLiveDrag, disabled]); }, [curtainRef, handleRef, bottomPinnedRef, scrollRef, setLiveDrag, disabled]);
} }

View file

@ -33,9 +33,10 @@ 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). Also resets // (the pin / unpin paths flip `pinned` instead). Narrowed to the
// 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: CurtainSnap) => void; commit: (next: 'peek' | 'closed') => 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.
@ -54,7 +55,38 @@ 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
@ -63,10 +95,17 @@ export type CurtainTransition = 'pin' | 'unpin' | 'peek' | 'close-peek' | 'form-
// 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
// * pinned + DOWN → unpin. // on commit — no destination above pinned).
// * closed + UP → pin. // * pinned + DOWN → pinned-free (HANDLE-only contract — the body
// * closed + DOWN → peek. // hook bails entirely while pinned so unpin /
// 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.
@ -76,8 +115,8 @@ export function resolveCurtainTransition(
pinned: boolean, pinned: boolean,
direction: 'up' | 'down' direction: 'up' | 'down'
): CurtainTransition | null { ): CurtainTransition | null {
if (pinned) return direction === 'down' ? 'unpin' : null; if (pinned) return direction === 'down' ? 'pinned-free' : null;
if (snap === 'closed') return direction === 'up' ? 'pin' : 'peek'; if (snap === 'closed') return 'closed-free';
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;
@ -88,30 +127,53 @@ 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 (pin, unpin, peek, close-peek, form-close) with crisp // transition (closed-free, pinned-free, close-peek, form-close)
// 1:1 finger ↔ curtain tracking regardless of whether the chat list // with crisp 1:1 finger ↔ curtain tracking regardless of whether
// inside the curtain is scrollable. The curtain BODY has a parallel // the chat list inside the curtain is scrollable. The curtain BODY
// gesture (`useCurtainBodyGesture`) with rubber-banded dynamics that // has a parallel gesture (`useCurtainBodyGesture`) with rubber-
// only engages when the body's chat list has no scrollable content — // banded dynamics that only engages when the body's chat list has
// so the user can pull the curtain «from anywhere» on empty / short // no scrollable content — so the user can pull the curtain «from
// lists but a real list-scroll is never hijacked under their finger. // anywhere» on empty / short lists but a real list-scroll is never
// History note: an earlier `useCurtainGesture` bound the peek / // hijacked under their finger. The body is also fully inert while
// form-close paths to the list scroll viewport directly. That coupling // pinned, so unpin (and unpin → peek overshoot) stays a deliberate
// produced repeating «drag-up at scrollTop=0 hijacks for pin» / «drag- // handle pull.
// down at scrollTop=0 hijacks for peek» bugs and was removed when
// pin / unpin moved here.
// //
// All five transitions track the finger 1:1, clamped at the relevant // Design rationale: gestures used to bind to the chat list's scroll
// snap edge so jitter past the destination doesn't visually overshoot: // viewport directly, which produced repeating «drag-at-scrollTop=0
// * pin / unpin — clamp ±PIN_TRAVEL_PX, commit at // hijacks for pin/peek» bugs. Moving every transition onto a
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX // dedicated handle (plus an opt-in body surface that bails on
// («дотянул прям до самого верха»). // 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 push // * form-close — capped at 0 so a downward jitter can't
// the curtain below its form-snap position. // push the curtain below its form-snap top,
// Commit at ACTIVE_CLOSE_THRESHOLD_PX // NO upper clamp. Commit at
// (absolute distance, not a fraction). // ACTIVE_CLOSE_THRESHOLD_PX (absolute).
// //
// 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 +
@ -223,36 +285,47 @@ export function useCurtainHandleGesture({
engaged = true; engaged = true;
e.preventDefault(); e.preventDefault();
// Clamp / rubber-band the raw finger delta into the live curtain // Clamp the raw finger delta into the live curtain displacement
// displacement (`lastDelta`). Stored separately because the // (`lastDelta`). Stored separately because the commit math on
// commit math on release needs the same value the curtain was // release needs the same value the curtain was visually showing.
// visually showing.
let atCommit = false; let atCommit = false;
switch (transition) { switch (transition) {
case 'pin': case 'closed-free':
// 1:1 up, clamped so the curtain doesn't enter the // Single free-range drag spanning pin↔closed↔peek. 1:1 with
// system-tray safe-top zone. // NO clamps either side: the curtain follows the finger off-
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta)); // screen upward (past safe-top) and continuously into peek
atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD; // territory downward in the same gesture. The release decides
// 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 'unpin': case 'pinned-free':
// 1:1 down, clamped so the curtain doesn't descend past its // 1:1 down from pinned. Clamped at 0 below (a downward
// `closed` resting top during the drag. // jitter past the start mustn't push the curtain into
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta)); // safe-top — there's no destination above pinned), NO
// 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. Symmetric clamp to peek above. // 1:1 up; delta is negative. Lower-capped at 0 (a downward
lastDelta = Math.min(0, Math.max(-PEEK_TRAVEL_PX, delta)); // jitter shouldn't push past the peek snap), NO upper clamp
// — 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':
@ -262,10 +335,19 @@ 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;
default: case null:
// Unreachable — transition is non-null past the dead-zone // Unreachable: `engaged` is set only after `transition` is
// resolution above and is never cleared mid-gesture. // resolved non-null in the dead-zone block above; reaching
// 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);
@ -285,27 +367,42 @@ 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 'pin': case 'closed-free':
// 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 { } else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
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);
} }
break; break;
case 'pinned-free':
// Two-tier commit: peek wins if the finger crossed the
// 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);
commitRef.current('peek');
} else if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(false);
} else {
setLiveDrag(0, false);
}
break;
case 'close-peek': case 'close-peek':
if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) { if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('closed'); commitRef.current('closed');
@ -320,9 +417,19 @@ export function useCurtainHandleGesture({
setLiveDrag(0, false); setLiveDrag(0, false);
} }
break; break;
default: case null:
// 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;
@ -354,6 +461,16 @@ 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,9 +35,7 @@ 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). If no `pinKey` is provided, the pin lives in a // independent).
// 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-
@ -69,7 +67,10 @@ 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.
commit: (next: CurtainSnap) => void; // Narrowed to the two non-form destinations the gesture hooks ever
// 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;
@ -101,35 +102,30 @@ 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);
// Pin storage split: atom-backed when `pinKey` is supplied (survives // Per-tab pin lives in `curtainPinnedByTabAtom` so the lock survives
// listing-pane remount on Room navigate-back), local useState // the route-driven listing-pane unmount that happens when the user
// fallback when no key is supplied (web/non-listing mounts where // taps into a Room and back. The atom outlives any individual
// pinning isn't expected). // StreamHeader instance.
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom); const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
const [pinnedLocal, setPinnedLocal] = useState(false); const pinned = !!pinnedMap[pinKey];
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) => {
if (pinKey) { setPinnedMap((prev) => {
setPinnedMap((prev) => { // Compare-and-skip so we don't allocate a fresh object (and
// Compare-and-skip so we don't allocate a fresh object (and // re-render every other subscriber of the atom) when nothing
// re-render every other subscriber of the atom) when nothing // actually changes.
// actually changes. if (!!prev[pinKey] === next) return prev;
if (!!prev[pinKey] === next) return prev; return { ...prev, [pinKey]: next };
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);
@ -145,12 +141,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
// static pager header's icons (the only call site of open()) are // visible openers (static pager header icons, in-pane chips on
// covered by the curtain when pinned, so the user can't trigger // non-pager surfaces) are all covered by the curtain when pinned,
// this directly — but a future programmatic open() or a per-pane // so the user can't trigger this directly — but a future
// tabsRow that escapes the pager-mode visibility:hidden gate // programmatic open() would otherwise mount the form behind the
// would otherwise mount the form behind the still-pinned curtain // still-pinned curtain at curtainTop=0 and present an invisible
// at curtainTop=0 and the user would see an invisible form. // form.
setPinned(false); setPinned(false);
}, },
[setPinned] [setPinned]
@ -162,17 +158,14 @@ export function useCurtainState(pinKey?: string): CurtainState {
setIsDragging(false); setIsDragging(false);
}, []); }, []);
const commit = useCallback((next: CurtainSnap) => { const commit = useCallback((next: 'peek' | 'closed') => {
setSnap(next); setSnap(next);
setLiveDragPx(0); setLiveDragPx(0);
setIsDragging(false); setIsDragging(false);
if (isFormSnap(next)) { // `activeForm` is intentionally NOT cleared here — it stays set
setActiveForm(next === 'form-search' ? 'search' : 'chat'); // so the closing transition has form content beneath the curtain
} // as it slides up. `acknowledgeClosed` clears it once the snap
// Note: when committing to a non-form snap (peek*/closed) we do // settles at `closed`.
// 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,6 +18,7 @@ import {
} from 'matrix-widget-api'; } from 'matrix-widget-api';
import { Theme } from '../../hooks/useTheme'; import { Theme } from '../../hooks/useTheme';
import { openExternalUrl } from '../../utils/capacitor'; import { openExternalUrl } from '../../utils/capacitor';
import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to';
import type { BotPreset } from './catalog'; import type { BotPreset } from './catalog';
import { import {
BotWidgetDriver, BotWidgetDriver,
@ -34,6 +35,14 @@ export type BotWidgetEmbedOptions = {
language: string; language: string;
onError: (error: Error) => void; onError: (error: Error) => void;
onReady?: () => void; onReady?: () => void;
// Optional generic «navigate cinny to a matrix.to room/alias» callback.
// Plumbed from `BotWidgetMount` where react-router's `useNavigate` is
// available. The embed validates the URL via `parseMatrixToRoom` BEFORE
// calling — handler receives an already-parsed `{roomIdOrAlias, viaServers}`
// and is free to assume the inputs are well-formed Matrix references. Not
// bot-aware: any widget that delivers a matrix.to URL via the side-channel
// (`open-matrix-to` action) reaches the same handler.
onOpenMatrixToRoom?: (target: MatrixToRoom) => void;
}; };
const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`; const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
@ -214,22 +223,30 @@ export class BotWidgetEmbed {
this.feedStateUpdate(ev); this.feedStateUpdate(ev);
}; };
// Side-channel postMessage handler for the widget's `openExternalUrl` // Side-channel postMessage handler for the widget's Vojo-extension
// call. Distinct from matrix-widget-api's `fromWidget` channel // actions. Distinct from matrix-widget-api's `fromWidget` channel
// (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it // (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant // doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
// of our extension and avoids the «unknown action» reply path. // of our extension and avoids the «unknown action» reply path.
// //
// Why this exists: the host's global `setupExternalLinkHandler` // Two actions today:
// (utils/capacitor.ts) intercepts `<a target="_blank">` clicks at
// the host document level and routes them via Capacitor's Browser
// plugin. But cross-origin iframes don't bubble click events into
// the parent document, so widget-side links are invisible to it —
// on Capacitor's Android WebView those clicks silently disappear.
// The widget posts this message; we validate the URL and forward
// to the same `openExternalUrl` helper the host uses elsewhere.
// //
// Security gates (defence in depth): // * `open-external-url` — forwards an https:// URL to the host's
// `openExternalUrl` (utils/capacitor.ts), which routes through
// Capacitor's Browser plugin on native and `window.open` on web.
// Exists because cross-origin iframes don't bubble click events
// to the host document, so the global `setupExternalLinkHandler`
// never sees widget-side `<a target="_blank">` clicks — on
// Capacitor's Android WebView those would silently disappear.
//
// * `open-matrix-to` — generic «navigate cinny to a matrix.to room
// or alias». Validates the URL through the same `parseMatrixToRoom`
// cinny uses for in-app mention rendering, then hands the parsed
// `MatrixToRoom` to `options.onOpenMatrixToRoom` (composed by
// BotWidgetMount with `useNavigate` + `getChannelsSpacePath`). The
// widget never sees a route — it only knows matrix.to URLs.
//
// Security gates (defence in depth, apply to BOTH actions):
// 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this // 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this
// check, a compromised widget bundle could `window.location.href // check, a compromised widget bundle could `window.location.href
// = 'https://attacker.example/'` — the browser keeps the same // = 'https://attacker.example/'` — the browser keeps the same
@ -242,11 +259,17 @@ export class BotWidgetEmbed {
// iframe of the SAME origin — e.g. an ad embed loaded into a // iframe of the SAME origin — e.g. an ad embed loaded into a
// sibling frame on the same origin in a future deployment — // sibling frame on the same origin in a future deployment —
// could otherwise pass the origin check). // could otherwise pass the origin check).
// 3. Only https URLs are honoured. We tightened from http+https to //
// https-only because no shipped widget content links over plain // Per-action URL validation (NOT shared, but each branch enforces):
// http; rejecting http closes a cleartext-redirect vector via // * `open-external-url` — requires `https:` protocol, rejecting plain
// Capacitor `Browser.open` on Android. // http, javascript:, data:, file:, etc. We tightened from http+https
// 4. javascript:, data:, file:, etc. are implicitly rejected by (3). // to https-only because no shipped widget content links over plain
// http; rejecting http closes a cleartext-redirect vector via
// Capacitor `Browser.open` on Android.
// * `open-matrix-to` — requires the URL to parse as a matrix.to room
// or alias via `parseMatrixToRoom`. Anything else (matrix.to user
// links, event links, arbitrary https URLs, javascript:/data:/file:
// pseudo-schemes) returns undefined and silently no-ops.
private readonly onWidgetMessage = (ev: MessageEvent) => { private readonly onWidgetMessage = (ev: MessageEvent) => {
if (ev.origin !== this.widgetOrigin) return; if (ev.origin !== this.widgetOrigin) return;
if (ev.source !== this.iframe.contentWindow) return; if (ev.source !== this.iframe.contentWindow) return;
@ -255,18 +278,38 @@ 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 {
const parsed = new URL(url); if (msg.action === 'open-external-url') {
if (parsed.protocol !== 'https:') return; try {
} catch { const parsed = new URL(url);
if (parsed.protocol !== 'https:') return;
} catch {
return;
}
openExternalUrl(url).catch(() => {
/* fire-and-forget: log handled inside openExternalUrl */
});
return; return;
} }
openExternalUrl(url).catch(() => {
/* fire-and-forget: log handled inside openExternalUrl */ if (msg.action === 'open-matrix-to') {
}); // Generic «navigate cinny to a matrix.to room/alias». Not bot-aware —
// the widget hands over a matrix.to URL it obtained however (parsed
// from a bridge sentinel, scraped from chat, whatever), and we
// validate via the same `parseMatrixToRoom` cinny uses for in-app
// mention rendering (plugins/react-custom-html-parser.tsx). Only the
// matrix.to/#/!roomId and matrix.to/#/#alias shapes pass — user
// links, event links, non-matrix.to URLs, javascript:/data:/etc. all
// return undefined and silently no-op here. The host-side router
// hop (`onOpenMatrixToRoom`) is the optional caller — embedded code
// paths that don't provide a callback (e.g. future test harness) get
// a silent drop, not a crash.
const parsed = parseMatrixToRoom(url);
if (!parsed) return;
this.options.onOpenMatrixToRoom?.(parsed);
}
}; };
public constructor(private readonly options: BotWidgetEmbedOptions) { public constructor(private readonly options: BotWidgetEmbedOptions) {

View file

@ -1,8 +1,16 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Room, SyncState } from 'matrix-js-sdk'; import { Room, SyncState } from 'matrix-js-sdk';
import type { BotPreset } from './catalog'; import type { BotPreset } from './catalog';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSyncState } from '../../hooks/useSyncState'; import { useSyncState } from '../../hooks/useSyncState';
import {
getCanonicalAliasOrRoomId,
getCanonicalAliasRoomId,
isRoomAlias,
} from '../../utils/matrix';
import { getChannelsSpacePath } from '../../pages/pathUtils';
import type { MatrixToRoom } from '../../plugins/matrix-to';
import { useBotWidgetEmbed } from './useBotWidgetEmbed'; import { useBotWidgetEmbed } from './useBotWidgetEmbed';
import * as css from './BotWidgetMount.css'; import * as css from './BotWidgetMount.css';
@ -34,15 +42,46 @@ type BotWidgetMountProps = {
export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { ready } = useBotWidgetEmbed({ containerRef, preset, room, onError }); const navigate = useNavigate();
const mx = useMatrixClient();
// Generic «navigate cinny to a matrix.to room/alias». Bot-agnostic: any
// widget that posts `{action: 'open-matrix-to', data: {url}}` on the
// `io.vojo.bot-widget` side-channel reaches this. The embed has already
// validated the URL via `parseMatrixToRoom` so `target` is well-formed.
// For an alias we resolve to the canonical room id first — the channels
// path expects an id-or-alias either way, but joined-room lookup needs
// the id form for the via-server hint to be effective. `viaServers` are
// currently dropped (the channels view doesn't propagate them); add a
// dedicated «join-via» path if a future widget needs to surface a room
// the user hasn't joined yet.
const handleOpenMatrixToRoom = useCallback(
(target: MatrixToRoom) => {
const { roomIdOrAlias } = target;
const idOrAlias = isRoomAlias(roomIdOrAlias)
? getCanonicalAliasRoomId(mx, roomIdOrAlias) ?? roomIdOrAlias
: roomIdOrAlias;
const canonical = getCanonicalAliasOrRoomId(mx, idOrAlias);
navigate(getChannelsSpacePath(canonical));
},
[mx, navigate]
);
const { ready } = useBotWidgetEmbed({
containerRef,
preset,
room,
onError,
onOpenMatrixToRoom: handleOpenMatrixToRoom,
});
// Track Matrix sync state so the bot loading bar yields to the global // Track Matrix sync state so the bot loading bar yields to the global
// SyncIndicator when the connection is unhealthy. Without this, on a // SyncIndicator when the connection is unhealthy. Without this, on a
// dropped network the user would see TWO sweeping bars at once — the // dropped network the user would see TWO sweeping bars at once — the
// bot bar at top stuck in «still loading» plus the SyncIndicator at // bot bar at top stuck in «still loading» plus the SyncIndicator at
// bottom in transient/error state. The bottom bar is the canonical // bottom in transient/error state. The bottom bar is the canonical
// connection-state surface; the top one defers. // connection-state surface; the top one defers. Reuses `mx` from the
const mx = useMatrixClient(); // navigate-callback block above — single hook call per render.
const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState()); const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState());
useSyncState( useSyncState(
mx, mx,
@ -106,10 +145,7 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
// SyncIndicator can take over without two bars overlapping. // SyncIndicator can take over without two bars overlapping.
// Reduced-motion: animation is off (no iterations ever land), so // Reduced-motion: animation is off (no iterations ever land), so
// parking a static stripe for ~2s isn't graceful, just stuck. // parking a static stripe for ~2s isn't graceful, just stuck.
if ( if (hideReason === 'sync' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
hideReason === 'sync' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
setVisible(false); setVisible(false);
setPendingHide(false); setPendingHide(false);
return undefined; return undefined;

View file

@ -3,6 +3,7 @@ import { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { Theme, useTheme } from '../../hooks/useTheme'; import { Theme, useTheme } from '../../hooks/useTheme';
import type { MatrixToRoom } from '../../plugins/matrix-to';
import type { BotPreset } from './catalog'; import type { BotPreset } from './catalog';
import { BotWidgetEmbed } from './BotWidgetEmbed'; import { BotWidgetEmbed } from './BotWidgetEmbed';
@ -11,6 +12,9 @@ type UseBotWidgetEmbedOptions = {
preset: BotPreset; preset: BotPreset;
room: Room; room: Room;
onError: () => void; onError: () => void;
// Forwarded into the embed. Plumbed from `BotWidgetMount` where the
// react-router context is available — the hook stays unaware of routing.
onOpenMatrixToRoom?: (target: MatrixToRoom) => void;
}; };
type UseBotWidgetEmbedResult = { type UseBotWidgetEmbedResult = {
@ -30,6 +34,7 @@ export const useBotWidgetEmbed = ({
preset, preset,
room, room,
onError, onError,
onOpenMatrixToRoom,
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => { }: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
@ -43,6 +48,12 @@ export const useBotWidgetEmbed = ({
themeRef.current = theme; themeRef.current = theme;
const languageRef = useRef<string>(i18n.language); const languageRef = useRef<string>(i18n.language);
languageRef.current = i18n.language; languageRef.current = i18n.language;
// Same indirection for `onOpenMatrixToRoom`: the callback identity
// typically changes per render (closes over `navigate`/`mx`), and we do
// NOT want that to remount the embed. The ref carries the latest fn; the
// embed only sees a stable shim that re-reads it.
const onOpenMatrixToRoomRef = useRef(onOpenMatrixToRoom);
onOpenMatrixToRoomRef.current = onOpenMatrixToRoom;
// Depend on primitive identity for the embed lifecycle — using `preset` // Depend on primitive identity for the embed lifecycle — using `preset`
// directly would remount the iframe (and re-handshake with the widget) // directly would remount the iframe (and re-handshake with the widget)
@ -72,6 +83,9 @@ export const useBotWidgetEmbed = ({
language: languageRef.current, language: languageRef.current,
onError, onError,
onReady: () => setReady(true), onReady: () => setReady(true),
// Indirection so the embed lifecycle doesn't reset when the
// navigate-callback closes over a new render's `mx`/`navigate`.
onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target),
}); });
embedRef.current = embed; embedRef.current = embed;
} catch (error) { } catch (error) {

View file

@ -22,7 +22,11 @@ export function BotStatePage({ title, description, icon, children }: BotStatePag
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
return ( return (
<Page> // Safe-top on the wrapper, not on `PageHeader`: the recipe is a
// 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,9 +21,12 @@ function BotRow({ preset }: { preset: BotPreset }) {
export function Bots() { export function Bots() {
const bots = useBotPresets(); const bots = useBotPresets();
// `scrollRef` is passed to the header so the touch gesture (native // `scrollRef` is forwarded so the curtain body gesture can check
// only) can recognise list scrollTop=0 and engage the curtain peek. // whether the list is scrollable and bail to native scroll on long
// Icons + click flows work on every platform regardless. // lists. Short / empty lists let the curtain body itself drive the
// 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

@ -1,57 +0,0 @@
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,14 +1,20 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useMemo, 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-
@ -25,19 +31,34 @@ import { ACTIVE_SPACE_KEY } from './useActiveSpace';
// 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"> <StreamHeader scrollRef={scrollRef} pinKey="channels" primaryAction={primaryAction}>
<ChannelsLanding /> <ChannelsLanding />
</StreamHeader> </StreamHeader>
</PageNav> </PageNav>
@ -54,21 +75,33 @@ 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) reads the // the same workspace. `useActiveSpace` (in ChannelsLanding and
// value but never writes it because the index route has no // MobileTabsPager) subscribes to the same atom, so this write
// :spaceIdOrAlias param — the write happens here. // immediately invalidates their cached reads — without that, the pager
// would serve a stale `destinationFor('channels')` after a switcher
// pick + tab swap on native.
useEffect(() => { useEffect(() => {
try { setActiveSpace(space.roomId);
localStorage.setItem(ACTIVE_SPACE_KEY, space.roomId); }, [setActiveSpace, space.roomId]);
} catch {
/* private mode / quota — non-fatal */ // Plus on the tabs row creates a channel inside the active workspace.
} // Single entry point for «create» on the Channels tab.
}, [space.roomId]); const primaryAction = useMemo<StreamHeaderPrimaryAction>(
() => ({
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'}>
@ -76,12 +109,8 @@ export function Channels() {
<StreamHeader <StreamHeader
scrollRef={scrollRef} scrollRef={scrollRef}
pinKey="channels" pinKey="channels"
bottomPinned={ primaryAction={primaryAction}
<> 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 → ChannelCreateRow → // Wrapped children (StreamHeader → ChannelsList → WorkspaceFooter).
// WorkspaceFooter). Stays put — the bottom is carved away by an animated // 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,11 +70,13 @@ 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). The active community // two text lines (name + member-count subtitle). Variant is
// is signalled solely via `aria-selected` — Folds NavItem paints // `SurfaceVariant` so the inactive row bg (`SurfaceVariant.Container`
// `ContainerActive` on `&[aria-selected=true]`, which is enough on // = #181a20) matches the sheet silhouette and blends into it — same
// this surface tier; an explicit trailing label was tried and // treatment as `CreateCommunityRow` above. The active community is
// dropped per product call. // signalled solely via `aria-selected` → `ContainerActive` (#2a2d36),
// 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
@ -86,7 +88,7 @@ function SpaceRow({ space, isActive, onPick }: SpaceRowProps) {
return ( return (
<NavItem <NavItem
variant="Background" variant="SurfaceVariant"
radii="400" radii="400"
aria-selected={isActive} aria-selected={isActive}
style={{ minHeight: ROW_MIN_HEIGHT }} style={{ minHeight: ROW_MIN_HEIGHT }}

View file

@ -1,18 +1,10 @@
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);
@ -23,16 +15,22 @@ 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. localStorage['vojo.activeSpaceId'] (if joined-orphan) // 2. `activeChannelsSpaceAtom` (= localStorage, reactive) (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 to localStorage) lives in `Channels.tsx`, where the inner // (writing the atom) lives in `Channels.tsx`, where the inner route
// route already has the resolved space context — at the index `/channels/` // 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;
@ -42,10 +40,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(
const stored = readPersisted(); () => (persisted && orphanSet.has(persisted) ? persisted : undefined),
return stored && orphanSet.has(stored) ? stored : undefined; [persisted, orphanSet]
}, [orphanSet]); );
return urlSpaceId ?? persistedSpaceId ?? orphanSpaceIds[0]; return urlSpaceId ?? persistedSpaceId ?? orphanSpaceIds[0];
}; };

View file

@ -41,20 +41,43 @@ export type MatrixToRoomEvent = MatrixToRoom & {
const MATRIX_TO = /^https?:\/\/matrix\.to\S*$/; const MATRIX_TO = /^https?:\/\/matrix\.to\S*$/;
export const testMatrixTo = (href: string): boolean => MATRIX_TO.test(href); export const testMatrixTo = (href: string): boolean => MATRIX_TO.test(href);
// Matrix room IDs start with `!` (and aliases with `#`) — characters that
// some URL builders percent-encode in path segments. Go's `id.MatrixURI`
// builder (mautrix-go id/matrixuri.go) uses `url.PathEscape`, which emits
// `%21` for `!` — so every matrix.to URL produced by a mautrix bridge
// arrives here as `https://matrix.to/#/%21abc:server`. Our regexes below
// match literal `!`/`#` only, so without a decode pass every bridge-
// generated permalink would silently fail to parse — both the in-chat
// linkifier (`plugins/react-custom-html-parser.tsx`) and the widget
// «open-matrix-to» action would drop the URL on the floor.
//
// Element Web does the same `decodeURIComponent` step before parsing in
// `apps/web/src/utils/permalinks/Permalinks.ts::parsePermalink`; we
// mirror that contract here. `decodeURIComponent` throws synchronously on
// malformed `%XX` sequences (e.g. lone `%`), so wrap it; a malformed URL
// is dropped the same way as a non-matching one (undefined).
const tryDecodeHref = (href: string): string => {
try {
return decodeURIComponent(href);
} catch {
return href;
}
};
const MATRIX_TO_USER = /^https?:\/\/matrix\.to\/#\/(@[^:\s]+:[^?/\s]+)\/?$/; const MATRIX_TO_USER = /^https?:\/\/matrix\.to\/#\/(@[^:\s]+:[^?/\s]+)\/?$/;
const MATRIX_TO_ROOM = /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/?(\?[\S]*)?$/; const MATRIX_TO_ROOM = /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/?(\?[\S]*)?$/;
const MATRIX_TO_ROOM_EVENT = const MATRIX_TO_ROOM_EVENT =
/^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/(\$[^?/\s]+)\/?(\?[\S]*)?$/; /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/(\$[^?/\s]+)\/?(\?[\S]*)?$/;
export const parseMatrixToUser = (href: string): string | undefined => { export const parseMatrixToUser = (href: string): string | undefined => {
const match = href.match(MATRIX_TO_USER); const match = tryDecodeHref(href).match(MATRIX_TO_USER);
if (!match) return undefined; if (!match) return undefined;
const userId = match[1]; const userId = match[1];
return userId; return userId;
}; };
export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => { export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => {
const match = href.match(MATRIX_TO_ROOM); const match = tryDecodeHref(href).match(MATRIX_TO_ROOM);
if (!match) return undefined; if (!match) return undefined;
const roomIdOrAlias = match[1]; const roomIdOrAlias = match[1];
@ -68,7 +91,7 @@ export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => {
}; };
export const parseMatrixToRoomEvent = (href: string): MatrixToRoomEvent | undefined => { export const parseMatrixToRoomEvent = (href: string): MatrixToRoomEvent | undefined => {
const match = href.match(MATRIX_TO_ROOM_EVENT); const match = tryDecodeHref(href).match(MATRIX_TO_ROOM_EVENT);
if (!match) return undefined; if (!match) return undefined;
const roomIdOrAlias = match[1]; const roomIdOrAlias = match[1];

View file

@ -0,0 +1,37 @@
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,4 +1,17 @@
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 +
@ -17,6 +30,10 @@ 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);