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

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

View file

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

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

View file

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

View file

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

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).
// Legacy mautrix-discord routes management-room commands through the
// bridge.commands.Processor in mautrix/go bridge/commands; outside the

View file

@ -742,6 +742,21 @@ body {
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 +
* chevron» structure but trims padding so a single login/logout card
* doesn't dominate a phone-height viewport. */

View file

@ -81,6 +81,13 @@ export function MobileTabsPagerHeader({
const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]);
const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]);
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
// stays put; the curtain physically rises ABOVE it via z-stack — see
@ -171,14 +178,19 @@ export function MobileTabsPagerHeader({
fill="None"
size="400"
radii="Pill"
onClick={openChat}
aria-label={t('Direct.create_chat')}
aria-controls={INLINE_FORM_ID}
onClick={primaryAction ? primaryAction.onClick : openChat}
aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
// 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-haspopup="dialog"
disabled={iconsDisabled}
>
<Icon size="100" src={Icons.Plus} />
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
</IconButton>
<IconButton
variant="SurfaceVariant"

View file

@ -143,8 +143,11 @@ export const strip = style({
//
// No paddingTop here: the per-pane StreamHeader still renders its
// own tabs row (kept for the curtain's TABS_ROW_PX snap math, just
// painted invisible via visibility:hidden), and PageNav's inner
// column reserves the status-bar safe-area inset via its own
// painted invisible via `opacity: 0` — load-bearing because
// `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
// the pager root simply paints OVER the same screen zone, so the
// underlying geometry stays identical to non-pager mode.

View file

@ -1,6 +1,6 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { color, toRem } from 'folds';
import { color, config, toRem } from 'folds';
import {
CHIP_GAP_PX,
CURTAIN_BREATHER_PX,
@ -9,6 +9,7 @@ import {
CURTAIN_SNAP_MS,
HANDLE_HEIGHT_PX,
TABS_ROW_PX,
WEB_TABS_ROW_PX,
} from './geometry';
// 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
// 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({
flexShrink: 0,
height: toRem(TABS_ROW_PX),
display: 'flex',
alignItems: 'center',
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({
@ -89,20 +108,32 @@ export const iconsCluster = style({
// Curtain. Layered above the header (z-index higher). Its top edge
// moves with the snap state (and live finger drag); its bottom edge
// is anchored to the stage bottom so the curtain's `bottomPinned`
// child (DirectSelfRow / ChannelCreateRow / WorkspaceFooter) stays
// glued to the visible viewport bottom regardless of where the
// curtain's top is.
// child (DirectSelfRow / WorkspaceFooter) stays glued to the visible
// viewport bottom regardless of where the curtain's top is.
//
// Only the TOP corners are rounded: the bottom is meant to read as
// continuous with the always-visible bottomPinned row (DirectSelfRow
// is the curtain's last flex child) — adding `borderBottomRadius`
// would crop the row's corners against the curtain's
// `overflow: hidden`, which visually reads as «a light-blue strip
// cuts into the row».
// On native, only the TOP corners are rounded: the bottom is meant
// to read as continuous with the always-visible bottomPinned row
// (DirectSelfRow is the curtain's last flex child) — adding
// `borderBottomRadius` would crop the row's corners against the
// curtain's `overflow: hidden`, which visually reads as «a light-
// blue strip cuts into the row».
//
// Live finger tracking and snap commits both flow through React state
// updates to `top` so the transition is always coordinated with the
// 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({
position: 'absolute',
left: 0,
@ -120,6 +151,12 @@ export const curtain = style({
// Hint the compositor while the curtain is moving. Cheap since the
// curtain is the only element in this stacking context that animates.
willChange: 'top',
selectors: {
'[data-platform="web"] &': {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
},
});
// 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
// curtain's flex-bottom by virtue of being the last child. The TSX
// applies a `transform: translateY(keyboardH)` to this element when
// the on-screen keyboard rises (via `VisualViewport.height` shrink)
// so the row stays at its ORIGINAL viewport-bottom position — under
// the keyboard, clipped by the curtain's `overflow: hidden`. Without
// this compensation, `interactive-widget=resizes-content` (global
// meta — load-bearing for the room composer) shrinks the layout
// viewport, dragging every `bottom: 0` element up over the inline
// form. The DirectSelfRow ending up immediately above the keyboard
// blocks the user's view of the form they're typing into.
// collapses this slot to `{ height: 0, overflow: hidden }` when the
// on-screen keyboard rises (via `VisualViewport.height` shrink) so
// the row neither paints nor claims flex space above the keyboard.
// Without this compensation, `interactive-widget=resizes-content`
// (global viewport meta — load-bearing for the room composer)
// shrinks the layout viewport, dragging every `bottom: 0` element
// up over the inline form. The DirectSelfRow ending up immediately
// above the keyboard would block the user's view of the form they're
// typing into.
export const bottomPinnedSlot = style({
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).

View file

@ -16,7 +16,12 @@ import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
import { isNativePlatform } from '../../utils/capacitor';
import { useBotPresets } from '../../features/bots/catalog';
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 { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
import * as css from './StreamHeader.css';
@ -45,21 +50,34 @@ type StreamHeaderProps = {
// `overflow: auto` div that the gesture hook listens to.
children: ReactNode;
// Optional row(s) pinned to the bottom of the curtain (DirectSelfRow,
// ChannelCreateRow, WorkspaceFooter). Hidden while a form is active
// so the on-screen keyboard's viewport resize doesn't push them up
// over the form (see commit 14ed080).
// WorkspaceFooter). Hidden while a form is active so the on-screen
// keyboard's viewport resize doesn't push them up over the form
// (see commit 14ed080).
bottomPinned?: ReactNode;
// Stable identifier used to persist the curtain's pinned overlay
// across listing-pane remounts (the user taps into a Room and back,
// which unmounts the listing pane). When provided, pin state is
// stored in `curtainPinnedByTabAtom[pinKey]`; without it, pin lives
// in a local useState that resets on unmount. Listing surfaces
// wired into the mobile pager (Direct / Channels / Bots) all pass
// a key; other consumers can omit it.
pinKey?: string;
// which unmounts the listing pane). Pin state is stored in
// `curtainPinnedByTabAtom[pinKey]` so it outlives any individual
// StreamHeader instance. Each listing tab (Direct/Channels/Bots)
// passes its own key; the Channels landing CTA and workspace
// listing share `"channels"` so pin survives the toggle between
// 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 navigate = useNavigate();
const bots = useBotPresets();
@ -129,20 +147,24 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
// Two parallel curtain-gesture surfaces:
//
// * `useCurtainHandleGesture` — the dedicated 32 px drag-handle
// at the top of the curtain. Crisp 1:1 finger ↔ curtain on
// every transition (pin, unpin, peek, close-peek, form-close).
// at the top of the curtain. Crisp 1:1 finger ↔ curtain. From
// 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 —
// the handle is a distinct surface and never competes with list
// scroll.
// scroll. Only rendered on native (`isNativePlatform()`).
//
// * `useCurtainBodyGesture` — anywhere on the curtain body
// OUTSIDE the handle (chat list, empty-state placeholder,
// bottom-pinned row). Rubber-banded (0.65) on every transition
// so the body drag reads as physically «heavier» than the
// handle's crisp pull. Engages ONLY when the chat list has no
// scrollable content — long lists keep native vertical scroll;
// short / empty lists let the user pull the curtain «from
// anywhere».
// OUTSIDE the handle (chat list, empty-state placeholder).
// Rubber-banded (0.65) for all transitions, so the body drag
// reads as physically «heavier» than the handle's crisp pull.
// Engages only when the chat list has no scrollable content;
// additionally bails on touches that start inside the bottom-
// pinned slot (DirectSelfRow / WorkspaceFooter have their own
// 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
// `PageNavResizeHandle`: `dragging` lights up the grabber pill;
@ -154,6 +176,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
// visual.
const handleRef = useRef<HTMLDivElement>(null);
const curtainRef = useRef<HTMLDivElement>(null);
const bottomPinnedRef = useRef<HTMLDivElement>(null);
const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({
dragging: false,
atCommit: false,
@ -171,6 +194,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
useCurtainBodyGesture({
curtainRef,
handleRef,
bottomPinnedRef,
scrollRef,
snap: curtain.snap,
pinned: curtain.pinned,
@ -196,8 +220,9 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
openChat,
closeForm: close,
isFormActive: isActive,
primaryAction: primaryAction ?? null,
}),
[openSearch, openChat, close, isActive]
[openSearch, openChat, close, isActive, primaryAction]
);
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
// global pinned atom shares this state across every listing tab so
// 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
? 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.
// 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.
const [keyboardOpen, setKeyboardOpen] = useState(false);
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;
if (!vv) return undefined;
const KEYBOARD_PROBE_PX = 100;
@ -284,7 +329,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
}, []);
return (
<div className={css.stage}>
<div className={css.stage} data-platform={isNativePlatform() ? undefined : 'web'}>
<header className={css.header}>
{/* Tabs row + action icons (always visible)
In pager mode the row stays mounted (curtain snap math
@ -353,13 +398,19 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
fill="None"
size="400"
radii="Pill"
onClick={openChat}
aria-label={t('Direct.create_chat')}
aria-controls={INLINE_FORM_ID}
onClick={primaryAction ? primaryAction.onClick : openChat}
aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
// `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-haspopup="dialog"
>
<Icon size="100" src={Icons.Plus} />
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
</IconButton>
<IconButton
variant="SurfaceVariant"
@ -422,9 +473,9 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
</div>
<div className={css.chipRow}>
<Chip
iconSrc={Icons.Plus}
label={t('Direct.create_chat')}
onClick={openChat}
iconSrc={primaryAction ? primaryAction.iconSrc : Icons.Plus}
label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
onClick={primaryAction ? primaryAction.onClick : openChat}
hidden={curtain.snap !== 'peek'}
/>
</div>
@ -448,14 +499,17 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
}}
onTransitionEnd={onCurtainTransitionEnd}
>
{/* Drag handle (native-only behaviour, but rendered on all
platforms so the layout stays identical the gesture hook
short-circuits off-native). Hosts the entire curtain
gesture surface pin, unpin, peek, close-peek and
form-close all bind here, leaving the chat list to native
scroll. Stays mounted across snap transitions so the
gesture surface is always reachable when there is one to
make.
{/* Drag handle native-only. On web (desktop browsers,
Electron) the curtain has no interactive snap states, so
the handle would be pure decoration with no behaviour
behind it; rendering it conditionally drops the 32 px
grabber strip on those surfaces and lets the chat list
sit flush against the curtain's rounded top.
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
`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
the gesture hook (dedup'd), so the handle visual updates
without slamming the DOM on every touchmove. */}
<div
ref={handleRef}
className={css.handle}
data-dragging={handleVisual.dragging || undefined}
data-at-commit={handleVisual.atCommit || undefined}
aria-hidden
>
<div className={css.handleBar} />
</div>
{isNativePlatform() && (
<div
ref={handleRef}
className={css.handle}
data-dragging={handleVisual.dragging || undefined}
data-at-commit={handleVisual.atCommit || undefined}
aria-hidden
>
<div className={css.handleBar} />
</div>
)}
{children}
{/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is
kept mounted across snaps so the curtain reads as a self-
{/* `bottomPinned` (DirectSelfRow, WorkspaceFooter) is kept
mounted across snaps so the curtain reads as a self-
contained "screen" with its bottom row always pinned to
the stage bottom. While the on-screen keyboard is up the
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). */}
{bottomPinned && (
<div
ref={bottomPinnedRef}
className={css.bottomPinnedSlot}
style={keyboardOpen ? { height: 0, overflow: 'hidden' } : undefined}
>

View file

@ -34,6 +34,26 @@
// Tabs row height. Always visible above the curtain.
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.
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
// and springs back to the previous resting snap.
//
// With 1:1 finger ↔ curtain tracking (no rubber-band on pin / unpin —
// see `useCurtainHandleGesture`), the committing finger pull is
// On the handle the up direction is 1:1 with no upper clamp (the
// «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
// the curtain across the full tabs-row height». The anti-accidental
// gate is provided by the dedicated handle hit-zone (intentional
// surface) — the chat list under the curtain is left to native
// scroll and never engages a pin path, so there's no scroll-vs-pin
// ambiguity to disambiguate.
// the curtain across the full tabs-row height». On the body the same
// displacement is reached with a longer finger pull because the body
// path is rubber-banded (×0.65).
//
// 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;
// Drag-handle hit-zone at the top of the curtain. The handle is the
// AUTHORITATIVE gesture surface — pin, unpin, peek, close-peek and
// 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.
// Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the
// handle is rendered only when `isNativePlatform()` is true (see
// StreamHeader.tsx) — on web (desktop / Electron) the curtain has
// no interactive snap states, so the handle would be pure
// 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
// (everything below the handle) handles drag from anywhere on the
// card, but only when the inner chat list has no scrollable content
// — its dynamics are rubber-banded so the body drag reads as
// physically «heavier» than the handle's crisp pull.
// handles drag from anywhere on the card, but only when the inner
// chat list has no scrollable content AND the curtain isn't pinned
// (unpin is handle-only). Its dynamics are rubber-banded so the
// body drag reads as physically «heavier» than the handle's crisp
// pull.
//
// Size: 32 px tall — enough touch target to land on comfortably with
// a thumb (the visible grabber pill inside is much smaller, see
// `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;

View file

@ -10,7 +10,11 @@ import {
RUBBER_BAND,
} from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState';
import { CurtainTransition, resolveCurtainTransition } from './useCurtainHandleGesture';
import {
assertNeverCurtainTransition,
CurtainTransition,
resolveCurtainTransition,
} from './useCurtainHandleGesture';
type Args = {
// 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
// hook has already armed for that touch).
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
// gesture engages only when this element is NOT scrollable
// (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,
// no direct DOM writes.
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.
commit: (next: CurtainSnap) => void;
commit: (next: 'peek' | 'closed') => void;
// Suppress gesture binding entirely. Same conditions as the handle
// hook — see StreamHeader's `gestureDisabled`.
disabled?: boolean;
@ -86,9 +100,17 @@ type Args = {
// 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
// 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({
curtainRef,
handleRef,
bottomPinnedRef,
scrollRef,
snap,
pinned,
@ -134,11 +156,20 @@ export function useCurtainBodyGesture({
const onTouchStart = (e: TouchEvent) => {
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
// handle's 32 px hit-zone — the handle's own listener has
// already armed for this touch.
const target = e.target as Node | null;
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
// vertical scroll. Skipped in form-* snaps because the visible
// 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.
let atCommit = false;
switch (transition) {
case 'pin':
// Rubber-banded up, clamped at the safe-top edge.
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta * RUBBER_BAND));
atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break;
case 'unpin':
// Rubber-banded down, clamped at the closed-resting edge.
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta * RUBBER_BAND));
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break;
case 'peek':
// Rubber-banded down. Bounds come from the direction guard
// above plus the snap clamp on touchend, so no extra clamp —
// matches the original list-bound peek feel.
case 'closed-free':
// Rubber-banded free-range drag spanning pin↔closed↔peek
// in one motion. NO clamps either side — the curtain
// follows the finger off-screen upward and continuously
// into peek territory downward. Direction-aware atCommit
// shows the right commit feedback for whichever side the
// user is leaning into. Mirrors the handle's `closed-free`
// but with 0.65× displacement so the body drag reads as
// physically «heavier».
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;
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;
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
@ -233,8 +266,21 @@ export function useCurtainBodyGesture({
lastDelta = Math.min(0, delta * RUBBER_BAND);
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
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;
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);
emitHandle(true, atCommit);
@ -249,22 +295,13 @@ export function useCurtainBodyGesture({
return;
}
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) {
setPinnedRef.current(true);
} else {
setLiveDrag(0, false);
}
break;
case 'unpin':
if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(false);
} else {
setLiveDrag(0, false);
}
break;
case 'peek':
if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('peek');
} else {
setLiveDrag(0, false);
@ -284,9 +321,18 @@ export function useCurtainBodyGesture({
setLiveDrag(0, false);
}
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);
break;
default: {
assertNeverCurtainTransition(transition);
setLiveDrag(0, false);
break;
}
}
startX = null;
startY = null;
@ -317,10 +363,18 @@ export function useCurtainBodyGesture({
curtain.removeEventListener('touchmove', onTouchMove);
curtain.removeEventListener('touchend', onTouchEnd);
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`,
// `setPinned` and `commit` are ref-mirrored. Only `disabled` needs
// to tear listeners down — it's the sole effect dep.
// 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.
setLiveDrag: (px: number, dragging: boolean) => void;
// 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.
commit: (next: CurtainSnap) => void;
commit: (next: 'peek' | 'closed') => void;
// Suppress gesture binding entirely. Used to gate motion when a
// bottom sheet is open or when this pane is inactive inside the
// swipe pager.
@ -54,7 +55,38 @@ type Args = {
// on the curtain body) decide how raw finger displacement translates
// into curtain motion — see `onTouchMove` here for the 1:1 branches
// 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
// 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.
//
// Direction guards encoded here:
// * pinned + UP → no-op (would push the curtain past safe-top).
// * pinned + DOWN → unpin.
// * closed + UP → pin.
// * closed + DOWN → peek.
// * pinned + UP → no-op (would push the curtain past safe-top
// on commit — no destination above pinned).
// * pinned + DOWN → pinned-free (HANDLE-only contract — the body
// 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 + DOWN → no-op (nothing lower to reveal).
// * form-* + UP → form-close.
@ -76,8 +115,8 @@ export function resolveCurtainTransition(
pinned: boolean,
direction: 'up' | 'down'
): CurtainTransition | null {
if (pinned) return direction === 'down' ? 'unpin' : null;
if (snap === 'closed') return direction === 'up' ? 'pin' : 'peek';
if (pinned) return direction === 'down' ? 'pinned-free' : null;
if (snap === 'closed') return 'closed-free';
if (snap === 'peek') return direction === 'up' ? 'close-peek' : null;
if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null;
return null;
@ -88,30 +127,53 @@ export function resolveCurtainTransition(
// desktop.
//
// The handle is the «authoritative» gesture surface — it owns every
// transition (pin, unpin, peek, close-peek, form-close) with crisp
// 1:1 finger ↔ curtain tracking regardless of whether the chat list
// inside the curtain is scrollable. The curtain BODY has a parallel
// gesture (`useCurtainBodyGesture`) with rubber-banded dynamics that
// only engages when the body's chat list has no scrollable content —
// so the user can pull the curtain «from anywhere» on empty / short
// lists but a real list-scroll is never hijacked under their finger.
// History note: an earlier `useCurtainGesture` bound the peek /
// form-close paths to the list scroll viewport directly. That coupling
// produced repeating «drag-up at scrollTop=0 hijacks for pin» / «drag-
// down at scrollTop=0 hijacks for peek» bugs and was removed when
// pin / unpin moved here.
// transition (closed-free, pinned-free, close-peek, form-close)
// with crisp 1:1 finger ↔ curtain tracking regardless of whether
// the chat list inside the curtain is scrollable. The curtain BODY
// has a parallel gesture (`useCurtainBodyGesture`) with rubber-
// banded dynamics that only engages when the body's chat list has
// no scrollable content — so the user can pull the curtain «from
// anywhere» on empty / short lists but a real list-scroll is never
// hijacked under their finger. The body is also fully inert while
// pinned, so unpin (and unpin → peek overshoot) stays a deliberate
// handle pull.
//
// All five transitions track the finger 1:1, clamped at the relevant
// snap edge so jitter past the destination doesn't visually overshoot:
// * pin / unpin — clamp ±PIN_TRAVEL_PX, commit at
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX
// («дотянул прям до самого верха»).
// * peek / close-peek — clamp ±PEEK_TRAVEL_PX, commit at
// Design rationale: gestures used to bind to the chat list's scroll
// viewport directly, which produced repeating «drag-at-scrollTop=0
// hijacks for pin/peek» bugs. Moving every transition onto a
// dedicated handle (plus an opt-in body surface that bails on
// scrollable lists) removes the scroll/gesture race entirely.
//
// 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.
// * form-close — capped at 0 so a downward jitter can't push
// the curtain below its form-snap position.
// Commit at ACTIVE_CLOSE_THRESHOLD_PX
// (absolute distance, not a fraction).
// * form-close — capped at 0 so a downward jitter can't
// push the curtain below its form-snap top,
// NO upper clamp. Commit at
// ACTIVE_CLOSE_THRESHOLD_PX (absolute).
//
// Handle visual: emitHandle(true, atCommit) fires on every transition
// during touchmove so the grabber pill animates Primary-blue +
@ -223,36 +285,47 @@ export function useCurtainHandleGesture({
engaged = true;
e.preventDefault();
// Clamp / rubber-band the raw finger delta into the live curtain
// displacement (`lastDelta`). Stored separately because the
// commit math on release needs the same value the curtain was
// visually showing.
// Clamp the raw finger delta into the live curtain displacement
// (`lastDelta`). Stored separately because the commit math on
// release needs the same value the curtain was visually showing.
let atCommit = false;
switch (transition) {
case 'pin':
// 1:1 up, clamped so the curtain doesn't enter the
// system-tray safe-top zone.
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta));
atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
case 'closed-free':
// Single free-range drag spanning pin↔closed↔peek. 1:1 with
// NO clamps either side: the curtain follows the finger off-
// screen upward (past safe-top) and continuously into peek
// 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;
case 'unpin':
// 1:1 down, clamped so the curtain doesn't descend past its
// `closed` resting top during the drag.
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta));
case 'pinned-free':
// 1:1 down from pinned. Clamped at 0 below (a downward
// jitter past the start mustn't push the curtain into
// 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;
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':
// 1:1 up; delta is negative. Symmetric clamp to peek above.
lastDelta = Math.min(0, Math.max(-PEEK_TRAVEL_PX, delta));
// 1:1 up; delta is negative. Lower-capped at 0 (a downward
// 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;
break;
case 'form-close':
@ -262,10 +335,19 @@ export function useCurtainHandleGesture({
lastDelta = Math.min(0, delta);
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
break;
default:
// Unreachable — transition is non-null past the dead-zone
// resolution above and is never cleared mid-gesture.
case null:
// Unreachable: `engaged` is set only after `transition` is
// 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;
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);
emitHandle(true, atCommit);
@ -285,27 +367,42 @@ export function useCurtainHandleGesture({
// transition re-enabled. Non-commit paths drop the live drag back
// to 0 with transition active so the curtain springs back.
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) {
setPinnedRef.current(true);
} else {
setLiveDrag(0, false);
}
break;
case 'unpin':
if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(false);
} else {
setLiveDrag(0, false);
}
break;
case 'peek':
if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('peek');
} else {
setLiveDrag(0, false);
}
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':
if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('closed');
@ -320,9 +417,19 @@ export function useCurtainHandleGesture({
setLiveDrag(0, false);
}
break;
default:
case null:
// Unreachable: `engaged` is set only after `transition` is
// resolved non-null. Mirrors the touchmove switch.
setLiveDrag(0, false);
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;
startY = null;
@ -354,6 +461,16 @@ export function useCurtainHandleGesture({
handle.removeEventListener('touchmove', onTouchMove);
handle.removeEventListener('touchend', onTouchEnd);
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`,
// `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-
// driven listing-pane unmount when the user taps into a Room and
// back. Each tab keeps its own pin (Direct/Channels/Bots are
// independent). If no `pinKey` is provided, the pin lives in a
// local useState that resets on unmount — fine for non-listing
// surfaces where pinning isn't expected anyway.
// independent).
pinned: boolean;
// Setter for the pinned overlay. Called by the gesture hook on
// commit (drag-up-from-closed past threshold sets true; drag-down-
@ -69,7 +67,10 @@ export type CurtainState = {
close: () => void;
// Commit a snap stop directly. Used by the touch gesture on release.
// 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
// every touchmove. Updates are batched by React inside event handlers.
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 [activeForm, setActiveForm] = useState<ActiveForm>(null);
const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
const [liveDragPx, setLiveDragPx] = useState(0);
const [isDragging, setIsDragging] = useState(false);
// Pin storage split: atom-backed when `pinKey` is supplied (survives
// listing-pane remount on Room navigate-back), local useState
// fallback when no key is supplied (web/non-listing mounts where
// pinning isn't expected).
// Per-tab pin lives in `curtainPinnedByTabAtom` so the lock survives
// the route-driven listing-pane unmount that happens when the user
// taps into a Room and back. The atom outlives any individual
// StreamHeader instance.
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
const [pinnedLocal, setPinnedLocal] = useState(false);
const pinned = pinKey ? !!pinnedMap[pinKey] : pinnedLocal;
const pinned = !!pinnedMap[pinKey];
const formMeasureRef = useRef<HTMLDivElement>(null);
const setPinned = useCallback(
(next: boolean) => {
if (pinKey) {
setPinnedMap((prev) => {
// Compare-and-skip so we don't allocate a fresh object (and
// re-render every other subscriber of the atom) when nothing
// actually changes.
if (!!prev[pinKey] === next) return prev;
return { ...prev, [pinKey]: next };
});
} else {
setPinnedLocal(next);
}
setPinnedMap((prev) => {
// Compare-and-skip so we don't allocate a fresh object (and
// re-render every other subscriber of the atom) when nothing
// actually changes.
if (!!prev[pinKey] === next) return prev;
return { ...prev, [pinKey]: next };
});
// Drop any in-flight live drag on commit so the curtain renders
// at the new pinned-derived top without a residual finger offset.
setLiveDragPx(0);
@ -145,12 +141,12 @@ export function useCurtainState(pinKey?: string): CurtainState {
setLiveDragPx(0);
setIsDragging(false);
// Safety net: clear pin so the form is visible. In practice the
// static pager header's icons (the only call site of open()) are
// covered by the curtain when pinned, so the user can't trigger
// this directly — but a future programmatic open() or a per-pane
// tabsRow that escapes the pager-mode visibility:hidden gate
// would otherwise mount the form behind the still-pinned curtain
// at curtainTop=0 and the user would see an invisible form.
// visible openers (static pager header icons, in-pane chips on
// non-pager surfaces) are all covered by the curtain when pinned,
// so the user can't trigger this directly — but a future
// programmatic open() would otherwise mount the form behind the
// still-pinned curtain at curtainTop=0 and present an invisible
// form.
setPinned(false);
},
[setPinned]
@ -162,17 +158,14 @@ export function useCurtainState(pinKey?: string): CurtainState {
setIsDragging(false);
}, []);
const commit = useCallback((next: CurtainSnap) => {
const commit = useCallback((next: 'peek' | 'closed') => {
setSnap(next);
setLiveDragPx(0);
setIsDragging(false);
if (isFormSnap(next)) {
setActiveForm(next === 'form-search' ? 'search' : 'chat');
}
// Note: when committing to a non-form snap (peek*/closed) we do
// NOT clear `activeForm` here — it stays set so the closing
// transition has form content beneath. `acknowledgeClosed` clears
// it once the curtain settles at `closed`.
// `activeForm` is intentionally NOT cleared here — it stays set
// so the closing transition has form content beneath the curtain
// as it slides up. `acknowledgeClosed` clears it once the snap
// settles at `closed`.
}, []);
const setLiveDrag = useCallback((px: number, dragging: boolean) => {

View file

@ -18,6 +18,7 @@ import {
} from 'matrix-widget-api';
import { Theme } from '../../hooks/useTheme';
import { openExternalUrl } from '../../utils/capacitor';
import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to';
import type { BotPreset } from './catalog';
import {
BotWidgetDriver,
@ -34,6 +35,14 @@ export type BotWidgetEmbedOptions = {
language: string;
onError: (error: Error) => void;
onReady?: () => void;
// Optional generic «navigate cinny to a matrix.to room/alias» callback.
// Plumbed from `BotWidgetMount` where react-router's `useNavigate` is
// available. The embed validates the URL via `parseMatrixToRoom` BEFORE
// calling — handler receives an already-parsed `{roomIdOrAlias, viaServers}`
// and is free to assume the inputs are well-formed Matrix references. Not
// bot-aware: any widget that delivers a matrix.to URL via the side-channel
// (`open-matrix-to` action) reaches the same handler.
onOpenMatrixToRoom?: (target: MatrixToRoom) => void;
};
const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
@ -214,22 +223,30 @@ export class BotWidgetEmbed {
this.feedStateUpdate(ev);
};
// Side-channel postMessage handler for the widget's `openExternalUrl`
// call. Distinct from matrix-widget-api's `fromWidget` channel
// Side-channel postMessage handler for the widget's Vojo-extension
// actions. Distinct from matrix-widget-api's `fromWidget` channel
// (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
// of our extension and avoids the «unknown action» reply path.
//
// Why this exists: the host's global `setupExternalLinkHandler`
// (utils/capacitor.ts) intercepts `<a target="_blank">` clicks at
// the host document level and routes them via Capacitor's Browser
// plugin. But cross-origin iframes don't bubble click events into
// the parent document, so widget-side links are invisible to it —
// on Capacitor's Android WebView those clicks silently disappear.
// The widget posts this message; we validate the URL and forward
// to the same `openExternalUrl` helper the host uses elsewhere.
// Two actions today:
//
// Security gates (defence in depth):
// * `open-external-url` — forwards an https:// URL to the host's
// `openExternalUrl` (utils/capacitor.ts), which routes through
// Capacitor's Browser plugin on native and `window.open` on web.
// Exists because cross-origin iframes don't bubble click events
// to the host document, so the global `setupExternalLinkHandler`
// never sees widget-side `<a target="_blank">` clicks — on
// Capacitor's Android WebView those would silently disappear.
//
// * `open-matrix-to` — generic «navigate cinny to a matrix.to room
// or alias». Validates the URL through the same `parseMatrixToRoom`
// cinny uses for in-app mention rendering, then hands the parsed
// `MatrixToRoom` to `options.onOpenMatrixToRoom` (composed by
// BotWidgetMount with `useNavigate` + `getChannelsSpacePath`). The
// widget never sees a route — it only knows matrix.to URLs.
//
// Security gates (defence in depth, apply to BOTH actions):
// 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this
// check, a compromised widget bundle could `window.location.href
// = 'https://attacker.example/'` — the browser keeps the same
@ -242,11 +259,17 @@ export class BotWidgetEmbed {
// iframe of the SAME origin — e.g. an ad embed loaded into a
// sibling frame on the same origin in a future deployment —
// could otherwise pass the origin check).
// 3. Only https URLs are honoured. We tightened from http+https to
// https-only because no shipped widget content links over plain
// http; rejecting http closes a cleartext-redirect vector via
// Capacitor `Browser.open` on Android.
// 4. javascript:, data:, file:, etc. are implicitly rejected by (3).
//
// Per-action URL validation (NOT shared, but each branch enforces):
// * `open-external-url` — requires `https:` protocol, rejecting plain
// http, javascript:, data:, file:, etc. We tightened from http+https
// to https-only because no shipped widget content links over plain
// http; rejecting http closes a cleartext-redirect vector via
// Capacitor `Browser.open` on Android.
// * `open-matrix-to` — requires the URL to parse as a matrix.to room
// or alias via `parseMatrixToRoom`. Anything else (matrix.to user
// links, event links, arbitrary https URLs, javascript:/data:/file:
// pseudo-schemes) returns undefined and silently no-ops.
private readonly onWidgetMessage = (ev: MessageEvent) => {
if (ev.origin !== this.widgetOrigin) return;
if (ev.source !== this.iframe.contentWindow) return;
@ -255,18 +278,38 @@ export class BotWidgetEmbed {
| undefined;
if (!msg || typeof msg !== 'object') return;
if (msg.api !== 'io.vojo.bot-widget') return;
if (msg.action !== 'open-external-url') return;
const url = msg.data?.url;
if (typeof url !== 'string') return;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return;
} catch {
if (msg.action === 'open-external-url') {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return;
} catch {
return;
}
openExternalUrl(url).catch(() => {
/* fire-and-forget: log handled inside openExternalUrl */
});
return;
}
openExternalUrl(url).catch(() => {
/* fire-and-forget: log handled inside openExternalUrl */
});
if (msg.action === 'open-matrix-to') {
// Generic «navigate cinny to a matrix.to room/alias». Not bot-aware —
// the widget hands over a matrix.to URL it obtained however (parsed
// from a bridge sentinel, scraped from chat, whatever), and we
// validate via the same `parseMatrixToRoom` cinny uses for in-app
// mention rendering (plugins/react-custom-html-parser.tsx). Only the
// matrix.to/#/!roomId and matrix.to/#/#alias shapes pass — user
// links, event links, non-matrix.to URLs, javascript:/data:/etc. all
// return undefined and silently no-op here. The host-side router
// hop (`onOpenMatrixToRoom`) is the optional caller — embedded code
// paths that don't provide a callback (e.g. future test harness) get
// a silent drop, not a crash.
const parsed = parseMatrixToRoom(url);
if (!parsed) return;
this.options.onOpenMatrixToRoom?.(parsed);
}
};
public constructor(private readonly options: BotWidgetEmbedOptions) {

View file

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

View file

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

View file

@ -22,7 +22,11 @@ export function BotStatePage({ title, description, icon, children }: BotStatePag
const screenSize = useScreenSizeContext();
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 && (
<PageHeader balance outlined={false}>
<Box grow="Yes" alignItems="Center" gap="200">

View file

@ -21,9 +21,12 @@ function BotRow({ preset }: { preset: BotPreset }) {
export function Bots() {
const bots = useBotPresets();
// `scrollRef` is passed to the header so the touch gesture (native
// only) can recognise list scrollTop=0 and engage the curtain peek.
// Icons + click flows work on every platform regardless.
// `scrollRef` is forwarded so the curtain body gesture can check
// whether the list is scrollable and bail to native scroll on long
// 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);
// Skip PageNav surface in pager mode — see Direct.tsx for the
// 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 { PageNav, PageNavContent } from '../../../components/page';
import { StreamHeader } from '../../../components/stream-header';
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 { ChannelCreateRow } from './ChannelCreateRow';
import { ChannelsLanding } from './ChannelsLanding';
import { ChannelsWorkspaceHorseshoe } from './ChannelsWorkspaceHorseshoe';
import { WorkspaceFooter } from './WorkspaceFooter';
import { ACTIVE_SPACE_KEY } from './useActiveSpace';
// Index route at /channels/ (no space selected). Renders the shared
// 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
// the centering to top-aligned. Same idiom as `Direct.tsx::DirectEmpty`.
export function ChannelsRootNav() {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const openCreateSpaceModal = useOpenCreateSpaceModal();
// Skip PageNav surface in pager mode so the pager's static header
// tabs (sitting behind the swipe strip in DOM order) show through
// until covered by the rising curtain. See Direct.tsx for the same
// pattern and `mobile-tabs-pager/style.css.ts::pagerStaticHeader`
// for the full overlay contract.
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 (
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
{/* Shared `pinKey` with the workspace-listing `Channels` below
so pin/unpin persists when the user toggles between the
landing CTA and a chosen workspace within the Channels tab. */}
<StreamHeader scrollRef={scrollRef} pinKey="channels">
<StreamHeader scrollRef={scrollRef} pinKey="channels" primaryAction={primaryAction}>
<ChannelsLanding />
</StreamHeader>
</PageNav>
@ -54,21 +75,33 @@ export function ChannelsRootNav() {
// global rail's «click avatar → resume your last in-space path» stays
// well-defined. Channels has its own segment-level navigation.
export function Channels() {
const { t } = useTranslation();
const space = useSpace();
const scrollRef = useRef<HTMLDivElement>(null);
const inPagerMode = useMobilePagerPane() !== null;
const openCreateRoomModal = useOpenCreateRoomModal();
const setActiveSpace = useSetAtom(activeChannelsSpaceAtom);
// Persist URL-driven active space so cold-starts at /channels/ resume on
// the same workspace. `useActiveSpace` (in ChannelsLanding) reads the
// value but never writes it because the index route has no
// :spaceIdOrAlias param — the write happens here.
// the same workspace. `useActiveSpace` (in ChannelsLanding and
// MobileTabsPager) subscribes to the same atom, so this write
// immediately invalidates their cached reads — without that, the pager
// would serve a stale `destinationFor('channels')` after a switcher
// pick + tab swap on native.
useEffect(() => {
try {
localStorage.setItem(ACTIVE_SPACE_KEY, space.roomId);
} catch {
/* private mode / quota — non-fatal */
}
}, [space.roomId]);
setActiveSpace(space.roomId);
}, [setActiveSpace, space.roomId]);
// Plus on the tabs row creates a channel inside the active workspace.
// Single entry point for «create» on the Channels tab.
const primaryAction = useMemo<StreamHeaderPrimaryAction>(
() => ({
iconSrc: Icons.Plus,
label: t('Channels.create_channel'),
onClick: () => openCreateRoomModal(space.roomId, CreateRoomType.TextRoom),
}),
[t, openCreateRoomModal, space.roomId]
);
return (
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
@ -76,12 +109,8 @@ export function Channels() {
<StreamHeader
scrollRef={scrollRef}
pinKey="channels"
bottomPinned={
<>
<ChannelCreateRow space={space} />
<WorkspaceFooter space={space} />
</>
}
primaryAction={primaryAction}
bottomPinned={<WorkspaceFooter space={space} />}
>
<PageNavContent scrollRef={scrollRef}>
<ChannelsList scrollRef={scrollRef} />

View file

@ -41,8 +41,8 @@ export const container = style({
marginTop: 'calc(-1 * var(--vojo-safe-top, 0px))',
});
// Wrapped children (StreamHeader → ChannelsList → ChannelCreateRow →
// WorkspaceFooter). Stays put — the bottom is carved away by an animated
// Wrapped children (StreamHeader → ChannelsList → WorkspaceFooter).
// Stays put — the bottom is carved away by an animated
// `clip-path: inset(...)` with rounded BL/BR. `backgroundColor` is
// 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

View file

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

View file

@ -1,18 +1,10 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { activeChannelsSpaceAtom } from '../../../state/activeChannelsSpace';
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 => {
try {
return decodeURIComponent(raw);
@ -23,16 +15,22 @@ const safeDecode = (raw: string): string | undefined => {
// Resolves the active Space for the channels segment. Priority:
// 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
// Returns undefined when the user has 0 joined orphan spaces. Persistence
// (writing to localStorage) lives in `Channels.tsx`, where the inner
// route already has the resolved space context — at the index `/channels/`
// (writing the atom) lives in `Channels.tsx`, where the inner route
// already has the resolved space context — at the index `/channels/`
// 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 => {
const mx = useMatrixClient();
const { spaceIdOrAlias } = useParams();
const orphanSet = useMemo(() => new Set(orphanSpaceIds), [orphanSpaceIds]);
const persisted = useAtomValue(activeChannelsSpaceAtom);
const urlSpaceId = useMemo(() => {
if (!spaceIdOrAlias) return undefined;
@ -42,10 +40,10 @@ export const useActiveSpace = (orphanSpaceIds: string[]): string | undefined =>
return resolved && orphanSet.has(resolved) ? resolved : undefined;
}, [mx, spaceIdOrAlias, orphanSet]);
const persistedSpaceId = useMemo(() => {
const stored = readPersisted();
return stored && orphanSet.has(stored) ? stored : undefined;
}, [orphanSet]);
const persistedSpaceId = useMemo(
() => (persisted && orphanSet.has(persisted) ? persisted : undefined),
[persisted, orphanSet]
);
return urlSpaceId ?? persistedSpaceId ?? orphanSpaceIds[0];
};

View file

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

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 { 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
// tabs row at the top of MobileTabsPager. The pager hoists the tabs +
@ -17,6 +30,10 @@ export type MobilePagerCurtainControls = {
openChat: () => void;
closeForm: () => void;
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);