Compare commits
10 commits
2617eaf46e
...
4a2e29fdb5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a2e29fdb5 | ||
|
|
f104bdfe8b | ||
|
|
a62761af06 | ||
|
|
2720830cb0 | ||
|
|
1d788ee20f | ||
|
|
298669a084 | ||
|
|
ab75a178e4 | ||
|
|
94ec309120 | ||
|
|
23cebcd38f | ||
|
|
16cb9c5b26 |
30 changed files with 1106 additions and 430 deletions
60
apps/.eslintrc.cjs
Normal file
60
apps/.eslintrc.cjs
Normal 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',
|
||||
},
|
||||
};
|
||||
|
|
@ -95,6 +95,18 @@ const LinkIcon = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
// 2×2 grid of rounded squares — leads the OpenSpaceCard. Reads as
|
||||
// «space with channels inside»; consistent visual vocabulary with the
|
||||
// channels-tab workspace grid affordances on the host side.
|
||||
const SpaceGridIcon = () => (
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<rect x="3.5" y="3.5" width="5.5" height="5.5" rx="1.4" />
|
||||
<rect x="11" y="3.5" width="5.5" height="5.5" rx="1.4" />
|
||||
<rect x="3.5" y="11" width="5.5" height="5.5" rx="1.4" />
|
||||
<rect x="11" y="11" width="5.5" height="5.5" rx="1.4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Linkifier — same heuristic as TG widget.
|
||||
const URL_RE = /https?:\/\/[^\s)]+/g;
|
||||
|
||||
|
|
@ -388,6 +400,13 @@ const loadHCaptcha = (): Promise<HCaptchaApi> => {
|
|||
`script[src^="https://js.hcaptcha.com/1/api.js"]`
|
||||
) as HTMLScriptElement | null;
|
||||
|
||||
// `timeoutHandle` is read in the `settle` closure declared below
|
||||
// BEFORE the assignment at the bottom of this function. ESLint's
|
||||
// flow analysis can't see the deferred assignment through the
|
||||
// closure and flags this as never-reassigned; in practice the value
|
||||
// IS reassigned and using `const` here would break the hcaptcha
|
||||
// script-load timeout path.
|
||||
// eslint-disable-next-line prefer-const
|
||||
let timeoutHandle: number | undefined;
|
||||
let settled = false;
|
||||
const settle = (action: () => void) => {
|
||||
|
|
@ -599,9 +618,7 @@ const CaptchaPanel = ({ state, t, onSolved, onCancel, onExpired }: CaptchaPanelP
|
|||
<div class="auth-card-hint">{t('auth-card.captcha.hint')}</div>
|
||||
<div class="auth-card-captcha-frame">
|
||||
<div ref={containerRef} class="auth-card-captcha-host" />
|
||||
{loadError ? (
|
||||
<div class="auth-card-error">{t('auth-card.captcha.load-error')}</div>
|
||||
) : null}
|
||||
{loadError ? <div class="auth-card-error">{t('auth-card.captcha.load-error')}</div> : null}
|
||||
</div>
|
||||
<div class="auth-card-row">
|
||||
<button type="button" class="btn-text" onClick={onCancel}>
|
||||
|
|
@ -764,6 +781,40 @@ const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Open-space card — Vojo extension
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
type OpenSpaceCardProps = {
|
||||
t: T;
|
||||
matrixToUrl: string;
|
||||
onOpen: (url: string) => void;
|
||||
};
|
||||
|
||||
// Surfaces the personal Discord space the bridge auto-created at login.
|
||||
// Renders only when `state.spaceMatrixToUrl` is populated — i.e. against a
|
||||
// Vojo-patched bridge that emitted `VOJO-LOGIN-SPACE-V1`. Against an
|
||||
// upstream/unpatched bridge the card is absent (no sentinel, no URL, the
|
||||
// `space_ready` reducer case never fires).
|
||||
//
|
||||
// Click hands the URL to the host via the `io.vojo.bot-widget`
|
||||
// side-channel (api.openMatrixToUrl) — the widget is sandboxed and
|
||||
// can't navigate cinny itself. Host validates and routes.
|
||||
const OpenSpaceCard = ({ t, matrixToUrl, onOpen }: OpenSpaceCardProps) => (
|
||||
<button class="command-card" type="button" onClick={() => onOpen(matrixToUrl)}>
|
||||
<span class="command-card-lead-icon" aria-hidden="true">
|
||||
<SpaceGridIcon />
|
||||
</span>
|
||||
<div class="command-card-body">
|
||||
<div class="command-card-name">{t('card.open-space.name')}</div>
|
||||
<div class="command-card-desc">{t('card.open-space.desc')}</div>
|
||||
</div>
|
||||
<span class="command-card-chevron" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Main App
|
||||
// --------------------------------------------------------------------------
|
||||
|
|
@ -927,6 +978,15 @@ export function App({ bootstrap, api }: Props) {
|
|||
// hydrate too; the live path treats it identically.
|
||||
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
||||
appendedAnyHistory = true;
|
||||
} else if (parsed.kind === 'space_ready') {
|
||||
// VOJO-LOGIN-SPACE-V1 sentinel body is a JSON blob —
|
||||
// machine-readable, never user-readable. Suppress the raw
|
||||
// body from the transcript and emit a diag breadcrumb
|
||||
// instead so a reload-replay shows «space ready» rather
|
||||
// than `VOJO-LOGIN-SPACE-V1 {"matrix_to_url":"…"}` ugly
|
||||
// verbatim. Same discipline as the captcha branch above.
|
||||
append({ kind: 'diag', text: t('diag.space-ready') });
|
||||
appendedAnyHistory = true;
|
||||
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
|
||||
// m.text / m.notice — body is safe to replay verbatim,
|
||||
// BUT we still scrub any login-URL-shaped substring as
|
||||
|
|
@ -989,10 +1049,7 @@ export function App({ bootstrap, api }: Props) {
|
|||
append({ kind: 'diag', text: t('diag.qr-issued') });
|
||||
} else if (event.kind === 'qr_redacted') {
|
||||
const liveState = stateRef.current;
|
||||
if (
|
||||
liveState.kind === 'awaiting_qr_scan' &&
|
||||
liveState.qrEventId === event.redactsEventId
|
||||
) {
|
||||
if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
|
||||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||
}
|
||||
} else if (event.kind === 'captcha_challenge') {
|
||||
|
|
@ -1001,6 +1058,12 @@ export function App({ bootstrap, api }: Props) {
|
|||
// transcript DOM (where screenshots / accessibility tools could
|
||||
// leak them). Diag-only display.
|
||||
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
||||
} else if (event.kind === 'space_ready') {
|
||||
// Sentinel body is the JSON `{"matrix_to_url":"…"}` — not human-
|
||||
// readable and pointless to show verbatim. Emit a diag breadcrumb;
|
||||
// the actual «Open in Channels» card is rendered by the reducer
|
||||
// attaching `spaceMatrixToUrl` to the connected state.
|
||||
append({ kind: 'diag', text: t('diag.space-ready') });
|
||||
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
|
||||
const body = ev.content.body ?? '';
|
||||
append({ kind: 'from-bot', text: `← ${scrubLoginSecret(body)}` });
|
||||
|
|
@ -1185,9 +1248,7 @@ export function App({ bootstrap, api }: Props) {
|
|||
// entry, but a manual disconnect path could leave us in connected
|
||||
// and trigger reconnect from there).
|
||||
const handle =
|
||||
state.kind === 'connected_dead' || state.kind === 'connected'
|
||||
? state.handle
|
||||
: undefined;
|
||||
state.kind === 'connected_dead' || state.kind === 'connected' ? state.handle : undefined;
|
||||
dispatch({ kind: 'request_reconnect', handle });
|
||||
try {
|
||||
await sendBare('reconnect');
|
||||
|
|
@ -1353,6 +1414,17 @@ export function App({ bootstrap, api }: Props) {
|
|||
}
|
||||
/>
|
||||
<div class="command-grid">
|
||||
{/* Open-space CTA — only against a Vojo-patched bridge that
|
||||
* emitted the VOJO-LOGIN-SPACE-V1 sentinel. Listed first so a
|
||||
* fresh post-login user sees «next step» before the Logout
|
||||
* destructive action. */}
|
||||
{state.spaceMatrixToUrl ? (
|
||||
<OpenSpaceCard
|
||||
t={t}
|
||||
matrixToUrl={state.spaceMatrixToUrl}
|
||||
onOpen={(url) => api.openMatrixToUrl(url)}
|
||||
/>
|
||||
) : null}
|
||||
<LogoutCard t={t} onConfirm={onConfirmLogout} />
|
||||
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,15 @@ const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i;
|
|||
const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1';
|
||||
const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/;
|
||||
|
||||
// Vojo-patched bridge emits this sentinel right after «Successfully logged
|
||||
// in as @user» (commands_login_space.go::sendLoginSpaceNotice). Carries the
|
||||
// matrix.to URL of the user's personal Discord space so the widget can
|
||||
// render a CTA. Same markdown-inert + structured-JSON discipline as the
|
||||
// captcha sentinel above; the bridge sends this via SendMessageEvent to
|
||||
// bypass goldmark round-trip.
|
||||
const LOGIN_SPACE_SENTINEL_PREFIX = 'VOJO-LOGIN-SPACE-V1';
|
||||
const LOGIN_SPACE_SENTINEL_RE = /^VOJO-LOGIN-SPACE-V1\s+(\{[\s\S]*\})\s*$/;
|
||||
|
||||
// Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED
|
||||
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
|
||||
// instead». Kept so a deployment running unpatched bridge still produces a
|
||||
|
|
@ -160,6 +169,28 @@ export const parseLegacyV076Body = (rawBody: string): LoginEvent => {
|
|||
}
|
||||
if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' };
|
||||
|
||||
// Vojo login-space sentinel: structured JSON with the personal Discord
|
||||
// space's matrix.to URL. Checked alongside the captcha sentinel —
|
||||
// markdown-inert prefix means it lands verbatim from the bridge, parsed
|
||||
// into a `space_ready` event for the reducer to attach to connected state.
|
||||
// Malformed payload (missing/empty `matrix_to_url`, JSON parse failure) is
|
||||
// silently dropped as `unknown` rather than surfacing a stale CTA.
|
||||
if (body.startsWith(LOGIN_SPACE_SENTINEL_PREFIX)) {
|
||||
const match = LOGIN_SPACE_SENTINEL_RE.exec(body);
|
||||
if (match) {
|
||||
try {
|
||||
const payload = JSON.parse(match[1]) as Record<string, unknown>;
|
||||
const matrixToUrl = typeof payload.matrix_to_url === 'string' ? payload.matrix_to_url : '';
|
||||
if (matrixToUrl) {
|
||||
return { kind: 'space_ready', matrixToUrl };
|
||||
}
|
||||
} catch {
|
||||
// fall through — malformed payload is treated as unknown
|
||||
}
|
||||
}
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
|
||||
const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body);
|
||||
if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() };
|
||||
|
||||
|
|
@ -247,8 +278,8 @@ export const parseEventLegacyV076 = (event: ParsableEvent): LoginEvent => {
|
|||
typeof event.redacts === 'string'
|
||||
? event.redacts
|
||||
: isObject(event.content) && typeof event.content.redacts === 'string'
|
||||
? event.content.redacts
|
||||
: undefined;
|
||||
? event.content.redacts
|
||||
: undefined;
|
||||
if (!target) return { kind: 'unknown' };
|
||||
return { kind: 'qr_redacted', redactsEventId: target };
|
||||
}
|
||||
|
|
@ -330,20 +361,11 @@ function runSanityChecks(): void {
|
|||
|
||||
// Login success (post-QR scan). No snowflake in this line; App fires
|
||||
// `ping` afterwards to pick up the discordId.
|
||||
[
|
||||
'Successfully logged in as @example',
|
||||
{ kind: 'login_success', handle: 'example' },
|
||||
],
|
||||
[
|
||||
'Successfully logged in as @user.name',
|
||||
{ kind: 'login_success', handle: 'user.name' },
|
||||
],
|
||||
['Successfully logged in as @example', { kind: 'login_success', handle: 'example' }],
|
||||
['Successfully logged in as @user.name', { kind: 'login_success', handle: 'user.name' }],
|
||||
|
||||
// Login failure paths.
|
||||
[
|
||||
'Error logging in: rate limited 429',
|
||||
{ kind: 'login_failed', reason: 'rate limited 429' },
|
||||
],
|
||||
['Error logging in: rate limited 429', { kind: 'login_failed', reason: 'rate limited 429' }],
|
||||
// CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on
|
||||
// unpatched upstream v0.7.6.
|
||||
[
|
||||
|
|
@ -387,10 +409,7 @@ function runSanityChecks(): void {
|
|||
|
||||
// Logout.
|
||||
['Logged out successfully.', { kind: 'logout_ok' }],
|
||||
[
|
||||
"You weren't logged in, but data was re-cleared just to be safe.",
|
||||
{ kind: 'logout_no_op' },
|
||||
],
|
||||
["You weren't logged in, but data was re-cleared just to be safe.", { kind: 'logout_no_op' }],
|
||||
|
||||
// Disconnect / reconnect.
|
||||
['Successfully disconnected', { kind: 'disconnect_ok' }],
|
||||
|
|
@ -521,7 +540,9 @@ function runSanityChecks(): void {
|
|||
// eslint-disable-next-line no-console
|
||||
console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected });
|
||||
throw new Error(
|
||||
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? '<none>'}`
|
||||
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${
|
||||
event.content?.msgtype ?? '<none>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,15 @@ export type LoginEvent =
|
|||
| { kind: 'reconnect_no_op' }
|
||||
| { kind: 'reconnect_failed'; reason?: string }
|
||||
|
||||
// --- Vojo: bridge-managed personal space ---------------------------------
|
||||
// Vojo-patched bridge emits the sentinel `VOJO-LOGIN-SPACE-V1 {...}` as a
|
||||
// separate m.notice right after the «Successfully logged in» line. Carries
|
||||
// a `matrix.to` URL pointing at the user's auto-created Discord space
|
||||
// (user.go::GetSpaceRoom on the bridge side). The widget surfaces this as
|
||||
// an «Open in Channels» card; click → host navigates cinny to the space.
|
||||
// See vojo-mautrix-discord/commands_login_space.go for the wire format.
|
||||
| { kind: 'space_ready'; matrixToUrl: string }
|
||||
|
||||
// --- bridge-side errors --------------------------------------------------
|
||||
// Generic «I don't know that command» — should not happen since we only
|
||||
// ship known commands, but visible if the bridge image is misconfigured
|
||||
|
|
|
|||
|
|
@ -55,13 +55,11 @@ export const EN: Record<StringKey, string> = {
|
|||
'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bot’s chat.',
|
||||
'auth-error.captcha-send-failed':
|
||||
'Could not deliver your CAPTCHA solution. Check your network and try signing in again.',
|
||||
'auth-error.captcha-expired':
|
||||
'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
|
||||
'auth-error.captcha-expired': 'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
|
||||
'auth-error.login-failed': 'Sign-in failed: {reason}',
|
||||
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}',
|
||||
'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}',
|
||||
'auth-error.connect-after-login-failed':
|
||||
'Signed in, but could not connect to Discord: {reason}',
|
||||
'auth-error.connect-after-login-failed': 'Signed in, but could not connect to Discord: {reason}',
|
||||
'auth-error.already-logged-in': 'You are already signed in to Discord — refresh status.',
|
||||
'auth-error.unknown-command':
|
||||
'The bot does not recognise this command — check the prefix in config.json.',
|
||||
|
|
@ -73,6 +71,9 @@ export const EN: Record<StringKey, string> = {
|
|||
'card.logout.confirm-prompt': 'Sign out for real?',
|
||||
'card.logout.confirm-yes': 'Sign out',
|
||||
'card.logout.confirm-no': 'Cancel',
|
||||
'card.open-space.name': 'Open in Channels',
|
||||
'card.open-space.desc': 'Jump to your Discord space with all chats and servers',
|
||||
'diag.space-ready': 'Discord space ready to open.',
|
||||
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
|
||||
'diag.ready': 'Ready to send commands.',
|
||||
'diag.checking-status': 'Checking connection status…',
|
||||
|
|
|
|||
|
|
@ -86,8 +86,7 @@ export const RU = {
|
|||
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
|
||||
'auth-error.captcha-send-failed':
|
||||
'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.',
|
||||
'auth-error.captcha-expired':
|
||||
'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
|
||||
'auth-error.captcha-expired': 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
|
||||
'auth-error.login-failed': 'Не удалось войти: {reason}',
|
||||
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
||||
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
|
||||
|
|
@ -106,7 +105,11 @@ export const RU = {
|
|||
'card.logout.confirm-prompt': 'Точно выйти?',
|
||||
'card.logout.confirm-yes': 'Выйти',
|
||||
'card.logout.confirm-no': 'Отмена',
|
||||
// --- Open Discord space (Vojo bridge sentinel) ------------------------
|
||||
'card.open-space.name': 'Открыть в Каналах',
|
||||
'card.open-space.desc': 'Перейти в спейс Discord со списком чатов и серверов',
|
||||
// --- Diagnostics in transcript ----------------------------------------
|
||||
'diag.space-ready': 'Discord-спейс готов к открытию.',
|
||||
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
|
||||
'diag.ready': 'Готов отправлять команды.',
|
||||
'diag.checking-status': 'Проверяю статус подключения…',
|
||||
|
|
|
|||
|
|
@ -104,8 +104,13 @@ export type LoginState =
|
|||
| { kind: 'reconnecting'; handle?: string }
|
||||
// Live session — ping or login_success confirmed. Discord legacy bridge
|
||||
// doesn't have a per-account loginId concept (single Discord account
|
||||
// per Matrix user), so logout doesn't need an id.
|
||||
| { kind: 'connected'; handle: string; discordId?: string }
|
||||
// per Matrix user), so logout doesn't need an id. `spaceMatrixToUrl`
|
||||
// is populated from the Vojo `VOJO-LOGIN-SPACE-V1` sentinel that lands
|
||||
// right after login_success; it survives the post-login re-ping and the
|
||||
// reconnect-ok transitions so the «Open in Channels» card stays visible
|
||||
// until logout. Absent until the sentinel arrives (and absent forever
|
||||
// against an UNPATCHED bridge — the card simply never appears).
|
||||
| { kind: 'connected'; handle: string; discordId?: string; spaceMatrixToUrl?: string }
|
||||
// ping says we have a token but the connection's down. Status pill:
|
||||
// green-ish but with a Reconnect recovery action exposed. The reducer
|
||||
// distinguishes `connection_dead` (Discord WS dropped) from `token_stored`
|
||||
|
|
@ -120,10 +125,7 @@ export type LoginState =
|
|||
// staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but
|
||||
// often valid for a couple of minutes — fresh enough to reuse). Other
|
||||
// transient states (logging_out, reconnecting) deliberately don't survive.
|
||||
export type HydrateRestoredState =
|
||||
| PendingFormState
|
||||
| CaptchaSolveState
|
||||
| { kind: 'qr_verifying' };
|
||||
export type HydrateRestoredState = PendingFormState | CaptchaSolveState | { kind: 'qr_verifying' };
|
||||
|
||||
// Outbound user actions the App dispatches. Form-submit actions clear any
|
||||
// pending lastError; structural transitions optimistically advance state —
|
||||
|
|
@ -169,9 +171,7 @@ const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiti
|
|||
const isCaptchaAcceptingState = (
|
||||
s: LoginState
|
||||
): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState =>
|
||||
s.kind === 'awaiting_qr_scan' ||
|
||||
s.kind === 'qr_verifying' ||
|
||||
s.kind === 'awaiting_captcha_solve';
|
||||
s.kind === 'awaiting_qr_scan' || s.kind === 'qr_verifying' || s.kind === 'awaiting_captcha_solve';
|
||||
|
||||
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
|
||||
if (action.kind === 'hydrate') {
|
||||
|
|
@ -266,11 +266,14 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
|||
case 'logged_in':
|
||||
// Authoritative source — accept from any state. Used by both the
|
||||
// initial ping AND the post-`login_success` re-ping that picks up
|
||||
// the discordId snowflake.
|
||||
// the discordId snowflake. Preserve `spaceMatrixToUrl` from a prior
|
||||
// `connected` so the post-login_success re-ping doesn't blank the
|
||||
// CTA before the user gets a chance to click it.
|
||||
return {
|
||||
kind: 'connected',
|
||||
handle: event.handle,
|
||||
discordId: event.discordId,
|
||||
spaceMatrixToUrl: state.kind === 'connected' ? state.spaceMatrixToUrl : undefined,
|
||||
};
|
||||
|
||||
case 'connection_dead':
|
||||
|
|
@ -492,12 +495,28 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
|||
// green with an empty handle, which the UI's
|
||||
// `state.handle ? connected-as : connected` ternary tolerates.
|
||||
// This avoids the `unknown` flap that the previous draft would
|
||||
// produce when no handle was stashed.
|
||||
// produce when no handle was stashed. spaceMatrixToUrl is not
|
||||
// restorable from connected_dead (the dead state never carried it),
|
||||
// so the CTA stays hidden until a fresh sentinel arrives — bridge
|
||||
// does NOT re-emit on reconnect, but the card returns once the user
|
||||
// explicitly re-logs in.
|
||||
if (state.kind === 'reconnecting' || state.kind === 'connected_dead') {
|
||||
return { kind: 'connected', handle: state.handle ?? '' };
|
||||
}
|
||||
return state;
|
||||
|
||||
case 'space_ready':
|
||||
// Vojo-patched bridge surfaced the personal Discord space — attach
|
||||
// its matrix.to URL to the connected state so the «Open in Channels»
|
||||
// card renders. Late-arriving sentinels from an abandoned flow drop
|
||||
// here silently (e.g. a sentinel that lands during `logging_out`
|
||||
// mustn't resurrect a connected state). Honour only from the
|
||||
// canonical alive states.
|
||||
if (state.kind === 'connected') {
|
||||
return { ...state, spaceMatrixToUrl: event.matrixToUrl };
|
||||
}
|
||||
return state;
|
||||
|
||||
case 'reconnect_failed':
|
||||
if (state.kind !== 'reconnecting') return state;
|
||||
// Roll back to connected_dead carrying the previous handle. The
|
||||
|
|
@ -565,10 +584,7 @@ type HydrateAccumulator = {
|
|||
terminated: boolean;
|
||||
};
|
||||
|
||||
const stepHydrate = (
|
||||
prevAcc: HydrateAccumulator,
|
||||
input: HydrateInput
|
||||
): HydrateAccumulator => {
|
||||
const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateAccumulator => {
|
||||
const { ev, ts } = input;
|
||||
|
||||
// After a terminal event we normally stop — except if a fresh
|
||||
|
|
@ -693,9 +709,12 @@ const stepHydrate = (
|
|||
|
||||
case 'already_logged_in':
|
||||
case 'unknown':
|
||||
case 'space_ready':
|
||||
// Soft no-op for hydrate. already_logged_in is a live-flow warning
|
||||
// that doesn't reflect persistent state; unknown is a wording-drift
|
||||
// catch-all.
|
||||
// catch-all; space_ready is a post-terminal sentinel — hydrate
|
||||
// terminates on login_success and lets live ping reconcile, so
|
||||
// the URL gets attached on the live path, not here.
|
||||
return acc;
|
||||
|
||||
default: {
|
||||
|
|
|
|||
|
|
@ -125,6 +125,27 @@ export class WidgetApi {
|
|||
);
|
||||
}
|
||||
|
||||
// Ask the host to navigate to a matrix.to URL inside the cinny app
|
||||
// (room or space). Same side-channel pattern as `openExternalUrl` —
|
||||
// distinct from matrix-widget-api's `fromWidget` so the SDK stays
|
||||
// ignorant of this Vojo extension. The host validates the URL via
|
||||
// `parseMatrixToRoom` (rejecting non-room URLs, javascript:/data:, etc.)
|
||||
// BEFORE routing into the react-router; sending anything that isn't a
|
||||
// matrix.to/#/!roomId or matrix.to/#/#alias URL silently no-ops on the
|
||||
// host side. The widget is responsible for only invoking this when it
|
||||
// genuinely has a matrix.to room URL (e.g. parsed from a bridge
|
||||
// sentinel).
|
||||
public openMatrixToUrl(url: string): void {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
api: 'io.vojo.bot-widget',
|
||||
action: 'open-matrix-to',
|
||||
data: { url },
|
||||
},
|
||||
this.bootstrap.parentOrigin
|
||||
);
|
||||
}
|
||||
|
||||
// Always prefix outbound commands with `<commandPrefix> ` (trailing space).
|
||||
// Legacy mautrix-discord routes management-room commands through the
|
||||
// bridge.commands.Processor in mautrix/go bridge/commands; outside the
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from 'matrix-widget-api';
|
||||
import { Theme } from '../../hooks/useTheme';
|
||||
import { openExternalUrl } from '../../utils/capacitor';
|
||||
import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to';
|
||||
import type { BotPreset } from './catalog';
|
||||
import {
|
||||
BotWidgetDriver,
|
||||
|
|
@ -34,6 +35,14 @@ export type BotWidgetEmbedOptions = {
|
|||
language: string;
|
||||
onError: (error: Error) => void;
|
||||
onReady?: () => void;
|
||||
// Optional generic «navigate cinny to a matrix.to room/alias» callback.
|
||||
// Plumbed from `BotWidgetMount` where react-router's `useNavigate` is
|
||||
// available. The embed validates the URL via `parseMatrixToRoom` BEFORE
|
||||
// calling — handler receives an already-parsed `{roomIdOrAlias, viaServers}`
|
||||
// and is free to assume the inputs are well-formed Matrix references. Not
|
||||
// bot-aware: any widget that delivers a matrix.to URL via the side-channel
|
||||
// (`open-matrix-to` action) reaches the same handler.
|
||||
onOpenMatrixToRoom?: (target: MatrixToRoom) => void;
|
||||
};
|
||||
|
||||
const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
|
||||
|
|
@ -214,22 +223,30 @@ export class BotWidgetEmbed {
|
|||
this.feedStateUpdate(ev);
|
||||
};
|
||||
|
||||
// Side-channel postMessage handler for the widget's `openExternalUrl`
|
||||
// call. Distinct from matrix-widget-api's `fromWidget` channel
|
||||
// Side-channel postMessage handler for the widget's Vojo-extension
|
||||
// actions. Distinct from matrix-widget-api's `fromWidget` channel
|
||||
// (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it
|
||||
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
|
||||
// of our extension and avoids the «unknown action» reply path.
|
||||
//
|
||||
// Why this exists: the host's global `setupExternalLinkHandler`
|
||||
// (utils/capacitor.ts) intercepts `<a target="_blank">` clicks at
|
||||
// the host document level and routes them via Capacitor's Browser
|
||||
// plugin. But cross-origin iframes don't bubble click events into
|
||||
// the parent document, so widget-side links are invisible to it —
|
||||
// on Capacitor's Android WebView those clicks silently disappear.
|
||||
// The widget posts this message; we validate the URL and forward
|
||||
// to the same `openExternalUrl` helper the host uses elsewhere.
|
||||
// Two actions today:
|
||||
//
|
||||
// Security gates (defence in depth):
|
||||
// * `open-external-url` — forwards an https:// URL to the host's
|
||||
// `openExternalUrl` (utils/capacitor.ts), which routes through
|
||||
// Capacitor's Browser plugin on native and `window.open` on web.
|
||||
// Exists because cross-origin iframes don't bubble click events
|
||||
// to the host document, so the global `setupExternalLinkHandler`
|
||||
// never sees widget-side `<a target="_blank">` clicks — on
|
||||
// Capacitor's Android WebView those would silently disappear.
|
||||
//
|
||||
// * `open-matrix-to` — generic «navigate cinny to a matrix.to room
|
||||
// or alias». Validates the URL through the same `parseMatrixToRoom`
|
||||
// cinny uses for in-app mention rendering, then hands the parsed
|
||||
// `MatrixToRoom` to `options.onOpenMatrixToRoom` (composed by
|
||||
// BotWidgetMount with `useNavigate` + `getChannelsSpacePath`). The
|
||||
// widget never sees a route — it only knows matrix.to URLs.
|
||||
//
|
||||
// Security gates (defence in depth, apply to BOTH actions):
|
||||
// 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this
|
||||
// check, a compromised widget bundle could `window.location.href
|
||||
// = 'https://attacker.example/'` — the browser keeps the same
|
||||
|
|
@ -242,11 +259,17 @@ export class BotWidgetEmbed {
|
|||
// iframe of the SAME origin — e.g. an ad embed loaded into a
|
||||
// sibling frame on the same origin in a future deployment —
|
||||
// could otherwise pass the origin check).
|
||||
// 3. Only https URLs are honoured. We tightened from http+https to
|
||||
// https-only because no shipped widget content links over plain
|
||||
// http; rejecting http closes a cleartext-redirect vector via
|
||||
// Capacitor `Browser.open` on Android.
|
||||
// 4. javascript:, data:, file:, etc. are implicitly rejected by (3).
|
||||
//
|
||||
// Per-action URL validation (NOT shared, but each branch enforces):
|
||||
// * `open-external-url` — requires `https:` protocol, rejecting plain
|
||||
// http, javascript:, data:, file:, etc. We tightened from http+https
|
||||
// to https-only because no shipped widget content links over plain
|
||||
// http; rejecting http closes a cleartext-redirect vector via
|
||||
// Capacitor `Browser.open` on Android.
|
||||
// * `open-matrix-to` — requires the URL to parse as a matrix.to room
|
||||
// or alias via `parseMatrixToRoom`. Anything else (matrix.to user
|
||||
// links, event links, arbitrary https URLs, javascript:/data:/file:
|
||||
// pseudo-schemes) returns undefined and silently no-ops.
|
||||
private readonly onWidgetMessage = (ev: MessageEvent) => {
|
||||
if (ev.origin !== this.widgetOrigin) return;
|
||||
if (ev.source !== this.iframe.contentWindow) return;
|
||||
|
|
@ -255,18 +278,38 @@ export class BotWidgetEmbed {
|
|||
| undefined;
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
if (msg.api !== 'io.vojo.bot-widget') return;
|
||||
if (msg.action !== 'open-external-url') return;
|
||||
const url = msg.data?.url;
|
||||
if (typeof url !== 'string') return;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:') return;
|
||||
} catch {
|
||||
|
||||
if (msg.action === 'open-external-url') {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:') return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
openExternalUrl(url).catch(() => {
|
||||
/* fire-and-forget: log handled inside openExternalUrl */
|
||||
});
|
||||
return;
|
||||
}
|
||||
openExternalUrl(url).catch(() => {
|
||||
/* fire-and-forget: log handled inside openExternalUrl */
|
||||
});
|
||||
|
||||
if (msg.action === 'open-matrix-to') {
|
||||
// Generic «navigate cinny to a matrix.to room/alias». Not bot-aware —
|
||||
// the widget hands over a matrix.to URL it obtained however (parsed
|
||||
// from a bridge sentinel, scraped from chat, whatever), and we
|
||||
// validate via the same `parseMatrixToRoom` cinny uses for in-app
|
||||
// mention rendering (plugins/react-custom-html-parser.tsx). Only the
|
||||
// matrix.to/#/!roomId and matrix.to/#/#alias shapes pass — user
|
||||
// links, event links, non-matrix.to URLs, javascript:/data:/etc. all
|
||||
// return undefined and silently no-op here. The host-side router
|
||||
// hop (`onOpenMatrixToRoom`) is the optional caller — embedded code
|
||||
// paths that don't provide a callback (e.g. future test harness) get
|
||||
// a silent drop, not a crash.
|
||||
const parsed = parseMatrixToRoom(url);
|
||||
if (!parsed) return;
|
||||
this.options.onOpenMatrixToRoom?.(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
public constructor(private readonly options: BotWidgetEmbedOptions) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Room, SyncState } from 'matrix-js-sdk';
|
||||
import type { BotPreset } from './catalog';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useSyncState } from '../../hooks/useSyncState';
|
||||
import {
|
||||
getCanonicalAliasOrRoomId,
|
||||
getCanonicalAliasRoomId,
|
||||
isRoomAlias,
|
||||
} from '../../utils/matrix';
|
||||
import { getChannelsSpacePath } from '../../pages/pathUtils';
|
||||
import type { MatrixToRoom } from '../../plugins/matrix-to';
|
||||
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
|
||||
import * as css from './BotWidgetMount.css';
|
||||
|
||||
|
|
@ -34,15 +42,46 @@ type BotWidgetMountProps = {
|
|||
|
||||
export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { ready } = useBotWidgetEmbed({ containerRef, preset, room, onError });
|
||||
const navigate = useNavigate();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
// Generic «navigate cinny to a matrix.to room/alias». Bot-agnostic: any
|
||||
// widget that posts `{action: 'open-matrix-to', data: {url}}` on the
|
||||
// `io.vojo.bot-widget` side-channel reaches this. The embed has already
|
||||
// validated the URL via `parseMatrixToRoom` so `target` is well-formed.
|
||||
// For an alias we resolve to the canonical room id first — the channels
|
||||
// path expects an id-or-alias either way, but joined-room lookup needs
|
||||
// the id form for the via-server hint to be effective. `viaServers` are
|
||||
// currently dropped (the channels view doesn't propagate them); add a
|
||||
// dedicated «join-via» path if a future widget needs to surface a room
|
||||
// the user hasn't joined yet.
|
||||
const handleOpenMatrixToRoom = useCallback(
|
||||
(target: MatrixToRoom) => {
|
||||
const { roomIdOrAlias } = target;
|
||||
const idOrAlias = isRoomAlias(roomIdOrAlias)
|
||||
? getCanonicalAliasRoomId(mx, roomIdOrAlias) ?? roomIdOrAlias
|
||||
: roomIdOrAlias;
|
||||
const canonical = getCanonicalAliasOrRoomId(mx, idOrAlias);
|
||||
navigate(getChannelsSpacePath(canonical));
|
||||
},
|
||||
[mx, navigate]
|
||||
);
|
||||
|
||||
const { ready } = useBotWidgetEmbed({
|
||||
containerRef,
|
||||
preset,
|
||||
room,
|
||||
onError,
|
||||
onOpenMatrixToRoom: handleOpenMatrixToRoom,
|
||||
});
|
||||
|
||||
// Track Matrix sync state so the bot loading bar yields to the global
|
||||
// SyncIndicator when the connection is unhealthy. Without this, on a
|
||||
// dropped network the user would see TWO sweeping bars at once — the
|
||||
// bot bar at top stuck in «still loading» plus the SyncIndicator at
|
||||
// bottom in transient/error state. The bottom bar is the canonical
|
||||
// connection-state surface; the top one defers.
|
||||
const mx = useMatrixClient();
|
||||
// connection-state surface; the top one defers. Reuses `mx` from the
|
||||
// navigate-callback block above — single hook call per render.
|
||||
const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState());
|
||||
useSyncState(
|
||||
mx,
|
||||
|
|
@ -106,10 +145,7 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
|
|||
// SyncIndicator can take over without two bars overlapping.
|
||||
// Reduced-motion: animation is off (no iterations ever land), so
|
||||
// parking a static stripe for ~2s isn't graceful, just stuck.
|
||||
if (
|
||||
hideReason === 'sync' ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
) {
|
||||
if (hideReason === 'sync' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setVisible(false);
|
||||
setPendingHide(false);
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Room } from 'matrix-js-sdk';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { Theme, useTheme } from '../../hooks/useTheme';
|
||||
import type { MatrixToRoom } from '../../plugins/matrix-to';
|
||||
import type { BotPreset } from './catalog';
|
||||
import { BotWidgetEmbed } from './BotWidgetEmbed';
|
||||
|
||||
|
|
@ -11,6 +12,9 @@ type UseBotWidgetEmbedOptions = {
|
|||
preset: BotPreset;
|
||||
room: Room;
|
||||
onError: () => void;
|
||||
// Forwarded into the embed. Plumbed from `BotWidgetMount` where the
|
||||
// react-router context is available — the hook stays unaware of routing.
|
||||
onOpenMatrixToRoom?: (target: MatrixToRoom) => void;
|
||||
};
|
||||
|
||||
type UseBotWidgetEmbedResult = {
|
||||
|
|
@ -30,6 +34,7 @@ export const useBotWidgetEmbed = ({
|
|||
preset,
|
||||
room,
|
||||
onError,
|
||||
onOpenMatrixToRoom,
|
||||
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
|
||||
const { i18n } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
|
|
@ -43,6 +48,12 @@ export const useBotWidgetEmbed = ({
|
|||
themeRef.current = theme;
|
||||
const languageRef = useRef<string>(i18n.language);
|
||||
languageRef.current = i18n.language;
|
||||
// Same indirection for `onOpenMatrixToRoom`: the callback identity
|
||||
// typically changes per render (closes over `navigate`/`mx`), and we do
|
||||
// NOT want that to remount the embed. The ref carries the latest fn; the
|
||||
// embed only sees a stable shim that re-reads it.
|
||||
const onOpenMatrixToRoomRef = useRef(onOpenMatrixToRoom);
|
||||
onOpenMatrixToRoomRef.current = onOpenMatrixToRoom;
|
||||
|
||||
// Depend on primitive identity for the embed lifecycle — using `preset`
|
||||
// directly would remount the iframe (and re-handshake with the widget)
|
||||
|
|
@ -72,6 +83,9 @@ export const useBotWidgetEmbed = ({
|
|||
language: languageRef.current,
|
||||
onError,
|
||||
onReady: () => setReady(true),
|
||||
// Indirection so the embed lifecycle doesn't reset when the
|
||||
// navigate-callback closes over a new render's `mx`/`navigate`.
|
||||
onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target),
|
||||
});
|
||||
embedRef.current = embed;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
37
src/app/state/activeChannelsSpace.ts
Normal file
37
src/app/state/activeChannelsSpace.ts
Normal 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
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue