feat(bots): forward bot-widget iframe touches over a side-channel so swipe-back works across the widget body on mobile
This commit is contained in:
parent
c7dc1d51de
commit
180021b9ad
12 changed files with 554 additions and 28 deletions
|
|
@ -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(<App bootstrap={result.bootstrap} api={api} />, root);
|
||||
}
|
||||
|
|
|
|||
121
apps/widget-discord/src/swipe-forward.ts
Normal file
121
apps/widget-discord/src/swipe-forward.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
|
|
@ -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(<App bootstrap={result.bootstrap} api={api} />, root);
|
||||
}
|
||||
|
|
|
|||
121
apps/widget-telegram/src/swipe-forward.ts
Normal file
121
apps/widget-telegram/src/swipe-forward.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
|
|
@ -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(<App bootstrap={result.bootstrap} api={api} />, root);
|
||||
}
|
||||
|
|
|
|||
121
apps/widget-whatsapp/src/swipe-forward.ts
Normal file
121
apps/widget-whatsapp/src/swipe-forward.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
38
src/app/components/swipe-back/externalSwipeFeed.ts
Normal file
38
src/app/components/swipe-back/externalSwipeFeed.ts
Normal file
|
|
@ -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<Listener>();
|
||||
|
||||
export const subscribeExternalSwipe = (listener: Listener): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const emitExternalSwipe = (touch: ExternalSwipeTouch): void => {
|
||||
listeners.forEach((listener) => listener(touch));
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={css.Shell}>
|
||||
<BotShellHero preset={preset} room={room} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue