diff --git a/apps/widget-discord/src/main.tsx b/apps/widget-discord/src/main.tsx index 40e2ff54..2a49583b 100644 --- a/apps/widget-discord/src/main.tsx +++ b/apps/widget-discord/src/main.tsx @@ -3,6 +3,7 @@ import { readBootstrap } from './bootstrap'; import { App } from './App'; import { createT } from './i18n'; import { WidgetApi, buildCapabilities } from './widget-api'; +import { installSwipeForwarder } from './swipe-forward'; import './styles.css'; // Input-mode detector — see apps/widget-telegram/src/main.tsx for the @@ -58,5 +59,8 @@ if (!result.ok) { // with the cached-bundle remount path. See widget-telegram for full // rationale. const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId)); + // Forward the raw touch stream to the host so its swipe-back + // gesture works over this iframe — see swipe-forward.ts. + installSwipeForwarder(result.bootstrap.parentOrigin); render(, root); } diff --git a/apps/widget-discord/src/swipe-forward.ts b/apps/widget-discord/src/swipe-forward.ts new file mode 100644 index 00000000..11f4b344 --- /dev/null +++ b/apps/widget-discord/src/swipe-forward.ts @@ -0,0 +1,121 @@ +// Forwards the widget's raw touch stream to the Vojo host so the +// swipe-back-from-widget gesture works across the iframe boundary. An +// iframe is a separate browsing context — touches inside it NEVER bubble +// to the host document, so without this the host's interactive-pop +// gesture (src/app/components/swipe-back) is dead over the widget body. +// +// Protocol: `{ api: 'io.vojo.bot-widget', action: 'swipe-touch', +// data: { phase, x, y } }` posted to the parent with the pinned +// `parentOrigin` (same side-channel + origin discipline as +// `open-external-url` in widget-api.ts). Coordinates are IFRAME-local +// clientX/Y; the host offsets them by the iframe's viewport rect. +// +// The host owns the real gesture state machine (dead-zone axis resolve, +// edge guard, distance commit). The ONLY logic duplicated here is the +// axis resolution needed to call preventDefault locally — the host +// cannot cancel this document's scroll, so once a single-finger drag +// resolves as horizontal-rightward we must suppress our own default +// handling or the widget's vertical scroll would fight the card slide. +// Thresholds mirror the host's swipe-back/geometry.ts: keep in sync. +const DEAD_ZONE_PX = 12; + +type Phase = 'start' | 'move' | 'end' | 'cancel'; + +export function installSwipeForwarder(parentOrigin: string): void { + const post = (phase: Phase, x: number, y: number): void => { + window.parent.postMessage( + { api: 'io.vojo.bot-widget', action: 'swipe-touch', data: { phase, x, y } }, + parentOrigin + ); + }; + + let tracking = false; + let bailed = false; + let engaged = false; + let startX = 0; + let startY = 0; + + const cancel = (x: number, y: number): void => { + if (tracking && !bailed) post('cancel', x, y); + tracking = false; + bailed = true; + }; + + document.addEventListener( + 'touchstart', + (e) => { + if (e.touches.length !== 1) { + cancel(0, 0); + return; + } + const t = e.touches[0]; + tracking = true; + bailed = false; + engaged = false; + startX = t.clientX; + startY = t.clientY; + post('start', t.clientX, t.clientY); + }, + { passive: true } + ); + + document.addEventListener( + 'touchmove', + (e) => { + if (!tracking || bailed) return; + if (e.touches.length !== 1) { + const t = e.touches[0]; + cancel(t.clientX, t.clientY); + return; + } + const t = e.touches[0]; + if (!engaged) { + const dx = t.clientX - startX; + const dy = t.clientY - startY; + if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) { + // Still inside the dead-zone — keep feeding the host (its own + // machine waits the same way) but make no local decision yet. + post('move', t.clientX, t.clientY); + return; + } + // Vertical-dominant or leftward: the gesture is the widget's own + // (scroll / horizontal UI). Stop forwarding — the host's machine + // bails identically from the same data; the cancel is belt and + // braces against threshold drift. + if (Math.abs(dy) >= Math.abs(dx) || dx <= 0) { + cancel(t.clientX, t.clientY); + return; + } + engaged = true; + } + // Horizontal-rightward drag — the host owns it now. Suppress the + // widget's own scroll for the rest of the touch. + if (e.cancelable) e.preventDefault(); + post('move', t.clientX, t.clientY); + }, + { passive: false } + ); + + document.addEventListener( + 'touchend', + (e) => { + if (!tracking || bailed) { + tracking = false; + return; + } + tracking = false; + const t = e.changedTouches[0]; + post('end', t?.clientX ?? startX, t?.clientY ?? startY); + }, + { passive: true } + ); + + document.addEventListener( + 'touchcancel', + (e) => { + const t = e.changedTouches[0]; + cancel(t?.clientX ?? startX, t?.clientY ?? startY); + }, + { passive: true } + ); +} diff --git a/apps/widget-telegram/src/main.tsx b/apps/widget-telegram/src/main.tsx index 02e999e5..8923b2f7 100644 --- a/apps/widget-telegram/src/main.tsx +++ b/apps/widget-telegram/src/main.tsx @@ -3,6 +3,7 @@ import { readBootstrap } from './bootstrap'; import { App } from './App'; import { createT } from './i18n'; import { WidgetApi } from './widget-api'; +import { installSwipeForwarder } from './swipe-forward'; import './styles.css'; // Input-mode detector for hover styling. CSS gates `:hover` and @@ -73,5 +74,8 @@ if (!result.ok) { // cached-bundle remount the request can race ahead of any useEffect — // construction at module-load closes that window. const api = new WidgetApi(result.bootstrap); + // Forward the raw touch stream to the host so its swipe-back + // gesture works over this iframe — see swipe-forward.ts. + installSwipeForwarder(result.bootstrap.parentOrigin); render(, root); } diff --git a/apps/widget-telegram/src/swipe-forward.ts b/apps/widget-telegram/src/swipe-forward.ts new file mode 100644 index 00000000..11f4b344 --- /dev/null +++ b/apps/widget-telegram/src/swipe-forward.ts @@ -0,0 +1,121 @@ +// Forwards the widget's raw touch stream to the Vojo host so the +// swipe-back-from-widget gesture works across the iframe boundary. An +// iframe is a separate browsing context — touches inside it NEVER bubble +// to the host document, so without this the host's interactive-pop +// gesture (src/app/components/swipe-back) is dead over the widget body. +// +// Protocol: `{ api: 'io.vojo.bot-widget', action: 'swipe-touch', +// data: { phase, x, y } }` posted to the parent with the pinned +// `parentOrigin` (same side-channel + origin discipline as +// `open-external-url` in widget-api.ts). Coordinates are IFRAME-local +// clientX/Y; the host offsets them by the iframe's viewport rect. +// +// The host owns the real gesture state machine (dead-zone axis resolve, +// edge guard, distance commit). The ONLY logic duplicated here is the +// axis resolution needed to call preventDefault locally — the host +// cannot cancel this document's scroll, so once a single-finger drag +// resolves as horizontal-rightward we must suppress our own default +// handling or the widget's vertical scroll would fight the card slide. +// Thresholds mirror the host's swipe-back/geometry.ts: keep in sync. +const DEAD_ZONE_PX = 12; + +type Phase = 'start' | 'move' | 'end' | 'cancel'; + +export function installSwipeForwarder(parentOrigin: string): void { + const post = (phase: Phase, x: number, y: number): void => { + window.parent.postMessage( + { api: 'io.vojo.bot-widget', action: 'swipe-touch', data: { phase, x, y } }, + parentOrigin + ); + }; + + let tracking = false; + let bailed = false; + let engaged = false; + let startX = 0; + let startY = 0; + + const cancel = (x: number, y: number): void => { + if (tracking && !bailed) post('cancel', x, y); + tracking = false; + bailed = true; + }; + + document.addEventListener( + 'touchstart', + (e) => { + if (e.touches.length !== 1) { + cancel(0, 0); + return; + } + const t = e.touches[0]; + tracking = true; + bailed = false; + engaged = false; + startX = t.clientX; + startY = t.clientY; + post('start', t.clientX, t.clientY); + }, + { passive: true } + ); + + document.addEventListener( + 'touchmove', + (e) => { + if (!tracking || bailed) return; + if (e.touches.length !== 1) { + const t = e.touches[0]; + cancel(t.clientX, t.clientY); + return; + } + const t = e.touches[0]; + if (!engaged) { + const dx = t.clientX - startX; + const dy = t.clientY - startY; + if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) { + // Still inside the dead-zone — keep feeding the host (its own + // machine waits the same way) but make no local decision yet. + post('move', t.clientX, t.clientY); + return; + } + // Vertical-dominant or leftward: the gesture is the widget's own + // (scroll / horizontal UI). Stop forwarding — the host's machine + // bails identically from the same data; the cancel is belt and + // braces against threshold drift. + if (Math.abs(dy) >= Math.abs(dx) || dx <= 0) { + cancel(t.clientX, t.clientY); + return; + } + engaged = true; + } + // Horizontal-rightward drag — the host owns it now. Suppress the + // widget's own scroll for the rest of the touch. + if (e.cancelable) e.preventDefault(); + post('move', t.clientX, t.clientY); + }, + { passive: false } + ); + + document.addEventListener( + 'touchend', + (e) => { + if (!tracking || bailed) { + tracking = false; + return; + } + tracking = false; + const t = e.changedTouches[0]; + post('end', t?.clientX ?? startX, t?.clientY ?? startY); + }, + { passive: true } + ); + + document.addEventListener( + 'touchcancel', + (e) => { + const t = e.changedTouches[0]; + cancel(t?.clientX ?? startX, t?.clientY ?? startY); + }, + { passive: true } + ); +} diff --git a/apps/widget-whatsapp/src/main.tsx b/apps/widget-whatsapp/src/main.tsx index 35dbac1d..73c72866 100644 --- a/apps/widget-whatsapp/src/main.tsx +++ b/apps/widget-whatsapp/src/main.tsx @@ -3,6 +3,7 @@ import { readBootstrap } from './bootstrap'; import { App } from './App'; import { createT } from './i18n'; import { WidgetApi } from './widget-api'; +import { installSwipeForwarder } from './swipe-forward'; import './styles.css'; // Input-mode detector for hover styling. CSS gates `:hover` and @@ -73,5 +74,8 @@ if (!result.ok) { // cached-bundle remount the request can race ahead of any useEffect — // construction at module-load closes that window. const api = new WidgetApi(result.bootstrap); + // Forward the raw touch stream to the host so its swipe-back + // gesture works over this iframe — see swipe-forward.ts. + installSwipeForwarder(result.bootstrap.parentOrigin); render(, root); } diff --git a/apps/widget-whatsapp/src/swipe-forward.ts b/apps/widget-whatsapp/src/swipe-forward.ts new file mode 100644 index 00000000..11f4b344 --- /dev/null +++ b/apps/widget-whatsapp/src/swipe-forward.ts @@ -0,0 +1,121 @@ +// Forwards the widget's raw touch stream to the Vojo host so the +// swipe-back-from-widget gesture works across the iframe boundary. An +// iframe is a separate browsing context — touches inside it NEVER bubble +// to the host document, so without this the host's interactive-pop +// gesture (src/app/components/swipe-back) is dead over the widget body. +// +// Protocol: `{ api: 'io.vojo.bot-widget', action: 'swipe-touch', +// data: { phase, x, y } }` posted to the parent with the pinned +// `parentOrigin` (same side-channel + origin discipline as +// `open-external-url` in widget-api.ts). Coordinates are IFRAME-local +// clientX/Y; the host offsets them by the iframe's viewport rect. +// +// The host owns the real gesture state machine (dead-zone axis resolve, +// edge guard, distance commit). The ONLY logic duplicated here is the +// axis resolution needed to call preventDefault locally — the host +// cannot cancel this document's scroll, so once a single-finger drag +// resolves as horizontal-rightward we must suppress our own default +// handling or the widget's vertical scroll would fight the card slide. +// Thresholds mirror the host's swipe-back/geometry.ts: keep in sync. +const DEAD_ZONE_PX = 12; + +type Phase = 'start' | 'move' | 'end' | 'cancel'; + +export function installSwipeForwarder(parentOrigin: string): void { + const post = (phase: Phase, x: number, y: number): void => { + window.parent.postMessage( + { api: 'io.vojo.bot-widget', action: 'swipe-touch', data: { phase, x, y } }, + parentOrigin + ); + }; + + let tracking = false; + let bailed = false; + let engaged = false; + let startX = 0; + let startY = 0; + + const cancel = (x: number, y: number): void => { + if (tracking && !bailed) post('cancel', x, y); + tracking = false; + bailed = true; + }; + + document.addEventListener( + 'touchstart', + (e) => { + if (e.touches.length !== 1) { + cancel(0, 0); + return; + } + const t = e.touches[0]; + tracking = true; + bailed = false; + engaged = false; + startX = t.clientX; + startY = t.clientY; + post('start', t.clientX, t.clientY); + }, + { passive: true } + ); + + document.addEventListener( + 'touchmove', + (e) => { + if (!tracking || bailed) return; + if (e.touches.length !== 1) { + const t = e.touches[0]; + cancel(t.clientX, t.clientY); + return; + } + const t = e.touches[0]; + if (!engaged) { + const dx = t.clientX - startX; + const dy = t.clientY - startY; + if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) { + // Still inside the dead-zone — keep feeding the host (its own + // machine waits the same way) but make no local decision yet. + post('move', t.clientX, t.clientY); + return; + } + // Vertical-dominant or leftward: the gesture is the widget's own + // (scroll / horizontal UI). Stop forwarding — the host's machine + // bails identically from the same data; the cancel is belt and + // braces against threshold drift. + if (Math.abs(dy) >= Math.abs(dx) || dx <= 0) { + cancel(t.clientX, t.clientY); + return; + } + engaged = true; + } + // Horizontal-rightward drag — the host owns it now. Suppress the + // widget's own scroll for the rest of the touch. + if (e.cancelable) e.preventDefault(); + post('move', t.clientX, t.clientY); + }, + { passive: false } + ); + + document.addEventListener( + 'touchend', + (e) => { + if (!tracking || bailed) { + tracking = false; + return; + } + tracking = false; + const t = e.changedTouches[0]; + post('end', t?.clientX ?? startX, t?.clientY ?? startY); + }, + { passive: true } + ); + + document.addEventListener( + 'touchcancel', + (e) => { + const t = e.changedTouches[0]; + cancel(t?.clientX ?? startX, t?.clientY ?? startY); + }, + { passive: true } + ); +} diff --git a/src/app/components/swipe-back/externalSwipeFeed.ts b/src/app/components/swipe-back/externalSwipeFeed.ts new file mode 100644 index 00000000..8d6991ed --- /dev/null +++ b/src/app/components/swipe-back/externalSwipeFeed.ts @@ -0,0 +1,38 @@ +// Cross-boundary input feed for the swipe-back gesture. An iframe is a +// separate browsing context — touches inside it NEVER reach the host's +// listeners, so the swipe-back-from-widget gesture was dead over the +// bridge-bot widgets. The widget apps (apps/widget-*) forward their raw +// touch stream over the validated `io.vojo.bot-widget` side-channel +// (`swipe-touch` action); `BotWidgetMount` maps the coordinates into host +// viewport space and emits them here; `useSwipeBackGesture` subscribes and +// routes them through the SAME state machine as native touches (dead-zone +// axis resolve, edge guard, distance-commit). +// +// Module-level pub-sub (not context): the emitter (BotWidgetMount, inside +// the routed chat) and the consumer (SwipeBackOverlay, the router-level +// card) live in distant subtrees, and the feed is transient input — no +// render state, nothing to persist. + +export type ExternalSwipePhase = 'start' | 'move' | 'end' | 'cancel'; + +export type ExternalSwipeTouch = { + phase: ExternalSwipePhase; + // Host-viewport coordinates (the emitter translates iframe-local ones). + x: number; + y: number; +}; + +type Listener = (touch: ExternalSwipeTouch) => void; + +const listeners = new Set(); + +export const subscribeExternalSwipe = (listener: Listener): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const emitExternalSwipe = (touch: ExternalSwipeTouch): void => { + listeners.forEach((listener) => listener(touch)); +}; diff --git a/src/app/components/swipe-back/useSwipeBackGesture.ts b/src/app/components/swipe-back/useSwipeBackGesture.ts index c79812d0..0fea59b2 100644 --- a/src/app/components/swipe-back/useSwipeBackGesture.ts +++ b/src/app/components/swipe-back/useSwipeBackGesture.ts @@ -1,5 +1,6 @@ import { MutableRefObject, useEffect, useRef } from 'react'; import { COMMIT_FRACTION, DEAD_ZONE_PX, EDGE_GUARD_PX, MIN_COMMIT_PX } from './geometry'; +import { subscribeExternalSwipe } from './externalSwipeFeed'; type Args = { // The sliding chat CARD element the listeners bind to. Touches outside it @@ -23,12 +24,21 @@ type Args = { }; // Rightward "swipe-to-go-back" (interactive pop) driver. Mirrors -// `useMobileTabsPagerGesture`: single listener on the card root, refs for -// live state, axis-resolve in the dead-zone, distance threshold-commit on -// release with snap-back below it. Differences: rightward-only (back, never -// forward — leftward clamps at 0). The card is `touch-action: pan-y`, so the -// browser reserves horizontal panning for us and our moves stay cancelable -// over the whole screen. +// `useMobileTabsPagerGesture`: refs for live state, axis-resolve in the +// dead-zone, distance threshold-commit on release with snap-back below it. +// Rightward-only (back, never forward — leftward clamps at 0). The card is +// `touch-action: pan-y`, so the browser reserves horizontal panning for us +// and our moves stay cancelable over the whole screen. +// +// TWO input sources feed ONE state machine (begin/move/end/cancel below): +// 1. Native touch listeners on the card root — every React-rendered +// surface (chats, AI chat, hero headers). +// 2. The external swipe feed (`externalSwipeFeed.ts`) — touches forwarded +// by the bridge-bot widget iframes over the validated side-channel, +// already mapped into host-viewport coordinates by `BotWidgetMount`. +// An iframe consumes its touches natively, so without this feed the +// gesture is dead over the widget body. External moves carry no +// cancelable browser event — the widget side owns preventDefault. export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBack }: Args): void { const disabledRef = useRef(disabled); const setDragRef = useRef(setDrag); @@ -66,37 +76,42 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac reset(); }; - const onTouchStart = (e: TouchEvent) => { - if (disabledRef.current || e.touches.length !== 1) { - springHome(); - return; - } - const t = e.touches[0]; + // ── State machine (shared by both input sources) ────────────────── + + const begin = (x: number, y: number) => { + // Heal any orphaned offset FIRST: unlike native touches, the external + // feed has no guaranteed terminal event — an iframe dying mid-drag + // (widget crash → destroy() drops the message listener) never posts + // 'end', which would otherwise leave the card frozen half-slid with + // no recovery (a plain tap's end() sees clean state and no-ops). + // springHome is a no-op after a cleanly terminated gesture. + springHome(); + if (disabledRef.current) return; const vw = window.innerWidth; // The L/R edge strip belongs to the Android system back-gesture in // edge-to-edge mode. Ours shares its direction, so we MUST cede it — // a drag must start at least EDGE_GUARD_PX inside the left edge. - if (t.clientX < EDGE_GUARD_PX || t.clientX > vw - EDGE_GUARD_PX) { - springHome(); - return; - } - startX = t.clientX; - startY = t.clientY; + if (x < EDGE_GUARD_PX || x > vw - EDGE_GUARD_PX) return; + startX = x; + startY = y; engaged = false; bailed = false; lastDragPx = 0; }; - const onTouchMove = (e: TouchEvent) => { - if (e.touches.length !== 1 || disabledRef.current) { + // `preventDefault` is the source-specific cancelation hook: native + // touches pass the cancelable TouchEvent's preventDefault; the external + // feed passes undefined (the widget side already prevented its own + // default once it resolved the gesture as ours). + const move = (x: number, y: number, preventDefault?: () => void) => { + if (disabledRef.current) { springHome(); bailed = true; return; } if (startX === null || startY === null || bailed) return; - const t = e.touches[0]; - const dx = t.clientX - startX; - const dy = t.clientY - startY; + const dx = x - startX; + const dy = y - startY; if (!engaged) { if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) return; @@ -119,7 +134,7 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac // screen (including over the vertical-scrolling timeline), so the // gesture engages everywhere, not just over non-scrollable spots. The // guard is defensive only. - if (e.cancelable) e.preventDefault(); + preventDefault?.(); const vw = window.innerWidth; // Follow the finger 1:1 to the right; clamp to [0, vw]. Never negative: @@ -130,7 +145,7 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac setDragRef.current(drag, true); }; - const onTouchEnd = () => { + const end = () => { if (!engaged || disabledRef.current) { springHome(); return; @@ -149,20 +164,57 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac } }; - const onTouchCancel = () => { + const cancel = () => { // System cancel (incoming call, scroll take-over, …) never commits. springHome(); }; + // ── Source 1: native touches on the card root ───────────────────── + + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length !== 1) { + springHome(); + return; + } + const t = e.touches[0]; + begin(t.clientX, t.clientY); + }; + + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length !== 1) { + springHome(); + bailed = true; + return; + } + const t = e.touches[0]; + move(t.clientX, t.clientY, () => { + if (e.cancelable) e.preventDefault(); + }); + }; + + const onTouchEnd = () => end(); + const onTouchCancel = () => cancel(); + root.addEventListener('touchstart', onTouchStart, { passive: true }); root.addEventListener('touchmove', onTouchMove, { passive: false }); root.addEventListener('touchend', onTouchEnd, { passive: true }); root.addEventListener('touchcancel', onTouchCancel, { passive: true }); + + // ── Source 2: the external (widget iframe) feed ─────────────────── + + const unsubscribeExternal = subscribeExternalSwipe((touch) => { + if (touch.phase === 'start') begin(touch.x, touch.y); + else if (touch.phase === 'move') move(touch.x, touch.y); + else if (touch.phase === 'end') end(); + else cancel(); + }); + return () => { root.removeEventListener('touchstart', onTouchStart); root.removeEventListener('touchmove', onTouchMove); root.removeEventListener('touchend', onTouchEnd); root.removeEventListener('touchcancel', onTouchCancel); + unsubscribeExternal(); }; // setDrag / onBack are mirrored via refs so the listener never re-binds // mid-gesture; `enabled` is a real dep so toggling it binds/unbinds. diff --git a/src/app/features/bots/BotShell.tsx b/src/app/features/bots/BotShell.tsx index fabf9e51..bcc7c5eb 100644 --- a/src/app/features/bots/BotShell.tsx +++ b/src/app/features/bots/BotShell.tsx @@ -42,6 +42,9 @@ export function BotShell({ preset, room }: BotShellProps) { setShowChat(true); }, [setFailed, setShowChat]); + // Swipe-back over the widget body works via the forwarded touch stream + // (widget → `swipe-touch` side-channel → BotWidgetMount → + // externalSwipeFeed) — no host-side capture strip needed. return (
diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts index 897f1204..4b1e56f0 100644 --- a/src/app/features/bots/BotWidgetEmbed.ts +++ b/src/app/features/bots/BotWidgetEmbed.ts @@ -49,6 +49,13 @@ export type BotWidgetEmbedOptions = { // the host's own picker. Plumbed from `BotWidgetMount` (via a ref-shim, like // `onOpenMatrixToRoom`) where `mx` + navigation are available. onAddToChat?: () => void; + // Forwarded raw touch stream from the widget (`swipe-touch` action) — + // coordinates are IFRAME-local; `BotWidgetMount` maps them into host + // viewport space and feeds the swipe-back gesture. An iframe consumes + // its touches natively, so this is the only way the + // swipe-back-from-widget gesture can see drags that start on the widget + // body. Carries no privileges — just pointer coordinates. + onSwipeTouch?: (phase: 'start' | 'move' | 'end' | 'cancel', x: number, y: number) => void; }; const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`; @@ -247,7 +254,14 @@ export class BotWidgetEmbed { // doesn't go through ClientWidgetApi at all — keeps the SDK ignorant // of our extension and avoids the «unknown action» reply path. // - // Three actions today: + // Four actions today: + // + // * `swipe-touch` — unidirectional forwarded touch stream driving the + // host's swipe-back gesture over the iframe (an iframe consumes its + // own touches, so the widget cooperates via `swipe-forward.ts`). + // Carries only a phase + viewport-local coordinates; shape-validated + // in the handler below, mapped to host coordinates by + // `BotWidgetMount`, consumed by `externalSwipeFeed`. // // * `add-to-chat` — elevated verb, gated on the `vojo.add_to_chat` // capability opt-in in config.json. Carries NO url and NO room/mxid: @@ -272,7 +286,7 @@ export class BotWidgetEmbed { // 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): + // Security gates (defence in depth, apply to ALL 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 @@ -307,6 +321,20 @@ export class BotWidgetEmbed { if (!msg || typeof msg !== 'object') return; if (msg.api !== 'io.vojo.bot-widget') return; + // Forwarded touch stream for the swipe-back gesture (see + // `onSwipeTouch` in the options). Validated shape only — finite + // numbers and a known phase; everything else is silently dropped. + if (msg.action === 'swipe-touch') { + const data = msg.data as { phase?: unknown; x?: unknown; y?: unknown } | undefined; + const phase = data?.phase; + const { x, y } = data ?? {}; + if (phase !== 'start' && phase !== 'move' && phase !== 'end' && phase !== 'cancel') return; + if (typeof x !== 'number' || !Number.isFinite(x)) return; + if (typeof y !== 'number' || !Number.isFinite(y)) return; + this.options.onSwipeTouch?.(phase, x, y); + return; + } + // Elevated verb — must be dispatched BEFORE any url extraction. Its `data` // is `{}` (no url), so a hoisted `typeof url === 'string'` gate would // early-return and the verb would never fire (F1). The host gate reads the diff --git a/src/app/features/bots/BotWidgetMount.tsx b/src/app/features/bots/BotWidgetMount.tsx index 4ecd2221..fd3d3808 100644 --- a/src/app/features/bots/BotWidgetMount.tsx +++ b/src/app/features/bots/BotWidgetMount.tsx @@ -17,6 +17,7 @@ import { import type { MatrixToRoom } from '../../plugins/matrix-to'; import { Membership } from '../../../types/matrix/room'; import { getRoomToParents, isOneOnOneRoom, isSpace } from '../../utils/room'; +import { emitExternalSwipe } from '../../components/swipe-back/externalSwipeFeed'; import { useBotWidgetEmbed } from './useBotWidgetEmbed'; import { BotAddToChatPicker } from './BotAddToChatPicker'; import * as css from './BotWidgetMount.css'; @@ -236,6 +237,24 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { const [pickerOpen, setPickerOpen] = useState(false); const handleAddToChat = useCallback(() => setPickerOpen(true), []); + // The widget's forwarded touch stream → the swipe-back gesture. The + // widget reports IFRAME-local clientX/Y; offset by the mount's viewport + // rect so the host-side state machine (edge guard, axis resolve in + // `useSwipeBackGesture`) sees true host-viewport coordinates. Always + // emitted — the feed only has a subscriber when the swipe overlay is + // mounted (mobile + native), everywhere else it's a no-op. + const handleSwipeTouch = useCallback( + (phase: 'start' | 'move' | 'end' | 'cancel', x: number, y: number) => { + const rect = containerRef.current?.getBoundingClientRect(); + emitExternalSwipe({ + phase, + x: x + (rect?.left ?? 0), + y: y + (rect?.top ?? 0), + }); + }, + [] + ); + const { ready } = useBotWidgetEmbed({ containerRef, preset, @@ -243,6 +262,7 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { onError, onOpenMatrixToRoom: handleOpenMatrixToRoom, onAddToChat: handleAddToChat, + onSwipeTouch: handleSwipeTouch, }); // Track Matrix sync state so the bot loading bar yields to the global diff --git a/src/app/features/bots/useBotWidgetEmbed.ts b/src/app/features/bots/useBotWidgetEmbed.ts index c4ed5009..61c24a82 100644 --- a/src/app/features/bots/useBotWidgetEmbed.ts +++ b/src/app/features/bots/useBotWidgetEmbed.ts @@ -19,6 +19,10 @@ type UseBotWidgetEmbedOptions = { // `add-to-chat` verb (host opens its own room picker). Plumbed from // `BotWidgetMount` where `mx` is available. onAddToChat?: () => void; + // Forwarded into the embed — the widget's raw touch stream (iframe-local + // coordinates) for the swipe-back gesture. `BotWidgetMount` maps the + // coordinates and feeds `externalSwipeFeed`. + onSwipeTouch?: (phase: 'start' | 'move' | 'end' | 'cancel', x: number, y: number) => void; }; type UseBotWidgetEmbedResult = { @@ -40,6 +44,7 @@ export const useBotWidgetEmbed = ({ onError, onOpenMatrixToRoom, onAddToChat, + onSwipeTouch, }: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => { const { i18n } = useTranslation(); const mx = useMatrixClient(); @@ -63,6 +68,10 @@ export const useBotWidgetEmbed = ({ // render) so the embed lifecycle effect doesn't remount the iframe. const onAddToChatRef = useRef(onAddToChat); onAddToChatRef.current = onAddToChat; + // Same ref indirection for the swipe feed — per-render closure identity + // must not remount the iframe. + const onSwipeTouchRef = useRef(onSwipeTouch); + onSwipeTouchRef.current = onSwipeTouch; // Depend on primitive identity for the embed lifecycle — using `preset` // directly would remount the iframe (and re-handshake with the widget) @@ -96,6 +105,7 @@ export const useBotWidgetEmbed = ({ // navigate-callback closes over a new render's `mx`/`navigate`. onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target), onAddToChat: () => onAddToChatRef.current?.(), + onSwipeTouch: (phase, x, y) => onSwipeTouchRef.current?.(phase, x, y), }); embedRef.current = embed; } catch (error) {