();
+
+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) {