diff --git a/apps/widget-discord/src/main.tsx b/apps/widget-discord/src/main.tsx index 2a49583b..40e2ff54 100644 --- a/apps/widget-discord/src/main.tsx +++ b/apps/widget-discord/src/main.tsx @@ -3,7 +3,6 @@ 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 @@ -59,8 +58,5 @@ 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 deleted file mode 100644 index 11f4b344..00000000 --- a/apps/widget-discord/src/swipe-forward.ts +++ /dev/null @@ -1,121 +0,0 @@ -// 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 8923b2f7..02e999e5 100644 --- a/apps/widget-telegram/src/main.tsx +++ b/apps/widget-telegram/src/main.tsx @@ -3,7 +3,6 @@ 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 @@ -74,8 +73,5 @@ 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 deleted file mode 100644 index 11f4b344..00000000 --- a/apps/widget-telegram/src/swipe-forward.ts +++ /dev/null @@ -1,121 +0,0 @@ -// 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 73c72866..35dbac1d 100644 --- a/apps/widget-whatsapp/src/main.tsx +++ b/apps/widget-whatsapp/src/main.tsx @@ -3,7 +3,6 @@ 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 @@ -74,8 +73,5 @@ 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 deleted file mode 100644 index 11f4b344..00000000 --- a/apps/widget-whatsapp/src/swipe-forward.ts +++ /dev/null @@ -1,121 +0,0 @@ -// 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/docs/ai/architecture.md b/docs/ai/architecture.md index 746465e6..c9392109 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -154,7 +154,7 @@ Use **`useIsOneOnOne()`** from `hooks/useRoom.ts` whenever you need the 1:1 vs g | `room/` | Core room view. **RoomTimeline.tsx** (~2516 LOC), **RoomInput.tsx** (~828 LOC), **RoomViewHeader.tsx** (11-line wrapper → **RoomViewHeaderDm.tsx**, ~791 LOC — the real Dawn header for *every* room class; identity area branches 3 ways: 1:1 → peer-profile sheet, group → members sheet, callView → static; subline shows `local:server` + presence for 1:1 or `N members` for groups; phone button via `useDmCallVisible`; the `…` overflow opens `room-actions/RoomActionsMenu` in an anchored folds PopOut — same chrome on desktop and mobile — restyled to a flat `ActionRow` vocabulary (`RoomActions.tsx`) on the dark-blue Vojo composer tone (folds Menu `variant="SurfaceVariant"` = #181a20); hosts mark-read/notifications/search/pinned/copy-link/settings/jump-to-time/invite/leave with the same nested popouts/overlays as upstream). Also **ThreadDrawer.tsx** (~1344 LOC, full thread surface with its own composer), `ThreadSummaryCard.tsx`, `RoomView.tsx` (composer-overlay pattern), `RoomViewMembersPanel`/`MembersSidePanel`, `RoomViewProfilePanel`/`ProfileSidePanel`, `RoomViewMediaSidePanel`/`MobileMediaViewerHorseshoe`, `RoomTimelineTyping.tsx`, `EmptyTimeline.tsx`, `RoomTombstone`, `CallChatView`, `CommandAutocomplete`, `room-pin-menu/`, `jump-to-time/`, `reaction-viewer/`. `MembersDrawer.tsx` still exists but is used **only** by lobby + `members-list/`, not Room.tsx. | | `room/message/` | `Message.tsx` (~1506 LOC). The Stream/Channel branch is `Message.tsx:1160` (`layout === 'channel' ? : `), driven by the `layout` prop from `RoomTimeline`. Hosts the edit/delete/react/report/pin/copy-link/source menu, `useDotColor` (Stream rail dot only), thread reply handler. Also `CallMessage`, `SyslineMessage`, `Reactions`, `EncryptedContent`. Editing happens IN the composer (Telegram-style edit banner in `RoomInput.tsx`, `roomIdToEditDraftAtomFamily`); the old in-timeline `MessageEditor` is gone. | | `room-nav/` | **Three** list-row components now: `RoomNavItem.tsx` (~434 LOC, channels + spaces lists), `DmStreamRow.tsx` (~496 LOC, the Direct-list row), `DirectInviteRow.tsx` (~282 LOC, inline accept/decline invite row in the Direct list). | -| `bots/` | **NEW.** Bridge-bot widget host (a bot's control room = the DM with its mxid). `catalog.ts` loads `BotPreset[]` from `config.json` `bots[]` (validates widget-origin allowlist + command prefix). `useBotRoom.ts` classifies control-room membership into a 6-state union. `BotShell` mounts a `matrix-widget-api` iframe (`BotWidgetEmbed`/`BotWidgetDriver`, tight `m.text`/`m.notice`-only capability allowlist). `botShowChatAtomFamily` toggles widget vs chat-fallback. `room.ts` = single source for portal-vs-control-room (`isBotControlRoom`). Widget iframes also forward touch phases over the `io.vojo.bot-widget` side-channel (`swipe-touch` action; widget-side `apps/widget-*/src/swipe-forward.ts`, host-side `BotWidgetEmbed` → `components/swipe-back/externalSwipeFeed.ts`) so the mobile swipe-back gesture works over the widget body — contract notes in `BotWidgetEmbed.ts`. Pairs with `pages/client/bots/`. | +| `bots/` | **NEW.** Bridge-bot widget host (a bot's control room = the DM with its mxid). `catalog.ts` loads `BotPreset[]` from `config.json` `bots[]` (validates widget-origin allowlist + command prefix). `useBotRoom.ts` classifies control-room membership into a 6-state union. `BotShell` mounts a `matrix-widget-api` iframe (`BotWidgetEmbed`/`BotWidgetDriver`, tight `m.text`/`m.notice`-only capability allowlist). `botShowChatAtomFamily` toggles widget vs chat-fallback. `room.ts` = single source for portal-vs-control-room (`isBotControlRoom`). The widget iframe is cross-origin, so the mobile swipe-back gesture (`components/swipe-back/`) is dead over the widget body — touches inside an iframe never reach the host; over the widget the user falls back to the Android system / hardware back. The `io.vojo.bot-widget` side-channel (`BotWidgetEmbed`) carries the remaining three host actions (`add-to-chat`, `open-external-url`, `open-matrix-to`). Pairs with `pages/client/bots/`. | | `share-target/` | **NEW.** Android/web system share-sheet hand-off. `ShareTargetStrip.tsx` is a top banner (mounted in `HorseshoeContainer`) shown while `pendingShareAtom` holds a payload; the next `RoomInput` mount consumes it (injects files + text, then nulls the atom). Native slot drained by `hooks/useShareTargetReceiver.ts`. | | `call/` | **In-room call pane** — `CallView` (prescreen/join screen + member list + livekit checks), `CallControls`/`Controls`/`PrescreenControls`/`CallMemberCard`. Mounted in `Room.tsx` via ``. Consumes `plugins/call` CallEmbed + `state/callEmbed`. Don't unmount/remount the widget root carelessly — Android FGS is keyed on `joined`. | | `call-status/` | **Global bottom call rail** — `IncomingCallStrip` (incoming-ring row) + `CallStatus` (active-call pill) + `CallControl`. Mounted via `pages/CallStatusRenderer.tsx` + `pages/IncomingCallStripRenderer.tsx` inside `HorseshoeContainer` (NOT directly in Router). Call **lifecycle** hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup`, `usePendingCallActionConsumer`) run in Router's `IncomingCallsFeature()`. | diff --git a/src/app/components/swipe-back/externalSwipeFeed.ts b/src/app/components/swipe-back/externalSwipeFeed.ts deleted file mode 100644 index 8d6991ed..00000000 --- a/src/app/components/swipe-back/externalSwipeFeed.ts +++ /dev/null @@ -1,38 +0,0 @@ -// 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 0fea59b2..9d33af62 100644 --- a/src/app/components/swipe-back/useSwipeBackGesture.ts +++ b/src/app/components/swipe-back/useSwipeBackGesture.ts @@ -1,6 +1,5 @@ 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 @@ -30,15 +29,10 @@ type Args = { // `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. +// Input is native touch listeners on the card root — every React-rendered +// surface (chats, AI chat, hero headers). Touches that land inside a +// cross-origin widget iframe (the bots tab) never reach these listeners, so +// over the widget body the user falls back to the system / hardware back. export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBack }: Args): void { const disabledRef = useRef(disabled); const setDragRef = useRef(setDrag); @@ -76,16 +70,9 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac reset(); }; - // ── State machine (shared by both input sources) ────────────────── + // ── State machine (begin / move / end / cancel) ─────────────────── 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 @@ -99,11 +86,9 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac lastDragPx = 0; }; - // `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) => { + // `preventDefault` cancels the browser's own handling once we engage — + // the native touchmove stays cancelable across the whole `pan-y` card. + const move = (x: number, y: number, preventDefault: () => void) => { if (disabledRef.current) { springHome(); bailed = true; @@ -134,7 +119,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. - preventDefault?.(); + preventDefault(); const vw = window.innerWidth; // Follow the finger 1:1 to the right; clamp to [0, vw]. Never negative: @@ -169,8 +154,6 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac springHome(); }; - // ── Source 1: native touches on the card root ───────────────────── - const onTouchStart = (e: TouchEvent) => { if (e.touches.length !== 1) { springHome(); @@ -200,21 +183,11 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac 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 bcc7c5eb..7132d23e 100644 --- a/src/app/features/bots/BotShell.tsx +++ b/src/app/features/bots/BotShell.tsx @@ -42,9 +42,10 @@ 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. + // The widget runs in a cross-origin iframe, so touches that start on its + // body never reach the host swipe-back gesture; over the widget the user + // falls back to the system / hardware back. Swipe-back stays live on the + // surrounding native chrome (the hero header). return (
diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts index 4b1e56f0..d22a943f 100644 --- a/src/app/features/bots/BotWidgetEmbed.ts +++ b/src/app/features/bots/BotWidgetEmbed.ts @@ -49,13 +49,6 @@ 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}`; @@ -254,14 +247,7 @@ export class BotWidgetEmbed { // doesn't go through ClientWidgetApi at all — keeps the SDK ignorant // of our extension and avoids the «unknown action» reply path. // - // 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`. + // Three actions today: // // * `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: @@ -321,20 +307,6 @@ 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 fd3d3808..4ecd2221 100644 --- a/src/app/features/bots/BotWidgetMount.tsx +++ b/src/app/features/bots/BotWidgetMount.tsx @@ -17,7 +17,6 @@ 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'; @@ -237,24 +236,6 @@ 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, @@ -262,7 +243,6 @@ 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 61c24a82..c4ed5009 100644 --- a/src/app/features/bots/useBotWidgetEmbed.ts +++ b/src/app/features/bots/useBotWidgetEmbed.ts @@ -19,10 +19,6 @@ 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 = { @@ -44,7 +40,6 @@ export const useBotWidgetEmbed = ({ onError, onOpenMatrixToRoom, onAddToChat, - onSwipeTouch, }: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => { const { i18n } = useTranslation(); const mx = useMatrixClient(); @@ -68,10 +63,6 @@ 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) @@ -105,7 +96,6 @@ 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) {