@adminide-stack/yantra-mobile 12.0.50 → 12.0.51-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/lib/components/KeyboardComposerDock.js +74 -29
  2. package/lib/components/KeyboardComposerDock.js.map +1 -1
  3. package/lib/components/NavigationHeader/NavigationHeader.js +5 -2
  4. package/lib/components/NavigationHeader/NavigationHeader.js.map +1 -1
  5. package/lib/config/env-config.js +14 -2
  6. package/lib/config/env-config.js.map +1 -1
  7. package/lib/features/audio-input/NativeDictationBar.js +162 -0
  8. package/lib/features/audio-input/NativeDictationBar.js.map +1 -0
  9. package/lib/features/audio-input/nativeDictation.js +9 -0
  10. package/lib/features/audio-input/nativeDictation.js.map +1 -0
  11. package/lib/features/canvas/canvasCore.js +51 -0
  12. package/lib/features/canvas/canvasCore.js.map +1 -0
  13. package/lib/features/canvas/sessionIdentity.js +21 -0
  14. package/lib/features/canvas/sessionIdentity.js.map +1 -0
  15. package/lib/features/canvas/surfaceIndex.js +122 -1
  16. package/lib/features/canvas/surfaceIndex.js.map +1 -1
  17. package/lib/features/canvas/useCanvasSession.js +75 -29
  18. package/lib/features/canvas/useCanvasSession.js.map +1 -1
  19. package/lib/features/chat/ChatTranscript.js +35 -13
  20. package/lib/features/chat/ChatTranscript.js.map +1 -1
  21. package/lib/hooks/useChatApi.js +123 -41
  22. package/lib/hooks/useChatApi.js.map +1 -1
  23. package/lib/hooks/useChatStream.js +4 -2
  24. package/lib/hooks/useChatStream.js.map +1 -1
  25. package/lib/hooks/useKeyboardBottomOffset.js +22 -0
  26. package/lib/hooks/useKeyboardBottomOffset.js.map +1 -0
  27. package/lib/index.js +1 -1
  28. package/lib/index.js.map +1 -1
  29. package/lib/screens/Chat/index.js +39 -10
  30. package/lib/screens/Chat/index.js.map +1 -1
  31. package/lib/screens/ChatHistory/index.js +1 -1
  32. package/lib/screens/ChatHistory/index.js.map +1 -1
  33. package/lib/screens/Home/HomeScreen.js +27 -4
  34. package/lib/screens/Home/HomeScreen.js.map +1 -1
  35. package/lib/screens/Home/components/CanvasPrewarm.js +58 -0
  36. package/lib/screens/Home/components/CanvasPrewarm.js.map +1 -0
  37. package/lib/screens/Home/components/ChatHistoryLanding.js +30 -38
  38. package/lib/screens/Home/components/ChatHistoryLanding.js.map +1 -1
  39. package/lib/utils/keyboardController.js +21 -0
  40. package/lib/utils/keyboardController.js.map +1 -0
  41. package/package.json +5 -4
@@ -0,0 +1,51 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropSymbols = Object.getOwnPropertySymbols;
3
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
4
+ var __propIsEnum = Object.prototype.propertyIsEnumerable;
5
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
6
+ var __spreadValues = (a, b) => {
7
+ for (var prop in b || (b = {}))
8
+ if (__hasOwnProp.call(b, prop))
9
+ __defNormalProp(a, prop, b[prop]);
10
+ if (__getOwnPropSymbols)
11
+ for (var prop of __getOwnPropSymbols(b)) {
12
+ if (__propIsEnum.call(b, prop))
13
+ __defNormalProp(a, prop, b[prop]);
14
+ }
15
+ return a;
16
+ };
17
+ function parseBoardState(raw) {
18
+ if (!raw || typeof raw !== "object") return null;
19
+ const candidate = raw;
20
+ if (candidate.version !== 1 || !Array.isArray(candidate.items)) return null;
21
+ const items = candidate.items.filter(isRenderableItem);
22
+ return __spreadValues({
23
+ version: 1,
24
+ savedAt: typeof candidate.savedAt === "number" ? candidate.savedAt : 0,
25
+ items,
26
+ zoom: clampZoom(typeof candidate.zoom === "number" ? candidate.zoom : 1)
27
+ }, typeof candidate.title === "string" && candidate.title ? {
28
+ title: candidate.title
29
+ } : {});
30
+ }
31
+ function emptyBoardState(title) {
32
+ return __spreadValues({
33
+ version: 1,
34
+ savedAt: 0,
35
+ items: [],
36
+ zoom: 1
37
+ }, title ? {
38
+ title
39
+ } : {});
40
+ }
41
+ function isRenderableItem(item) {
42
+ if (!item || typeof item !== "object") return false;
43
+ const c = item;
44
+ return typeof c.id === "string" && (c.type === "image" || c.type === "video" || c.type === "audio") && typeof c.src === "string" && c.src.length > 0 && !c.src.startsWith("blob:") && [c.x, c.y, c.width, c.height].every((n) => typeof n === "number" && Number.isFinite(n)) && c.width > 0 && c.height > 0;
45
+ }
46
+ const MIN_ZOOM = 0.25;
47
+ const MAX_ZOOM = 4;
48
+ function clampZoom(zoom) {
49
+ if (!Number.isFinite(zoom)) return 1;
50
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
51
+ }export{MAX_ZOOM,MIN_ZOOM,clampZoom,emptyBoardState,isRenderableItem,parseBoardState};//# sourceMappingURL=canvasCore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canvasCore.js","sources":["../../../src/features/canvas/canvasCore.ts"],"sourcesContent":["/**\n * Platform-agnostic canvas document model — the native twin of the web\n * surface's `state/boards.ts` (`@yantra/canvas-surface`).\n *\n * The shapes are copied, not imported: the surface package builds for the DOM\n * (react-dom peer, vite, `crypto.randomUUID`), so importing it would drag the\n * web toolchain into Metro. The contract that actually matters is the persisted\n * JSON — `PersistedCanvasState` version 1 — and tests below pin this file to\n * fixtures of that JSON, so drift between the copies fails a test rather than\n * corrupting a board.\n *\n * Everything here is pure data + math so it runs identically under Metro,\n * vite, and jest: no RN imports, no DOM globals.\n */\n\nexport interface CanvasItem {\n id: string;\n type: 'image' | 'video' | 'audio';\n src: string;\n x: number;\n y: number;\n width: number;\n height: number;\n zIndex: number;\n generatedAssetName?: string;\n}\n\nexport interface PersistedCanvasState {\n version: 1;\n savedAt: number;\n items: CanvasItem[];\n zoom: number;\n title?: string;\n}\n\n/** Parse a persisted board, tolerating the unknown: bad rows drop, not throw. */\nexport function parseBoardState(raw: unknown): PersistedCanvasState | null {\n if (!raw || typeof raw !== 'object') return null;\n const candidate = raw as Partial<PersistedCanvasState>;\n if (candidate.version !== 1 || !Array.isArray(candidate.items)) return null;\n const items = candidate.items.filter(isRenderableItem);\n return {\n version: 1,\n savedAt: typeof candidate.savedAt === 'number' ? candidate.savedAt : 0,\n items,\n zoom: clampZoom(typeof candidate.zoom === 'number' ? candidate.zoom : 1),\n ...(typeof candidate.title === 'string' && candidate.title ? { title: candidate.title } : {}),\n };\n}\n\n/** Blank board used when the index row has no items yet — native still opens it. */\nexport function emptyBoardState(title?: string): PersistedCanvasState {\n return {\n version: 1,\n savedAt: 0,\n items: [],\n zoom: 1,\n ...(title ? { title } : {}),\n };\n}\n\n/**\n * Media that cannot survive a reload. Web drops `blob:` (dies with the tab);\n * native also drops device URIs (`file:`, photo-library schemes) until the host\n * has uploaded them to storage — same rule as canvas-surface `buildBoardState`.\n */\nexport function isLocalOnlySrc(src: string): boolean {\n return /^(blob:|file:|content:|ph:|assets-library:|data:)/i.test(src.trim());\n}\n\n/**\n * State to write to the index. Title is sticky once the user (or the first\n * labelled item) sets it, matching web `buildBoardState`.\n */\n/** Placeholder titles native used to stamp on a blank board. Web leaves title\n * unset so the first upload (`IMG_0002`) becomes the grid name. */\nexport function isUnsetBoardTitle(title?: string): boolean {\n const t = title?.trim();\n return !t || /^untitled$/i.test(t) || /^new canvas$/i.test(t);\n}\n\n/** Web `addMediaItem` strips the extension so `IMG_0003.jpeg` → `IMG_0003`. */\nexport function labelFromFileName(name: string): string {\n const base = name.trim().split(/[\\\\/]/).pop() || name.trim();\n const stripped = base.replace(/\\.[^.]+$/, '');\n return stripped || base;\n}\n\nexport function buildBoardState(\n items: readonly CanvasItem[],\n zoom: number,\n currentTitle?: string,\n): PersistedCanvasState {\n const persistableItems = items.filter((it) => !isLocalOnlySrc(it.src));\n const named = persistableItems.find((it) => it.generatedAssetName)?.generatedAssetName;\n return {\n version: 1,\n savedAt: Date.now(),\n items: persistableItems,\n zoom: clampZoom(zoom),\n title: isUnsetBoardTitle(currentTitle) ? named : currentTitle,\n };\n}\n\n/**\n * Comparable fingerprint of what is worth a write. Geometry and zoom ride\n * along on the next real save but are not themselves a reason to hit the index\n * (web `boardSignature`).\n */\nexport function boardSignature(state: PersistedCanvasState): string {\n return JSON.stringify({\n title: state.title ?? '',\n items: state.items.map((it) => [it.id, it.type, it.src, it.generatedAssetName ?? '']),\n });\n}\n\n/** Mint a board id. The index upserts on `{tenantId, surface, channelId}` with no chat FK. */\nexport function newBoardId(): string {\n const c = globalThis.crypto as Crypto | undefined;\n if (c?.randomUUID) return c.randomUUID();\n return `board-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\n/** An item the viewer can draw. `blob:` URLs died with the web tab that made them. */\nexport function isRenderableItem(item: unknown): item is CanvasItem {\n if (!item || typeof item !== 'object') return false;\n const c = item as Partial<CanvasItem>;\n return (\n typeof c.id === 'string' &&\n (c.type === 'image' || c.type === 'video' || c.type === 'audio') &&\n typeof c.src === 'string' &&\n c.src.length > 0 &&\n !c.src.startsWith('blob:') &&\n [c.x, c.y, c.width, c.height].every((n) => typeof n === 'number' && Number.isFinite(n)) &&\n (c.width as number) > 0 &&\n (c.height as number) > 0\n );\n}\n\n/**\n * Board frame for an upload — twin of canvas-surface `addMediaItem`.\n * Web does not use the file's pixel size (a 12MP photo would fill the plane\n * and force 25% zoom). Image 500×400, video 640×360, audio 400×80, at (100,100).\n */\nexport function frameForUpload(type: CanvasItem['type']): Pick<CanvasItem, 'x' | 'y' | 'width' | 'height'> {\n if (type === 'video') return { x: 100, y: 100, width: 640, height: 360 };\n if (type === 'audio') return { x: 100, y: 100, width: 400, height: 80 };\n return { x: 100, y: 100, width: 500, height: 400 };\n}\n\n/** Device-pixel dumps (a 12MP photo) must not sit at 100% zoom and fill the phone. */\nexport function coerceUploadSizedItem(item: CanvasItem): CanvasItem {\n const cap = frameForUpload(item.type);\n if (item.width <= cap.width * 1.5 && item.height <= cap.height * 1.5) return item;\n return { ...item, width: cap.width, height: cap.height, x: item.x || cap.x, y: item.y || cap.y };\n}\n\n/** Twin of canvas-surface `dimsForAspect` — generated video/image frame size. */\nexport function dimsForAspect(aspect: string, maxEdge: number): { width: number; height: number } {\n const [w, h] = aspect.split(':').map(Number);\n if (!w || !h) return { width: maxEdge, height: maxEdge };\n return w >= h\n ? { width: maxEdge, height: Math.round((maxEdge * h) / w) }\n : { width: Math.round((maxEdge * w) / h), height: maxEdge };\n}\n\n/** Floor for item edits - a rect can never be resized away entirely. */\nexport const MIN_ITEM_SIZE = 24;\n\nexport interface ItemFrame {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\nexport type ResizeCorner = 'tl' | 'tr' | 'bl' | 'br';\n\n/**\n * Resize `frame` by dragging `corner` by (dx, dy) board units, keeping the\n * OPPOSITE corner anchored - the gesture the selection handles drive. Both\n * axes move independently (free-form resize); each side clamps at\n * MIN_ITEM_SIZE so a rect can shrink to a nub but never invert or vanish.\n *\n * Marked as a worklet: the resize handles call this per-frame on the UI\n * thread. The directive is inert everywhere else (plain string in node/jest).\n */\nexport function resizeFrame(frame: ItemFrame, corner: ResizeCorner, dx: number, dy: number): ItemFrame {\n 'worklet';\n\n const left = corner === 'tl' || corner === 'bl';\n const top = corner === 'tl' || corner === 'tr';\n\n const width = Math.max(MIN_ITEM_SIZE, left ? frame.width - dx : frame.width + dx);\n const height = Math.max(MIN_ITEM_SIZE, top ? frame.height - dy : frame.height + dy);\n\n return {\n // Anchor the opposite corner: a left/top edge moves by however much the\n // clamped size actually changed, not by the raw drag delta.\n x: left ? frame.x + (frame.width - width) : frame.x,\n y: top ? frame.y + (frame.height - height) : frame.y,\n width,\n height,\n };\n}\n\n/** New items array with `id`'s frame replaced - the commit step after a drag\n * or resize gesture ends. Unknown ids return the array unchanged. */\nexport function applyItemFrame(items: readonly CanvasItem[], id: string, frame: ItemFrame): CanvasItem[] {\n return items.map((item) => (item.id === id ? { ...item, ...frame } : item));\n}\n\nexport const MIN_ZOOM = 0.25;\nexport const MAX_ZOOM = 4;\n\nexport function clampZoom(zoom: number): number {\n if (!Number.isFinite(zoom)) return 1;\n return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));\n}\n\nexport interface BoardBounds {\n minX: number;\n minY: number;\n maxX: number;\n maxY: number;\n}\n\n/** Union of item rects; null for an empty board (an empty union has no bounds). */\nexport function boardBounds(items: readonly CanvasItem[]): BoardBounds | null {\n if (items.length === 0) return null;\n let minX = Infinity;\n let minY = Infinity;\n let maxX = -Infinity;\n let maxY = -Infinity;\n for (const it of items) {\n minX = Math.min(minX, it.x);\n minY = Math.min(minY, it.y);\n maxX = Math.max(maxX, it.x + it.width);\n maxY = Math.max(maxY, it.y + it.height);\n }\n return { minX, minY, maxX, maxY };\n}\n\nexport interface FitTransform {\n /** Uniform scale applied to board coordinates. */\n scale: number;\n /** Screen-space translation, applied after scaling. */\n translateX: number;\n translateY: number;\n}\n\n/**\n * Fit the whole board inside a viewport with a margin — the viewer's initial\n * camera. Pure math so pinch/pan gestures can start from a reproducible state.\n */\nexport function fitToViewport(\n bounds: BoardBounds,\n viewportWidth: number,\n viewportHeight: number,\n margin = 24,\n): FitTransform {\n const boardW = Math.max(1, bounds.maxX - bounds.minX);\n const boardH = Math.max(1, bounds.maxY - bounds.minY);\n const availW = Math.max(1, viewportWidth - margin * 2);\n const availH = Math.max(1, viewportHeight - margin * 2);\n const scale = clampZoom(Math.min(availW / boardW, availH / boardH));\n // Center the scaled board in the viewport.\n const translateX = (viewportWidth - boardW * scale) / 2 - bounds.minX * scale;\n const translateY = (viewportHeight - boardH * scale) / 2 - bounds.minY * scale;\n return { scale, translateX, translateY };\n}\n\n/** Painter's order: stable sort by zIndex so overlapping media stack as authored. */\nexport function paintOrder(items: readonly CanvasItem[]): CanvasItem[] {\n return [...items].sort((a, b) => a.zIndex - b.zIndex || a.id.localeCompare(b.id));\n}\n\n/**\n * Fan items with IDENTICAL rects into a row - a DISPLAY-layer fix.\n *\n * \"Save to canvas\" appends every file at the same default frame, so a board\n * the user never arranged holds its media exactly stacked: the viewer renders\n * all of them faithfully and shows only the top one. Items whose rect is an\n * exact duplicate of an earlier item's slide right by (width + gutter) each,\n * so every file is visible side by side; a board with a real layout has no\n * exact duplicates and passes through untouched.\n *\n * Deliberately not persisted: the spread is how the board is SHOWN, and only\n * becomes real geometry if the user drags an item, which commits that item's\n * new frame like any other edit.\n */\nexport function spreadStackedItems(items: readonly CanvasItem[], gutter = 24): CanvasItem[] {\n const seen = new Map<string, number>();\n return items.map((item) => {\n const key = `${item.x}|${item.y}|${item.width}|${item.height}`;\n const n = seen.get(key) ?? 0;\n seen.set(key, n + 1);\n return n === 0 ? item : { ...item, x: item.x + (item.width + gutter) * n };\n });\n}\n\n/** New list with `id` painted above every other item; absent id = no-op. */\nexport function bringToFront(items: readonly CanvasItem[], id: string): CanvasItem[] {\n const top = Math.max(...items.map((i) => i.zIndex), 0);\n return items.map((i) => (i.id === id && i.zIndex <= top ? { ...i, zIndex: top + 1 } : i));\n}\n\n/** New list with `id` painted below every other item; absent id = no-op. */\nexport function sendToBack(items: readonly CanvasItem[], id: string): CanvasItem[] {\n const bottom = Math.min(...items.map((i) => i.zIndex), 0);\n return items.map((i) => (i.id === id && i.zIndex >= bottom ? { ...i, zIndex: bottom - 1 } : i));\n}\n\n/** New list without `id`; absent id = same content. */\nexport function removeItem(items: readonly CanvasItem[], id: string): CanvasItem[] {\n return items.filter((i) => i.id !== id);\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAmCO,SAAS,gBAAgB,GAA2C,EAAA;AACzE,EAAA,IAAI,CAAC,GAAA,IAAO,OAAO,GAAA,KAAQ,UAAiB,OAAA,IAAA;AAC5C,EAAA,MAAM,SAAY,GAAA,GAAA;AAClB,EAAI,IAAA,SAAA,CAAU,YAAY,CAAK,IAAA,CAAC,MAAM,OAAQ,CAAA,SAAA,CAAU,KAAK,CAAA,EAAU,OAAA,IAAA;AACvE,EAAA,MAAM,KAAQ,GAAA,SAAA,CAAU,KAAM,CAAA,MAAA,CAAO,gBAAgB,CAAA;AACrD,EAAO,OAAA,cAAA,CAAA;AAAA,IACL,OAAS,EAAA,CAAA;AAAA,IACT,SAAS,OAAO,SAAA,CAAU,OAAY,KAAA,QAAA,GAAW,UAAU,OAAU,GAAA,CAAA;AAAA,IACrE,KAAA;AAAA,IACA,IAAA,EAAM,UAAU,OAAO,SAAA,CAAU,SAAS,QAAW,GAAA,SAAA,CAAU,OAAO,CAAC;AAAA,GAAA,EACnE,OAAO,SAAA,CAAU,KAAU,KAAA,QAAA,IAAY,UAAU,KAAQ,GAAA;AAAA,IAC3D,OAAO,SAAU,CAAA;AAAA,MACf,EAAC,CAAA;AAET;AAGO,SAAS,gBAAgB,KAAsC,EAAA;AACpE,EAAO,OAAA,cAAA,CAAA;AAAA,IACL,OAAS,EAAA,CAAA;AAAA,IACT,OAAS,EAAA,CAAA;AAAA,IACT,OAAO,EAAC;AAAA,IACR,IAAM,EAAA;AAAA,GAAA,EACF,KAAQ,GAAA;AAAA,IACV;AAAA,MACE,EAAC,CAAA;AAET;AA4DO,SAAS,iBAAiB,IAAmC,EAAA;AAClE,EAAA,IAAI,CAAC,IAAA,IAAQ,OAAO,IAAA,KAAS,UAAiB,OAAA,KAAA;AAC9C,EAAA,MAAM,CAAI,GAAA,IAAA;AACV,EAAO,OAAA,OAAO,EAAE,EAAO,KAAA,QAAA,KAAa,EAAE,IAAS,KAAA,OAAA,IAAW,CAAE,CAAA,IAAA,KAAS,OAAW,IAAA,CAAA,CAAE,SAAS,OAAY,CAAA,IAAA,OAAO,CAAE,CAAA,GAAA,KAAQ,QAAY,IAAA,CAAA,CAAE,IAAI,MAAS,GAAA,CAAA,IAAK,CAAC,CAAA,CAAE,GAAI,CAAA,UAAA,CAAW,OAAO,CAAK,IAAA,CAAC,CAAE,CAAA,CAAA,EAAG,CAAE,CAAA,CAAA,EAAG,EAAE,KAAO,EAAA,CAAA,CAAE,MAAM,CAAA,CAAE,KAAM,CAAA,CAAA,CAAA,KAAK,OAAO,CAAM,KAAA,QAAA,IAAY,MAAO,CAAA,QAAA,CAAS,CAAC,CAAC,KAAK,CAAE,CAAA,KAAA,GAAkB,CAAK,IAAA,CAAA,CAAE,MAAmB,GAAA,CAAA;AAC/T;AAwGO,MAAM,QAAW,GAAA;AACjB,MAAM,QAAW,GAAA;AACjB,SAAS,UAAU,IAAsB,EAAA;AAC9C,EAAA,IAAI,CAAC,MAAA,CAAO,QAAS,CAAA,IAAI,GAAU,OAAA,CAAA;AACnC,EAAA,OAAO,KAAK,GAAI,CAAA,QAAA,EAAU,KAAK,GAAI,CAAA,QAAA,EAAU,IAAI,CAAC,CAAA;AACpD"}
@@ -0,0 +1,21 @@
1
+ function b64urlJson(segment) {
2
+ try {
3
+ const b64 = segment.replace(/-/g, "+").replace(/_/g, "/");
4
+ const pad = b64 + "=".repeat((4 - b64.length % 4) % 4);
5
+ const text = typeof atob === "function" ? atob(pad) : (
6
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
7
+ require("buffer").Buffer.from(pad, "base64").toString("utf8")
8
+ );
9
+ return JSON.parse(text);
10
+ } catch (e) {
11
+ return null;
12
+ }
13
+ }
14
+ function tokenExpiryMs(token) {
15
+ if (!token) return null;
16
+ const parts = token.split(".");
17
+ if (parts.length < 2) return null;
18
+ const claims = b64urlJson(parts[1]);
19
+ const exp = claims == null ? void 0 : claims.exp;
20
+ return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
21
+ }export{tokenExpiryMs};//# sourceMappingURL=sessionIdentity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionIdentity.js","sources":["../../../src/features/canvas/sessionIdentity.ts"],"sourcesContent":["/**\n * Best-effort identity readout from the surface session token - purely for\n * DISPLAY. The canvas index scopes rows by the token's tenant, so \"No\n * canvases yet\" is ambiguous without saying WHO the index was asked as: the\n * same account sees different boards per org, and a phone whose org\n * bootstrap picked a different default org reports empty while the web shows\n * boards. No verification here (display only) - the payload is decoded, not\n * trusted.\n */\nexport interface SessionIdentity {\n org?: string;\n user?: string;\n}\n\nfunction b64urlJson(segment: string): Record<string, unknown> | null {\n try {\n const b64 = segment.replace(/-/g, '+').replace(/_/g, '/');\n const pad = b64 + '='.repeat((4 - (b64.length % 4)) % 4);\n // atob exists in RN (Hermes) and the web; Buffer covers node/jest.\n const text =\n typeof atob === 'function'\n ? atob(pad)\n : // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require\n (\n require('buffer') as { Buffer: { from(s: string, e: string): { toString(e: string): string } } }\n ).Buffer.from(pad, 'base64').toString('utf8');\n return JSON.parse(text) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\n/**\n * The token's `exp` claim in epoch milliseconds, or null if it cannot be read.\n * Used to schedule a re-mint before the surface session expires. Display-only\n * decode (no verification), same as the identity readout.\n */\nexport function tokenExpiryMs(token: string | null | undefined): number | null {\n if (!token) return null;\n const parts = token.split('.');\n if (parts.length < 2) return null;\n const claims = b64urlJson(parts[1]);\n const exp = claims?.exp;\n return typeof exp === 'number' && Number.isFinite(exp) ? exp * 1000 : null;\n}\n\nexport function sessionIdentityFromToken(token: string | null | undefined): SessionIdentity {\n if (!token) return {};\n const parts = token.split('.');\n if (parts.length < 2) return {};\n const claims = b64urlJson(parts[1]);\n if (!claims) return {};\n const pick = (...keys: string[]): string | undefined => {\n for (const key of keys) {\n const value = claims[key];\n if (typeof value === 'string' && value.trim()) return value.trim();\n }\n return undefined;\n };\n return {\n org: pick('orgName', 'tenantId', 'org', 'organization'),\n user: pick('email', 'username', 'name', 'sub', 'userId'),\n };\n}\n\n/** \"org acme · user jo@x.com · index surface-index-backend.foo\" fine print. */\nexport function describeSession(token: string | null | undefined, indexUrl: string): string {\n const id = sessionIdentityFromToken(token);\n const host = indexUrl.replace(/^https?:\\/\\//, '').split('/')[0];\n const parts: string[] = [];\n if (id.org) parts.push(`org ${id.org}`);\n if (id.user) parts.push(id.user);\n if (host) parts.push(host);\n return parts.join(' · ');\n}\n"],"names":[],"mappings":"AAaA,SAAS,WAAW,OAAiD,EAAA;AACnE,EAAI,IAAA;AACF,IAAM,MAAA,GAAA,GAAM,QAAQ,OAAQ,CAAA,IAAA,EAAM,GAAG,CAAE,CAAA,OAAA,CAAQ,MAAM,GAAG,CAAA;AACxD,IAAM,MAAA,GAAA,GAAM,MAAM,GAAI,CAAA,MAAA,CAAA,CAAQ,IAAI,GAAI,CAAA,MAAA,GAAS,KAAK,CAAC,CAAA;AAErD,IAAA,MAAM,IAAO,GAAA,OAAO,IAAS,KAAA,UAAA,GAAa,KAAK,GAAG,CAAA;AAAA;AAAA,MAEjD,OAAA,CAAQ,QAAQ,CAMd,CAAA,MAAA,CAAO,KAAK,GAAK,EAAA,QAAQ,CAAE,CAAA,QAAA,CAAS,MAAM;AAAA,KAAA;AAC7C,IAAO,OAAA,IAAA,CAAK,MAAM,IAAI,CAAA;AAAA,GAChB,CAAA,OAAA,CAAA,EAAA;AACN,IAAO,OAAA,IAAA;AAAA;AAEX;AAOO,SAAS,cAAc,KAAiD,EAAA;AAC7E,EAAI,IAAA,CAAC,OAAc,OAAA,IAAA;AACnB,EAAM,MAAA,KAAA,GAAQ,KAAM,CAAA,KAAA,CAAM,GAAG,CAAA;AAC7B,EAAI,IAAA,KAAA,CAAM,MAAS,GAAA,CAAA,EAAU,OAAA,IAAA;AAC7B,EAAA,MAAM,MAAS,GAAA,UAAA,CAAW,KAAM,CAAA,CAAC,CAAC,CAAA;AAClC,EAAA,MAAM,MAAM,MAAQ,IAAA,IAAA,GAAA,MAAA,GAAA,MAAA,CAAA,GAAA;AACpB,EAAO,OAAA,OAAO,QAAQ,QAAY,IAAA,MAAA,CAAO,SAAS,GAAG,CAAA,GAAI,MAAM,GAAO,GAAA,IAAA;AACxE"}
@@ -1,3 +1,21 @@
1
+ import {parseBoardState,emptyBoardState}from'./canvasCore.js';var __defProp = Object.defineProperty;
2
+ var __getOwnPropSymbols = Object.getOwnPropertySymbols;
3
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
4
+ var __propIsEnum = Object.prototype.propertyIsEnumerable;
5
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
6
+ var __spreadValues = (a, b) => {
7
+ for (var prop in b || (b = {}))
8
+ if (__hasOwnProp.call(b, prop))
9
+ __defNormalProp(a, prop, b[prop]);
10
+ if (__getOwnPropSymbols)
11
+ for (var prop of __getOwnPropSymbols(b)) {
12
+ if (__propIsEnum.call(b, prop))
13
+ __defNormalProp(a, prop, b[prop]);
14
+ }
15
+ return a;
16
+ };
17
+ const CHANNEL_FIELDS = "id surface channelId title metadata updatedAt";
18
+ const SCRATCH_BOARD_ID = "global";
1
19
  function surfaceIndexUrl(graphqlUrl, explicit) {
2
20
  const override = (explicit || "").trim();
3
21
  if (override) return override;
@@ -30,4 +48,107 @@ function parseOrigin(url) {
30
48
  protocol: match[1].toLowerCase(),
31
49
  host: match[2].toLowerCase()
32
50
  };
33
- }export{surfaceIndexUrl,surfaceIndexUrlFromTemplate};//# sourceMappingURL=surfaceIndex.js.map
51
+ }
52
+ let boardsCache = null;
53
+ function getCachedCanvasBoards(url) {
54
+ if (!url || !boardsCache || boardsCache.url !== url) return null;
55
+ return boardsCache.boards;
56
+ }
57
+ function setCachedCanvasBoards(url, boards) {
58
+ if (!url) return;
59
+ boardsCache = {
60
+ url,
61
+ boards
62
+ };
63
+ }
64
+ async function fetchMyCanvasBoards(opts) {
65
+ var _a, _b, _c, _d, _e;
66
+ if (!opts.url) {
67
+ return {
68
+ boards: [],
69
+ error: "No canvas index configured for this build."
70
+ };
71
+ }
72
+ let payload;
73
+ try {
74
+ const response = await fetch(opts.url, {
75
+ method: "POST",
76
+ headers: __spreadValues({
77
+ "content-type": "application/json"
78
+ }, opts.token ? {
79
+ authorization: `Bearer ${opts.token}`
80
+ } : {}),
81
+ body: JSON.stringify({
82
+ query: `query MySurfaceChannels($surface: SurfaceType) {
83
+ mySurfaceChannels(surface: $surface) { ${CHANNEL_FIELDS} }
84
+ }`,
85
+ variables: {
86
+ surface: "canvas"
87
+ }
88
+ }),
89
+ signal: opts.signal
90
+ });
91
+ if (response.status === 401 || response.status === 403) {
92
+ return {
93
+ boards: [],
94
+ error: "Your session expired. Sign in again to see your canvases."
95
+ };
96
+ }
97
+ if (!response.ok) {
98
+ return {
99
+ boards: [],
100
+ error: `The canvas index is unreachable (HTTP ${response.status}).`
101
+ };
102
+ }
103
+ payload = await response.json();
104
+ } catch (err) {
105
+ if (err instanceof Error && err.name === "AbortError") {
106
+ return {
107
+ boards: [],
108
+ error: null
109
+ };
110
+ }
111
+ return {
112
+ boards: [],
113
+ error: "Could not reach the canvas index. Check your connection."
114
+ };
115
+ }
116
+ if ((_a = payload == null ? void 0 : payload.errors) == null ? void 0 : _a.length) {
117
+ const raw = ((_b = payload.errors[0]) == null ? void 0 : _b.message) || "";
118
+ const isAuth = /tenantid|unauthenticated|unauthorized|not authorised|forbidden/i.test(raw);
119
+ return {
120
+ boards: [],
121
+ error: isAuth ? "Your session expired. Sign in again to see your canvases." : raw || "The canvas index rejected the request."
122
+ };
123
+ }
124
+ const rows = (_d = (_c = payload == null ? void 0 : payload.data) == null ? void 0 : _c.mySurfaceChannels) != null ? _d : [];
125
+ const boards = [];
126
+ for (const row of rows) {
127
+ const channelId = typeof (row == null ? void 0 : row.channelId) === "string" ? row.channelId : "";
128
+ if (!channelId || channelId === SCRATCH_BOARD_ID) continue;
129
+ const title = typeof (row == null ? void 0 : row.title) === "string" ? row.title : void 0;
130
+ const board = (_e = parseBoardState(coerceMetadata(row == null ? void 0 : row.metadata))) != null ? _e : emptyBoardState(title);
131
+ if (board.items.length === 0) continue;
132
+ boards.push({
133
+ channelId,
134
+ title: board.title || title,
135
+ savedAt: board.savedAt,
136
+ itemCount: board.items.length,
137
+ board
138
+ });
139
+ }
140
+ boards.sort((a, b) => b.savedAt - a.savedAt);
141
+ setCachedCanvasBoards(opts.url, boards);
142
+ return {
143
+ boards,
144
+ error: null
145
+ };
146
+ }
147
+ function coerceMetadata(value) {
148
+ if (typeof value !== "string") return value;
149
+ try {
150
+ return JSON.parse(value);
151
+ } catch (e) {
152
+ return null;
153
+ }
154
+ }export{fetchMyCanvasBoards,getCachedCanvasBoards,setCachedCanvasBoards,surfaceIndexUrl,surfaceIndexUrlFromTemplate};//# sourceMappingURL=surfaceIndex.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"surfaceIndex.js","sources":["../../../src/features/canvas/surfaceIndex.ts"],"sourcesContent":["/**\n * Native reader for the canvas boards in the SurfaceChannel index.\n *\n * The index (`servers/surface-index-subgraph-server`) is the ONLY store for a\n * canvas: the whole `PersistedCanvasState` rides in the row's `metadata`, and\n * `mySurfaceChannels` enumerates the caller's boards. Rows are per-USER, so the\n * phone — signed in as the same user — sees exactly what the web surface sees.\n *\n * This is the native twin of `@yantra/canvas-surface`'s `state/boards.ts`\n * (`pullBoards`). It is copied rather than imported for the same reason\n * `canvasCore.ts` is: that package builds for the DOM (vite, `import.meta.env`,\n * `window.location`), and importing it would drag the web toolchain into Metro.\n * The contract that matters is the wire shape, which both sides pin.\n *\n * Reads are best-effort: an unreachable index yields an error string for the UI\n * rather than throwing, matching how the web surface degrades.\n */\nimport { emptyBoardState, parseBoardState, type PersistedCanvasState } from './canvasCore';\n\n/** Fields the index returns for a channel row (mirror of CHANNEL_FIELDS). */\nconst CHANNEL_FIELDS = 'id surface channelId title metadata updatedAt';\n\n/**\n * The web surface's `/c/new` scratch board. It is not a real board (no host\n * session backs it), and `pullBoards` skips it — so must this, or an empty\n * scratch row would shadow the user's actual canvases.\n */\nconst SCRATCH_BOARD_ID = 'global';\n\nexport interface CanvasBoardSummary {\n channelId: string;\n title?: string;\n savedAt: number;\n itemCount: number;\n board: PersistedCanvasState;\n}\n\n/**\n * Where the index lives for this environment.\n *\n * Mirrors the surface-kit fallback: the index is a sibling host of the backend,\n * so drop the backend's first label and prefix `surface-index-backend.`\n * (`ideback.yantra-app-v1.cdebase.dev` -> `surface-index-backend.yantra-app-v1.cdebase.dev`).\n * An explicit URL always wins, so a non-standard environment sets one env var\n * instead of needing a code change.\n */\nexport function surfaceIndexUrl(graphqlUrl: string, explicit?: string): string {\n const override = (explicit || '').trim();\n if (override) return override;\n\n const base = (graphqlUrl || '').trim();\n if (!base) return '';\n try {\n const { protocol, host } = parseOrigin(base);\n const labels = host.split('.');\n if (labels.length < 3) return '';\n return `${protocol}//surface-index-backend.${labels.slice(1).join('.')}/graphql`;\n } catch {\n return '';\n }\n}\n\n/**\n * Index GraphQL URL from the same SURFACE_URL_TEMPLATE the native remotes use\n * (`https://{slug}-surface.<cluster>/` → `https://surface-index-backend.<cluster>/graphql`).\n * Local GRAPHQL_URL cannot derive a host; this is the develop-lane fallback.\n */\nexport function surfaceIndexUrlFromTemplate(template: string): string {\n const trimmed = (template || '').trim();\n const match = trimmed.match(/^(https?):\\/\\/\\{slug\\}-surface\\.([^/]+)/i);\n if (!match) return '';\n const host = match[2].replace(/\\/+$/, '');\n if (!host) return '';\n return `${match[1].toLowerCase()}://surface-index-backend.${host}/graphql`;\n}\n\nfunction parseOrigin(url: string): { protocol: string; host: string } {\n // RN has URL via react-native-url-polyfill (imported in the app entry), but\n // this module is also loaded by jest and the surface build, so parse by hand.\n const match = url.match(/^(https?:)\\/\\/([^/]+)/i);\n if (!match) throw new Error(`unparseable url: ${url}`);\n return { protocol: match[1].toLowerCase(), host: match[2].toLowerCase() };\n}\n\nexport interface FetchBoardsOptions {\n url: string;\n token?: string | null;\n signal?: AbortSignal;\n}\n\nexport interface FetchBoardsResult {\n boards: CanvasBoardSummary[];\n /** Plain sentence for the UI; null when the read succeeded. */\n error: string | null;\n}\n\n/**\n * The caller's canvases, newest first.\n *\n * Empty boards are dropped, matching web `pullBoards` — the grid only shows\n * canvases with media. Only the scratch `global` row is skipped for the same\n * reason as web (it would merge every `/c/new` scratch into one shared row).\n */\nexport async function fetchMyCanvasBoards(opts: FetchBoardsOptions): Promise<FetchBoardsResult> {\n if (!opts.url) {\n return { boards: [], error: 'No canvas index configured for this build.' };\n }\n\n let payload: SurfaceIndexResponse;\n try {\n const response = await fetch(opts.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),\n },\n body: JSON.stringify({\n query: `query MySurfaceChannels($surface: SurfaceType) {\n mySurfaceChannels(surface: $surface) { ${CHANNEL_FIELDS} }\n }`,\n variables: { surface: 'canvas' },\n }),\n signal: opts.signal,\n });\n\n if (response.status === 401 || response.status === 403) {\n return { boards: [], error: 'Your session expired. Sign in again to see your canvases.' };\n }\n if (!response.ok) {\n return { boards: [], error: `The canvas index is unreachable (HTTP ${response.status}).` };\n }\n payload = (await response.json()) as SurfaceIndexResponse;\n } catch (err) {\n if (err instanceof Error && err.name === 'AbortError') {\n return { boards: [], error: null };\n }\n return { boards: [], error: 'Could not reach the canvas index. Check your connection.' };\n }\n\n if (payload?.errors?.length) {\n // GraphQL errors arrive with HTTP 200. The index reports an unauthenticated\n // caller as \"Missing tenantId in userContext\", which is a developer\n // sentence - say what the user can actually do about it.\n const raw = payload.errors[0]?.message || '';\n const isAuth = /tenantid|unauthenticated|unauthorized|not authorised|forbidden/i.test(raw);\n return {\n boards: [],\n error: isAuth\n ? 'Your session expired. Sign in again to see your canvases.'\n : raw || 'The canvas index rejected the request.',\n };\n }\n\n const rows = payload?.data?.mySurfaceChannels ?? [];\n const boards: CanvasBoardSummary[] = [];\n for (const row of rows) {\n const channelId = typeof row?.channelId === 'string' ? row.channelId : '';\n if (!channelId || channelId === SCRATCH_BOARD_ID) continue;\n const title = typeof row?.title === 'string' ? row.title : undefined;\n const board = parseBoardState(coerceMetadata(row?.metadata)) ?? emptyBoardState(title);\n if (board.items.length === 0) continue;\n boards.push({\n channelId,\n title: board.title || title,\n savedAt: board.savedAt,\n itemCount: board.items.length,\n board,\n });\n }\n\n boards.sort((a, b) => b.savedAt - a.savedAt);\n return { boards, error: null };\n}\n\nexport interface PushBoardOptions {\n url: string;\n token?: string | null;\n channelId: string;\n state: PersistedCanvasState;\n}\n\n/**\n * Write one board to the index. Twin of canvas-surface `pushBoard`:\n * `registerSurfaceChannel` is the idempotent upsert (update throws when the\n * row does not exist). Metadata is replaced wholesale — send the complete state.\n *\n * Returns false when the id is unsyncable (`global` / empty) or the write fails.\n * Never throws.\n */\nexport async function pushCanvasBoard(opts: PushBoardOptions): Promise<boolean> {\n const channelId = opts.channelId?.trim();\n if (!channelId || channelId === SCRATCH_BOARD_ID) return false;\n if (!opts.url) return false;\n\n try {\n const response = await fetch(opts.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),\n },\n body: JSON.stringify({\n query: `mutation RegisterSurfaceChannel($input: RegisterSurfaceChannelInput!) {\n registerSurfaceChannel(input: $input) { ${CHANNEL_FIELDS} }\n }`,\n variables: {\n input: {\n surface: 'canvas',\n channelId,\n sessionId: channelId,\n ...(opts.state.title ? { title: opts.state.title } : {}),\n metadata: opts.state,\n },\n },\n }),\n });\n if (!response.ok) return false;\n const payload = (await response.json()) as {\n data?: { registerSurfaceChannel?: { id?: string } | null };\n errors?: Array<{ message?: string }>;\n };\n if (payload?.errors?.length) {\n if (__DEV__) {\n // eslint-disable-next-line no-console\n console.warn('[pushCanvasBoard]', payload.errors[0]?.message);\n }\n return false;\n }\n return Boolean(payload?.data?.registerSurfaceChannel?.id);\n } catch (err) {\n if (__DEV__) {\n // eslint-disable-next-line no-console\n console.warn('[pushCanvasBoard] failed:', err);\n }\n return false;\n }\n}\n\n/**\n * `metadata` is a JSON scalar, which arrives decoded — but a backend that types\n * it as a String hands back the encoded text instead, and a board that silently\n * fails to parse looks exactly like an empty canvas. Accept both.\n */\nfunction coerceMetadata(value: unknown): unknown {\n if (typeof value !== 'string') return value;\n try {\n return JSON.parse(value);\n } catch {\n return null;\n }\n}\n\ninterface SurfaceIndexResponse {\n data?: { mySurfaceChannels?: Array<Record<string, unknown>> | null } | null;\n errors?: Array<{ message?: string }>;\n}\n\n/** Mint a board id the same way the web does: the board IS its channel. */\nexport function newBoardChannelId(): string {\n const rand = () => Math.random().toString(36).slice(2, 10);\n try {\n const bytes = new Uint8Array(8);\n (globalThis.crypto as Crypto | undefined)?.getRandomValues?.(bytes);\n if (bytes.some((b) => b !== 0)) {\n return `canvas-${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`;\n }\n } catch {\n // fall through to Math.random\n }\n return `canvas-${rand()}${rand()}`;\n}\n\n/**\n * Write one board to the index - the native twin of the web's `pushBoard`.\n * `registerSurfaceChannel` because it is the idempotent upsert (update throws\n * when the row does not exist). `metadata` is REPLACED wholesale by the\n * backend, so the complete state goes every time; never send a patch.\n */\nexport async function saveCanvasBoard(opts: {\n url: string;\n token?: string | null;\n channelId: string;\n board: PersistedCanvasState;\n}): Promise<{ ok: boolean; error: string | null }> {\n if (!opts.url) return { ok: false, error: 'No canvas index configured for this build.' };\n try {\n const response = await fetch(opts.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),\n },\n body: JSON.stringify({\n query: `mutation RegisterSurfaceChannel($input: RegisterSurfaceChannelInput!) {\n registerSurfaceChannel(input: $input) { id channelId }\n }`,\n variables: {\n input: {\n surface: 'canvas',\n channelId: opts.channelId,\n title: opts.board.title || 'Untitled canvas',\n metadata: opts.board,\n },\n },\n }),\n });\n if (!response.ok) return { ok: false, error: `Save failed (HTTP ${response.status}).` };\n const payload = (await response.json()) as { errors?: Array<{ message?: string }> };\n if (payload?.errors?.length) return { ok: false, error: payload.errors[0]?.message || 'Save rejected.' };\n return { ok: true, error: null };\n } catch {\n return { ok: false, error: 'Could not reach the canvas index to save.' };\n }\n}\n"],"names":[],"mappings":"AA6CgB,SAAA,eAAA,CAAgB,YAAoB,QAA2B,EAAA;AAC7E,EAAM,MAAA,QAAA,GAAA,CAAY,QAAY,IAAA,EAAA,EAAI,IAAK,EAAA;AACvC,EAAA,IAAI,UAAiB,OAAA,QAAA;AACrB,EAAM,MAAA,IAAA,GAAA,CAAQ,UAAc,IAAA,EAAA,EAAI,IAAK,EAAA;AACrC,EAAI,IAAA,CAAC,MAAa,OAAA,EAAA;AAClB,EAAI,IAAA;AACF,IAAM,MAAA;AAAA,MACJ,QAAA;AAAA,MACA;AAAA,KACF,GAAI,YAAY,IAAI,CAAA;AACpB,IAAM,MAAA,MAAA,GAAS,IAAK,CAAA,KAAA,CAAM,GAAG,CAAA;AAC7B,IAAI,IAAA,MAAA,CAAO,MAAS,GAAA,CAAA,EAAU,OAAA,EAAA;AAC9B,IAAO,OAAA,CAAA,EAAG,QAAQ,CAA2B,wBAAA,EAAA,MAAA,CAAO,MAAM,CAAC,CAAA,CAAE,IAAK,CAAA,GAAG,CAAC,CAAA,QAAA,CAAA;AAAA,GAChE,CAAA,OAAA,CAAA,EAAA;AACN,IAAO,OAAA,EAAA;AAAA;AAEX;AAOO,SAAS,4BAA4B,QAA0B,EAAA;AACpE,EAAM,MAAA,OAAA,GAAA,CAAW,QAAY,IAAA,EAAA,EAAI,IAAK,EAAA;AACtC,EAAM,MAAA,KAAA,GAAQ,OAAQ,CAAA,KAAA,CAAM,0CAA0C,CAAA;AACtE,EAAI,IAAA,CAAC,OAAc,OAAA,EAAA;AACnB,EAAA,MAAM,OAAO,KAAM,CAAA,CAAC,CAAE,CAAA,OAAA,CAAQ,QAAQ,EAAE,CAAA;AACxC,EAAI,IAAA,CAAC,MAAa,OAAA,EAAA;AAClB,EAAA,OAAO,GAAG,KAAM,CAAA,CAAC,EAAE,WAAY,EAAC,4BAA4B,IAAI,CAAA,QAAA,CAAA;AAClE;AACA,SAAS,YAAY,GAGnB,EAAA;AAGA,EAAM,MAAA,KAAA,GAAQ,GAAI,CAAA,KAAA,CAAM,wBAAwB,CAAA;AAChD,EAAA,IAAI,CAAC,KAAO,EAAA,MAAM,IAAI,KAAM,CAAA,CAAA,iBAAA,EAAoB,GAAG,CAAE,CAAA,CAAA;AACrD,EAAO,OAAA;AAAA,IACL,QAAU,EAAA,KAAA,CAAM,CAAC,CAAA,CAAE,WAAY,EAAA;AAAA,IAC/B,IAAM,EAAA,KAAA,CAAM,CAAC,CAAA,CAAE,WAAY;AAAA,GAC7B;AACF"}
1
+ {"version":3,"file":"surfaceIndex.js","sources":["../../../src/features/canvas/surfaceIndex.ts"],"sourcesContent":["/**\n * Native reader for the canvas boards in the SurfaceChannel index.\n *\n * The index (`servers/surface-index-subgraph-server`) is the ONLY store for a\n * canvas: the whole `PersistedCanvasState` rides in the row's `metadata`, and\n * `mySurfaceChannels` enumerates the caller's boards. Rows are per-USER, so the\n * phone — signed in as the same user — sees exactly what the web surface sees.\n *\n * This is the native twin of `@yantra/canvas-surface`'s `state/boards.ts`\n * (`pullBoards`). It is copied rather than imported for the same reason\n * `canvasCore.ts` is: that package builds for the DOM (vite, `import.meta.env`,\n * `window.location`), and importing it would drag the web toolchain into Metro.\n * The contract that matters is the wire shape, which both sides pin.\n *\n * Reads are best-effort: an unreachable index yields an error string for the UI\n * rather than throwing, matching how the web surface degrades.\n */\nimport { emptyBoardState, parseBoardState, type PersistedCanvasState } from './canvasCore';\n\n/** Fields the index returns for a channel row (mirror of CHANNEL_FIELDS). */\nconst CHANNEL_FIELDS = 'id surface channelId title metadata updatedAt';\n\n/**\n * The web surface's `/c/new` scratch board. It is not a real board (no host\n * session backs it), and `pullBoards` skips it — so must this, or an empty\n * scratch row would shadow the user's actual canvases.\n */\nconst SCRATCH_BOARD_ID = 'global';\n\nexport interface CanvasBoardSummary {\n channelId: string;\n title?: string;\n savedAt: number;\n itemCount: number;\n board: PersistedCanvasState;\n}\n\n/**\n * Where the index lives for this environment.\n *\n * Mirrors the surface-kit fallback: the index is a sibling host of the backend,\n * so drop the backend's first label and prefix `surface-index-backend.`\n * (`ideback.yantra-app-v1.cdebase.dev` -> `surface-index-backend.yantra-app-v1.cdebase.dev`).\n * An explicit URL always wins, so a non-standard environment sets one env var\n * instead of needing a code change.\n */\nexport function surfaceIndexUrl(graphqlUrl: string, explicit?: string): string {\n const override = (explicit || '').trim();\n if (override) return override;\n\n const base = (graphqlUrl || '').trim();\n if (!base) return '';\n try {\n const { protocol, host } = parseOrigin(base);\n const labels = host.split('.');\n if (labels.length < 3) return '';\n return `${protocol}//surface-index-backend.${labels.slice(1).join('.')}/graphql`;\n } catch {\n return '';\n }\n}\n\n/**\n * Index GraphQL URL from the same SURFACE_URL_TEMPLATE the native remotes use\n * (`https://{slug}-surface.<cluster>/` → `https://surface-index-backend.<cluster>/graphql`).\n * Local GRAPHQL_URL cannot derive a host; this is the develop-lane fallback.\n */\nexport function surfaceIndexUrlFromTemplate(template: string): string {\n const trimmed = (template || '').trim();\n const match = trimmed.match(/^(https?):\\/\\/\\{slug\\}-surface\\.([^/]+)/i);\n if (!match) return '';\n const host = match[2].replace(/\\/+$/, '');\n if (!host) return '';\n return `${match[1].toLowerCase()}://surface-index-backend.${host}/graphql`;\n}\n\nfunction parseOrigin(url: string): { protocol: string; host: string } {\n // RN has URL via react-native-url-polyfill (imported in the app entry), but\n // this module is also loaded by jest and the surface build, so parse by hand.\n const match = url.match(/^(https?:)\\/\\/([^/]+)/i);\n if (!match) throw new Error(`unparseable url: ${url}`);\n return { protocol: match[1].toLowerCase(), host: match[2].toLowerCase() };\n}\n\nexport interface FetchBoardsOptions {\n url: string;\n token?: string | null;\n signal?: AbortSignal;\n}\n\nexport interface FetchBoardsResult {\n boards: CanvasBoardSummary[];\n /** Plain sentence for the UI; null when the read succeeded. */\n error: string | null;\n}\n\n/** In-memory board list so revisiting Canvas Native does not re-flash the spinner. */\nlet boardsCache: { url: string; boards: CanvasBoardSummary[] } | null = null;\n\nexport function getCachedCanvasBoards(url: string): CanvasBoardSummary[] | null {\n if (!url || !boardsCache || boardsCache.url !== url) return null;\n return boardsCache.boards;\n}\n\nexport function setCachedCanvasBoards(url: string, boards: CanvasBoardSummary[]): void {\n if (!url) return;\n boardsCache = { url, boards };\n}\n\n/**\n * The caller's canvases, newest first.\n *\n * Empty boards are dropped, matching web `pullBoards` — the grid only shows\n * canvases with media. Only the scratch `global` row is skipped for the same\n * reason as web (it would merge every `/c/new` scratch into one shared row).\n */\nexport async function fetchMyCanvasBoards(opts: FetchBoardsOptions): Promise<FetchBoardsResult> {\n if (!opts.url) {\n return { boards: [], error: 'No canvas index configured for this build.' };\n }\n\n let payload: SurfaceIndexResponse;\n try {\n const response = await fetch(opts.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),\n },\n body: JSON.stringify({\n query: `query MySurfaceChannels($surface: SurfaceType) {\n mySurfaceChannels(surface: $surface) { ${CHANNEL_FIELDS} }\n }`,\n variables: { surface: 'canvas' },\n }),\n signal: opts.signal,\n });\n\n if (response.status === 401 || response.status === 403) {\n return { boards: [], error: 'Your session expired. Sign in again to see your canvases.' };\n }\n if (!response.ok) {\n return { boards: [], error: `The canvas index is unreachable (HTTP ${response.status}).` };\n }\n payload = (await response.json()) as SurfaceIndexResponse;\n } catch (err) {\n if (err instanceof Error && err.name === 'AbortError') {\n return { boards: [], error: null };\n }\n return { boards: [], error: 'Could not reach the canvas index. Check your connection.' };\n }\n\n if (payload?.errors?.length) {\n // GraphQL errors arrive with HTTP 200. The index reports an unauthenticated\n // caller as \"Missing tenantId in userContext\", which is a developer\n // sentence - say what the user can actually do about it.\n const raw = payload.errors[0]?.message || '';\n const isAuth = /tenantid|unauthenticated|unauthorized|not authorised|forbidden/i.test(raw);\n return {\n boards: [],\n error: isAuth\n ? 'Your session expired. Sign in again to see your canvases.'\n : raw || 'The canvas index rejected the request.',\n };\n }\n\n const rows = payload?.data?.mySurfaceChannels ?? [];\n const boards: CanvasBoardSummary[] = [];\n for (const row of rows) {\n const channelId = typeof row?.channelId === 'string' ? row.channelId : '';\n if (!channelId || channelId === SCRATCH_BOARD_ID) continue;\n const title = typeof row?.title === 'string' ? row.title : undefined;\n const board = parseBoardState(coerceMetadata(row?.metadata)) ?? emptyBoardState(title);\n if (board.items.length === 0) continue;\n boards.push({\n channelId,\n title: board.title || title,\n savedAt: board.savedAt,\n itemCount: board.items.length,\n board,\n });\n }\n\n boards.sort((a, b) => b.savedAt - a.savedAt);\n setCachedCanvasBoards(opts.url, boards);\n return { boards, error: null };\n}\n\nexport interface PushBoardOptions {\n url: string;\n token?: string | null;\n channelId: string;\n state: PersistedCanvasState;\n}\n\n/**\n * Write one board to the index. Twin of canvas-surface `pushBoard`:\n * `registerSurfaceChannel` is the idempotent upsert (update throws when the\n * row does not exist). Metadata is replaced wholesale — send the complete state.\n *\n * Returns false when the id is unsyncable (`global` / empty) or the write fails.\n * Never throws.\n */\nexport async function pushCanvasBoard(opts: PushBoardOptions): Promise<boolean> {\n const channelId = opts.channelId?.trim();\n if (!channelId || channelId === SCRATCH_BOARD_ID) return false;\n if (!opts.url) return false;\n\n try {\n const response = await fetch(opts.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),\n },\n body: JSON.stringify({\n query: `mutation RegisterSurfaceChannel($input: RegisterSurfaceChannelInput!) {\n registerSurfaceChannel(input: $input) { ${CHANNEL_FIELDS} }\n }`,\n variables: {\n input: {\n surface: 'canvas',\n channelId,\n sessionId: channelId,\n ...(opts.state.title ? { title: opts.state.title } : {}),\n metadata: opts.state,\n },\n },\n }),\n });\n if (!response.ok) return false;\n const payload = (await response.json()) as {\n data?: { registerSurfaceChannel?: { id?: string } | null };\n errors?: Array<{ message?: string }>;\n };\n if (payload?.errors?.length) {\n if (__DEV__) {\n // eslint-disable-next-line no-console\n console.warn('[pushCanvasBoard]', payload.errors[0]?.message);\n }\n return false;\n }\n return Boolean(payload?.data?.registerSurfaceChannel?.id);\n } catch (err) {\n if (__DEV__) {\n // eslint-disable-next-line no-console\n console.warn('[pushCanvasBoard] failed:', err);\n }\n return false;\n }\n}\n\n/**\n * `metadata` is a JSON scalar, which arrives decoded — but a backend that types\n * it as a String hands back the encoded text instead, and a board that silently\n * fails to parse looks exactly like an empty canvas. Accept both.\n */\nfunction coerceMetadata(value: unknown): unknown {\n if (typeof value !== 'string') return value;\n try {\n return JSON.parse(value);\n } catch {\n return null;\n }\n}\n\ninterface SurfaceIndexResponse {\n data?: { mySurfaceChannels?: Array<Record<string, unknown>> | null } | null;\n errors?: Array<{ message?: string }>;\n}\n\n/** Mint a board id the same way the web does: the board IS its channel. */\nexport function newBoardChannelId(): string {\n const rand = () => Math.random().toString(36).slice(2, 10);\n try {\n const bytes = new Uint8Array(8);\n (globalThis.crypto as Crypto | undefined)?.getRandomValues?.(bytes);\n if (bytes.some((b) => b !== 0)) {\n return `canvas-${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`;\n }\n } catch {\n // fall through to Math.random\n }\n return `canvas-${rand()}${rand()}`;\n}\n\n/**\n * Write one board to the index - the native twin of the web's `pushBoard`.\n * `registerSurfaceChannel` because it is the idempotent upsert (update throws\n * when the row does not exist). `metadata` is REPLACED wholesale by the\n * backend, so the complete state goes every time; never send a patch.\n */\nexport async function saveCanvasBoard(opts: {\n url: string;\n token?: string | null;\n channelId: string;\n board: PersistedCanvasState;\n}): Promise<{ ok: boolean; error: string | null }> {\n if (!opts.url) return { ok: false, error: 'No canvas index configured for this build.' };\n try {\n const response = await fetch(opts.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),\n },\n body: JSON.stringify({\n query: `mutation RegisterSurfaceChannel($input: RegisterSurfaceChannelInput!) {\n registerSurfaceChannel(input: $input) { id channelId }\n }`,\n variables: {\n input: {\n surface: 'canvas',\n channelId: opts.channelId,\n title: opts.board.title || 'Untitled canvas',\n metadata: opts.board,\n },\n },\n }),\n });\n if (!response.ok) return { ok: false, error: `Save failed (HTTP ${response.status}).` };\n const payload = (await response.json()) as { errors?: Array<{ message?: string }> };\n if (payload?.errors?.length) return { ok: false, error: payload.errors[0]?.message || 'Save rejected.' };\n return { ok: true, error: null };\n } catch {\n return { ok: false, error: 'Could not reach the canvas index to save.' };\n }\n}\n"],"names":[],"mappings":"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"}
@@ -1,10 +1,10 @@
1
- import {useState,useMemo,useRef,useEffect}from'react';import {config}from'../../config/env-config.js';import {usePrerequisiteIds}from'../../hooks/usePrerequisiteIds.js';import {useObtainSystemToken}from'../../hooks/useObtainSystemToken.js';import {resolveSurfaceAuthGraphqlUrl}from'../../hooks/resolveSurfaceAuthGraphqlUrl.js';import {warmWebBuilderSession}from'../../screens/WebBuilder/webbuilderWarmCache.js';import {surfaceIndexUrl,surfaceIndexUrlFromTemplate}from'./surfaceIndex.js';function backendsEqual(a, b) {
1
+ import {useMemo,useState,useRef,useCallback,useEffect}from'react';import {AppState}from'react-native';import {config}from'../../config/env-config.js';import {usePrerequisiteIds}from'../../hooks/usePrerequisiteIds.js';import {useObtainSystemToken}from'../../hooks/useObtainSystemToken.js';import {resolveSurfaceAuthGraphqlUrl}from'../../hooks/resolveSurfaceAuthGraphqlUrl.js';import {getWarmWebBuilderSession,warmWebBuilderSession,clearWarmWebBuilderSession}from'../../screens/WebBuilder/webbuilderWarmCache.js';import {surfaceIndexUrl,surfaceIndexUrlFromTemplate}from'./surfaceIndex.js';import {tokenExpiryMs}from'./sessionIdentity.js';const RENEW_LEAD_MS = 9e4;
2
+ const MAX_RENEW_DELAY_MS = 40 * 60 * 1e3;
3
+ const MIN_RENEW_DELAY_MS = 3e4;
4
+ function backendsEqual(a, b) {
2
5
  return a.trim().replace(/\/$/, "") === b.trim().replace(/\/$/, "");
3
6
  }
4
7
  function useCanvasSession() {
5
- const [token, setToken] = useState(null);
6
- const [error, setError] = useState(null);
7
- const [ready, setReady] = useState(false);
8
8
  const {
9
9
  orgName,
10
10
  projectId,
@@ -23,6 +23,13 @@ function useCanvasSession() {
23
23
  if (derived) return derived;
24
24
  return surfaceIndexUrlFromTemplate(config.SURFACE_URL_TEMPLATE);
25
25
  }, [canvasGraphqlUrl]);
26
+ const seeded = getWarmWebBuilderSession(canvasGraphqlUrl);
27
+ const [token, setToken] = useState(() => {
28
+ var _a;
29
+ return (_a = seeded == null ? void 0 : seeded.token) != null ? _a : null;
30
+ });
31
+ const [error, setError] = useState(null);
32
+ const [ready, setReady] = useState(() => Boolean(seeded == null ? void 0 : seeded.token));
26
33
  const idsRef = useRef({
27
34
  orgName,
28
35
  projectId,
@@ -33,37 +40,76 @@ function useCanvasSession() {
33
40
  projectId,
34
41
  tagId
35
42
  };
43
+ const cancelledRef = useRef(false);
44
+ const renewTimerRef = useRef(null);
45
+ const tokenExpRef = useRef(null);
46
+ const failuresRef = useRef(0);
47
+ const clearRenewTimer = useCallback(() => {
48
+ if (renewTimerRef.current) {
49
+ clearTimeout(renewTimerRef.current);
50
+ renewTimerRef.current = null;
51
+ }
52
+ }, []);
53
+ const mint = useCallback(async (forceFresh) => {
54
+ if (forceFresh) clearWarmWebBuilderSession();
55
+ try {
56
+ const session = await warmWebBuilderSession({
57
+ graphqlUrl: canvasGraphqlUrl,
58
+ orgName: crossBackendAuth ? void 0 : idsRef.current.orgName,
59
+ projectId: crossBackendAuth ? void 0 : idsRef.current.projectId,
60
+ tagId: crossBackendAuth ? void 0 : idsRef.current.tagId,
61
+ obtainSystemToken: crossBackendAuth ? void 0 : obtainSystemToken,
62
+ discoverScope: crossBackendAuth
63
+ });
64
+ if (cancelledRef.current) return;
65
+ setToken(session.token);
66
+ setError(null);
67
+ failuresRef.current = 0;
68
+ const expMs = tokenExpiryMs(session.token);
69
+ tokenExpRef.current = expMs;
70
+ const delay = expMs ? Math.min(MAX_RENEW_DELAY_MS, Math.max(MIN_RENEW_DELAY_MS, expMs - Date.now() - RENEW_LEAD_MS)) : MAX_RENEW_DELAY_MS;
71
+ clearRenewTimer();
72
+ renewTimerRef.current = setTimeout(() => {
73
+ void mint(true);
74
+ }, delay);
75
+ } catch (err) {
76
+ if (cancelledRef.current) return;
77
+ const message = err instanceof Error ? err.message : String(err);
78
+ const waitingForIds = !crossBackendAuth && (/waiting for org\/project/i.test(message) || !idsRef.current.orgName);
79
+ if (waitingForIds) return;
80
+ setError("Could not sign in to load your canvases.");
81
+ const backoff = Math.min(MAX_RENEW_DELAY_MS, MIN_RENEW_DELAY_MS * 2 ** failuresRef.current);
82
+ failuresRef.current += 1;
83
+ clearRenewTimer();
84
+ renewTimerRef.current = setTimeout(() => {
85
+ void mint(true);
86
+ }, backoff);
87
+ } finally {
88
+ if (!cancelledRef.current) setReady(true);
89
+ }
90
+ }, [obtainSystemToken, canvasGraphqlUrl, crossBackendAuth, clearRenewTimer]);
36
91
  useEffect(() => {
37
- let cancelled = false;
92
+ cancelledRef.current = false;
38
93
  if (__DEV__) {
39
94
  console.log("[useCanvasSession] graphql=", canvasGraphqlUrl, "index=", indexUrl, "crossBackend=", crossBackendAuth);
40
95
  }
41
- (async () => {
42
- try {
43
- const session = await warmWebBuilderSession({
44
- graphqlUrl: canvasGraphqlUrl,
45
- orgName: crossBackendAuth ? void 0 : idsRef.current.orgName,
46
- projectId: crossBackendAuth ? void 0 : idsRef.current.projectId,
47
- tagId: crossBackendAuth ? void 0 : idsRef.current.tagId,
48
- obtainSystemToken: crossBackendAuth ? void 0 : obtainSystemToken,
49
- discoverScope: crossBackendAuth
50
- });
51
- if (cancelled) return;
52
- setToken(session.token);
53
- setError(null);
54
- } catch (err) {
55
- if (cancelled) return;
56
- const message = err instanceof Error ? err.message : String(err);
57
- if (/waiting for org\/project/i.test(message) || !idsRef.current.orgName) return;
58
- setError("Could not sign in to load your canvases.");
59
- } finally {
60
- if (!cancelled) setReady(true);
61
- }
62
- })();
96
+ void mint(false);
97
+ const appStateSub = AppState.addEventListener("change", (next) => {
98
+ if (next !== "active") return;
99
+ const expMs = tokenExpRef.current;
100
+ if (!expMs || Date.now() >= expMs - RENEW_LEAD_MS) void mint(true);
101
+ });
63
102
  return () => {
64
- cancelled = true;
103
+ cancelledRef.current = true;
104
+ clearRenewTimer();
105
+ appStateSub.remove();
65
106
  };
66
- }, [obtainSystemToken, orgName, projectId, canvasGraphqlUrl, indexUrl, crossBackendAuth]);
107
+ }, [mint, canvasGraphqlUrl, indexUrl, crossBackendAuth, clearRenewTimer]);
108
+ useEffect(() => {
109
+ if (token) return;
110
+ if (!crossBackendAuth && (!orgName || !projectId)) return;
111
+ void mint(false);
112
+ }, [token, orgName, projectId, crossBackendAuth, mint]);
67
113
  return {
68
114
  token,
69
115
  indexUrl,
@@ -1 +1 @@
1
- {"version":3,"file":"useCanvasSession.js","sources":["../../../src/features/canvas/useCanvasSession.ts"],"sourcesContent":["/**\n * The host half of the canvas: session + endpoint, nothing else.\n *\n * Everything about READING a canvas lives in the federated remote\n * (`CanvasExperience`) so it can be fixed by a surface deploy. What the remote\n * cannot know is this environment's index URL and a token the index accepts —\n * that is what this resolves and hands over.\n *\n * Auth reuses the WebBuilder warm session. When GRAPHQL_URL is local, that\n * session is minted against CANVAS_BACKEND_GRAPHQL_URL (discoverScope) so the\n * cluster canvas index accepts the JWT — a local token 401s there.\n */\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { config } from '../../config/env-config';\nimport { usePrerequisiteIds } from '../../hooks/usePrerequisiteIds';\nimport { useObtainSystemToken } from '../../hooks/useObtainSystemToken';\nimport { resolveSurfaceAuthGraphqlUrl } from '../../hooks/resolveSurfaceAuthGraphqlUrl';\nimport { warmWebBuilderSession } from '../../screens/WebBuilder/webbuilderWarmCache';\nimport { surfaceIndexUrl, surfaceIndexUrlFromTemplate } from './surfaceIndex';\n\nfunction backendsEqual(a: string, b: string): boolean {\n return a.trim().replace(/\\/$/, '') === b.trim().replace(/\\/$/, '');\n}\n\nexport interface CanvasSession {\n token: string | null;\n indexUrl: string;\n /** Plain sentence when the session could not be established, else null. */\n error: string | null;\n ready: boolean;\n}\n\nexport function useCanvasSession(): CanvasSession {\n const [token, setToken] = useState<string | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [ready, setReady] = useState(false);\n\n const { orgName, projectId, tagId } = usePrerequisiteIds();\n\n // Local GRAPHQL_URL (localhost) cannot derive a surface-index host, and a\n // locally minted JWT 401s against the cluster index. Same pairing Web\n // Builder uses: mint against CANVAS_BACKEND_GRAPHQL_URL, derive the index\n // from that backend. Staging/prod GRAPHQL_URL already is that backend.\n const canvasGraphqlUrl = useMemo(\n () =>\n resolveSurfaceAuthGraphqlUrl(config.CANVAS_SURFACE_URL, config.GRAPHQL_URL, {\n CANVAS_SURFACE_URL: config.CANVAS_SURFACE_URL,\n CANVAS_BACKEND_GRAPHQL_URL: config.CANVAS_BACKEND_GRAPHQL_URL,\n }),\n [],\n );\n const crossBackendAuth = !backendsEqual(canvasGraphqlUrl, config.GRAPHQL_URL);\n const { obtainSystemToken } = useObtainSystemToken(!crossBackendAuth);\n\n const indexUrl = useMemo(() => {\n const derived = surfaceIndexUrl(canvasGraphqlUrl, config.SURFACE_INDEX_URL);\n if (derived) return derived;\n return surfaceIndexUrlFromTemplate(config.SURFACE_URL_TEMPLATE);\n }, [canvasGraphqlUrl]);\n\n // org/project arrive a beat after mount; hold the latest in a ref so a\n // resolved id does not restart an in-flight mint.\n const idsRef = useRef({ orgName, projectId, tagId });\n idsRef.current = { orgName, projectId, tagId };\n\n useEffect(() => {\n let cancelled = false;\n\n if (__DEV__) {\n // eslint-disable-next-line no-console\n console.log(\n '[useCanvasSession] graphql=',\n canvasGraphqlUrl,\n 'index=',\n indexUrl,\n 'crossBackend=',\n crossBackendAuth,\n );\n }\n\n (async () => {\n try {\n const session = await warmWebBuilderSession({\n graphqlUrl: canvasGraphqlUrl,\n orgName: crossBackendAuth ? undefined : idsRef.current.orgName,\n projectId: crossBackendAuth ? undefined : idsRef.current.projectId,\n tagId: crossBackendAuth ? undefined : idsRef.current.tagId,\n obtainSystemToken: crossBackendAuth ? undefined : obtainSystemToken,\n discoverScope: crossBackendAuth,\n });\n if (cancelled) return;\n setToken(session.token);\n setError(null);\n } catch (err) {\n if (cancelled) return;\n const message = err instanceof Error ? err.message : String(err);\n // Missing org/project is a not-yet, not a failure: leave the\n // experience on its loading state rather than showing an error.\n if (/waiting for org\\/project/i.test(message) || !idsRef.current.orgName) return;\n setError('Could not sign in to load your canvases.');\n } finally {\n if (!cancelled) setReady(true);\n }\n })();\n\n return () => {\n cancelled = true;\n };\n }, [obtainSystemToken, orgName, projectId, canvasGraphqlUrl, indexUrl, crossBackendAuth]);\n\n return { token, indexUrl, error, ready };\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"useCanvasSession.js","sources":["../../../src/features/canvas/useCanvasSession.ts"],"sourcesContent":["/**\n * The host half of the canvas: session + endpoint, nothing else.\n *\n * Everything about READING a canvas lives in the federated remote\n * (`CanvasExperience`) so it can be fixed by a surface deploy. What the remote\n * cannot know is this environment's index URL and a token the index accepts —\n * that is what this resolves and hands over.\n *\n * Auth reuses the WebBuilder warm session. When GRAPHQL_URL is local, that\n * session is minted against CANVAS_BACKEND_GRAPHQL_URL (discoverScope) so the\n * cluster canvas index accepts the JWT — a local token 401s there.\n */\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { AppState } from 'react-native';\nimport { config } from '../../config/env-config';\nimport { usePrerequisiteIds } from '../../hooks/usePrerequisiteIds';\nimport { useObtainSystemToken } from '../../hooks/useObtainSystemToken';\nimport { resolveSurfaceAuthGraphqlUrl } from '../../hooks/resolveSurfaceAuthGraphqlUrl';\nimport {\n clearWarmWebBuilderSession,\n getWarmWebBuilderSession,\n warmWebBuilderSession,\n} from '../../screens/WebBuilder/webbuilderWarmCache';\nimport { surfaceIndexUrl, surfaceIndexUrlFromTemplate } from './surfaceIndex';\nimport { tokenExpiryMs } from './sessionIdentity';\n\n/** Re-mint this long before the token's `exp` so a call never rides an\n * about-to-die JWT. */\nconst RENEW_LEAD_MS = 90_000;\n/** Ceiling on the renewal timer - also the fallback cadence when the token\n * carries no readable `exp`. Comfortably under the 45-min warm-cache TTL so a\n * scheduled renewal always forces a genuinely fresh mint. */\nconst MAX_RENEW_DELAY_MS = 40 * 60 * 1000;\n/** Never schedule a renewal tighter than this (guards an already-expired token\n * from a hot re-mint loop). */\nconst MIN_RENEW_DELAY_MS = 30_000;\n\nfunction backendsEqual(a: string, b: string): boolean {\n return a.trim().replace(/\\/$/, '') === b.trim().replace(/\\/$/, '');\n}\n\nexport interface CanvasSession {\n token: string | null;\n indexUrl: string;\n /** Plain sentence when the session could not be established, else null. */\n error: string | null;\n ready: boolean;\n}\n\nexport function useCanvasSession(): CanvasSession {\n const { orgName, projectId, tagId } = usePrerequisiteIds();\n\n // Local GRAPHQL_URL (localhost) cannot derive a surface-index host, and a\n // locally minted JWT 401s against the cluster index. Same pairing Web\n // Builder uses: mint against CANVAS_BACKEND_GRAPHQL_URL, derive the index\n // from that backend. Staging/prod GRAPHQL_URL already is that backend.\n const canvasGraphqlUrl = useMemo(\n () =>\n resolveSurfaceAuthGraphqlUrl(config.CANVAS_SURFACE_URL, config.GRAPHQL_URL, {\n CANVAS_SURFACE_URL: config.CANVAS_SURFACE_URL,\n CANVAS_BACKEND_GRAPHQL_URL: config.CANVAS_BACKEND_GRAPHQL_URL,\n }),\n [],\n );\n const crossBackendAuth = !backendsEqual(canvasGraphqlUrl, config.GRAPHQL_URL);\n const { obtainSystemToken } = useObtainSystemToken(!crossBackendAuth);\n\n const indexUrl = useMemo(() => {\n const derived = surfaceIndexUrl(canvasGraphqlUrl, config.SURFACE_INDEX_URL);\n if (derived) return derived;\n return surfaceIndexUrlFromTemplate(config.SURFACE_URL_TEMPLATE);\n }, [canvasGraphqlUrl]);\n\n const seeded = getWarmWebBuilderSession(canvasGraphqlUrl);\n const [token, setToken] = useState<string | null>(() => seeded?.token ?? null);\n const [error, setError] = useState<string | null>(null);\n const [ready, setReady] = useState(() => Boolean(seeded?.token));\n\n // org/project arrive a beat after mount; hold the latest in a ref so a\n // resolved id does not restart an in-flight mint.\n const idsRef = useRef({ orgName, projectId, tagId });\n idsRef.current = { orgName, projectId, tagId };\n\n // Renewal machinery. `mint` re-mints the surface token; on success it\n // schedules the next mint just before the JWT's `exp`, so a mounted\n // surface never rides an expired session. `forceFresh` clears the shared\n // warm cache first, so a renewal actually gets a new token rather than the\n // stale cached one (the cache is a cross-surface dedupe for the first\n // mint, not a source of freshness).\n const cancelledRef = useRef(false);\n const renewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const tokenExpRef = useRef<number | null>(null);\n // Consecutive mint failures, for the self-heal backoff below. Reset to 0 on\n // any successful mint.\n const failuresRef = useRef(0);\n\n const clearRenewTimer = useCallback(() => {\n if (renewTimerRef.current) {\n clearTimeout(renewTimerRef.current);\n renewTimerRef.current = null;\n }\n }, []);\n\n const mint = useCallback(\n async (forceFresh: boolean) => {\n if (forceFresh) clearWarmWebBuilderSession();\n try {\n const session = await warmWebBuilderSession({\n graphqlUrl: canvasGraphqlUrl,\n orgName: crossBackendAuth ? undefined : idsRef.current.orgName,\n projectId: crossBackendAuth ? undefined : idsRef.current.projectId,\n tagId: crossBackendAuth ? undefined : idsRef.current.tagId,\n obtainSystemToken: crossBackendAuth ? undefined : obtainSystemToken,\n discoverScope: crossBackendAuth,\n });\n if (cancelledRef.current) return;\n setToken(session.token);\n setError(null);\n failuresRef.current = 0;\n\n // Schedule the next renewal from the token's own expiry.\n const expMs = tokenExpiryMs(session.token);\n tokenExpRef.current = expMs;\n const delay = expMs\n ? Math.min(MAX_RENEW_DELAY_MS, Math.max(MIN_RENEW_DELAY_MS, expMs - Date.now() - RENEW_LEAD_MS))\n : MAX_RENEW_DELAY_MS;\n clearRenewTimer();\n renewTimerRef.current = setTimeout(() => {\n void mint(true);\n }, delay);\n } catch (err) {\n if (cancelledRef.current) return;\n const message = err instanceof Error ? err.message : String(err);\n // Non-cross-backend: missing org/project is a not-yet, not a\n // failure. The org/project effect re-mints when they arrive, so\n // do not error or schedule a retry here. (Cross-backend derives\n // its own scope via discoverScope, so an empty local orgName is\n // expected and must NOT suppress the retry below.)\n const waitingForIds =\n !crossBackendAuth && (/waiting for org\\/project/i.test(message) || !idsRef.current.orgName);\n if (waitingForIds) return;\n setError('Could not sign in to load your canvases.');\n // Self-heal: a failed mint is usually the Auth0 id_token briefly\n // stale after a background/sleep (the app login token renews on\n // foreground, a beat behind). Retry on a capped exponential\n // backoff with forceFresh, so the surface recovers on its own the\n // moment the login token is fresh again - no force-quit needed.\n const backoff = Math.min(MAX_RENEW_DELAY_MS, MIN_RENEW_DELAY_MS * 2 ** failuresRef.current);\n failuresRef.current += 1;\n clearRenewTimer();\n renewTimerRef.current = setTimeout(() => {\n void mint(true);\n }, backoff);\n } finally {\n if (!cancelledRef.current) setReady(true);\n }\n },\n [obtainSystemToken, canvasGraphqlUrl, crossBackendAuth, clearRenewTimer],\n );\n\n useEffect(() => {\n cancelledRef.current = false;\n if (__DEV__) {\n // eslint-disable-next-line no-console\n console.log(\n '[useCanvasSession] graphql=',\n canvasGraphqlUrl,\n 'index=',\n indexUrl,\n 'crossBackend=',\n crossBackendAuth,\n );\n }\n void mint(false);\n\n // iOS suspends timers in the background; a token can lapse while away.\n // On return, re-mint immediately if it is at/near expiry so the first\n // post-foreground index call already carries a fresh JWT.\n const appStateSub = AppState.addEventListener('change', (next) => {\n if (next !== 'active') return;\n const expMs = tokenExpRef.current;\n if (!expMs || Date.now() >= expMs - RENEW_LEAD_MS) void mint(true);\n });\n\n return () => {\n cancelledRef.current = true;\n clearRenewTimer();\n appStateSub.remove();\n };\n }, [mint, canvasGraphqlUrl, indexUrl, crossBackendAuth, clearRenewTimer]);\n\n // First mint can fail while org/project are still hydrating. Retry once they\n // arrive so we do not sit on the spinner until the user leaves and comes back.\n useEffect(() => {\n if (token) return;\n if (!crossBackendAuth && (!orgName || !projectId)) return;\n void mint(false);\n }, [token, orgName, projectId, crossBackendAuth, mint]);\n\n return { token, indexUrl, error, ready };\n}\n"],"names":[],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import {jsx,jsxs}from'react/jsx-runtime';import {useRef,useMemo,useCallback,useEffect,memo}from'react';import {Keyboard,FlatList,StyleSheet,View,Text}from'react-native';import Markdown from'react-native-markdown-display';import {shouldRenderUserTranscriptTurn,displayUserMessageText}from'../attachments/displayUserMessageText.js';import {MessageAttachmentPreviews}from'../attachments/MessageAttachmentPreviews.js';var __defProp = Object.defineProperty;
1
+ import {jsx,jsxs}from'react/jsx-runtime';import {useRef,useMemo,useCallback,useEffect,memo}from'react';import {View,StyleSheet,FlatList,Platform,Pressable,Text}from'react-native';import {dismissKeyboard}from'../../utils/keyboardController.js';import {useKeyboardLiftHeight}from'../../components/KeyboardComposerDock.js';import Markdown from'react-native-markdown-display';import {shouldRenderUserTranscriptTurn,displayUserMessageText}from'../attachments/displayUserMessageText.js';import {MessageAttachmentPreviews}from'../attachments/MessageAttachmentPreviews.js';var __defProp = Object.defineProperty;
2
2
  var __defProps = Object.defineProperties;
3
3
  var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
4
4
  var __getOwnPropSymbols = Object.getOwnPropertySymbols;
@@ -28,6 +28,8 @@ function ChatTranscript({
28
28
  const listRef = useRef(null);
29
29
  const stickToBottomRef = useRef(true);
30
30
  const isProgrammaticScrollRef = useRef(false);
31
+ const ignoreScrollUntilRef = useRef(0);
32
+ const keyboardHeight = useKeyboardLiftHeight();
31
33
  const historyRows = useMemo(() => messages.filter((msg) => msg.role !== "user" || shouldRenderUserTranscriptTurn(msg.content, msg.attachments)).map((msg) => __spreadProps(__spreadValues({}, msg), {
32
34
  streaming: false
33
35
  })), [messages]);
@@ -42,10 +44,16 @@ function ChatTranscript({
42
44
  }];
43
45
  }, [historyRows, streamingContent]);
44
46
  const scrollToLatest = useCallback((animated) => {
45
- var _a;
46
47
  if (rows.length === 0) return;
47
48
  isProgrammaticScrollRef.current = true;
48
- (_a = listRef.current) == null ? void 0 : _a.scrollToEnd({
49
+ stickToBottomRef.current = true;
50
+ const list = listRef.current;
51
+ if (!list) return;
52
+ list.scrollToEnd({
53
+ animated
54
+ });
55
+ list.scrollToOffset({
56
+ offset: Number.MAX_SAFE_INTEGER,
49
57
  animated
50
58
  });
51
59
  }, [rows.length]);
@@ -55,13 +63,23 @@ function ChatTranscript({
55
63
  return () => clearTimeout(t);
56
64
  }, [rows.length, streamingContent == null ? void 0 : streamingContent.length, scrollToLatest]);
57
65
  useEffect(() => {
58
- const show = Keyboard.addListener("keyboardDidShow", () => {
59
- if (!stickToBottomRef.current) return;
60
- scrollToLatest(true);
61
- });
62
- return () => show.remove();
63
- }, [scrollToLatest]);
66
+ if (keyboardHeight <= 0) return void 0;
67
+ stickToBottomRef.current = true;
68
+ ignoreScrollUntilRef.current = Date.now() + 400;
69
+ const frame = requestAnimationFrame(() => scrollToLatest(false));
70
+ const afterLayout = setTimeout(() => scrollToLatest(false), 64);
71
+ const afterKeyboard = setTimeout(() => scrollToLatest(false), 280);
72
+ return () => {
73
+ cancelAnimationFrame(frame);
74
+ clearTimeout(afterLayout);
75
+ clearTimeout(afterKeyboard);
76
+ };
77
+ }, [keyboardHeight, scrollToLatest]);
64
78
  const handleScroll = useCallback((e) => {
79
+ if (Date.now() < ignoreScrollUntilRef.current) {
80
+ stickToBottomRef.current = true;
81
+ return;
82
+ }
65
83
  const {
66
84
  contentOffset,
67
85
  contentSize,
@@ -81,9 +99,9 @@ function ChatTranscript({
81
99
  const renderItem = useCallback(({
82
100
  item
83
101
  }) => /* @__PURE__ */ jsx(TranscriptRowView, { item, isDark, renderMessageActions: item.streaming ? void 0 : renderMessageActions }), [isDark, renderMessageActions]);
84
- return /* @__PURE__ */ jsx(FlatList, { ref: listRef, data: rows, keyExtractor: (item) => item.id, renderItem, extraData: streamingContent, initialNumToRender: 12, maxToRenderPerBatch: 8, updateCellsBatchingPeriod: 50, windowSize: 7, onScroll: handleScroll, onContentSizeChange: handleContentSizeChange, onLayout: () => {
102
+ return /* @__PURE__ */ jsx(View, { style: styles.list, children: /* @__PURE__ */ jsx(FlatList, { ref: listRef, data: rows, keyExtractor: (item) => item.id, renderItem, extraData: streamingContent, initialNumToRender: 12, maxToRenderPerBatch: 8, updateCellsBatchingPeriod: 50, windowSize: 7, onScroll: handleScroll, onContentSizeChange: handleContentSizeChange, onLayout: () => {
85
103
  if (stickToBottomRef.current) scrollToLatest(false);
86
- }, scrollEventThrottle: 16, keyboardShouldPersistTaps: "always", keyboardDismissMode: "none", automaticallyAdjustKeyboardInsets: false, automaticallyAdjustContentInsets: false, contentInsetAdjustmentBehavior: "never", contentContainerStyle: [styles.listContent, listContentStyle], style: styles.list });
104
+ }, scrollEventThrottle: 16, keyboardShouldPersistTaps: "handled", keyboardDismissMode: Platform.OS === "ios" ? "interactive" : "on-drag", automaticallyAdjustKeyboardInsets: false, automaticallyAdjustContentInsets: false, contentInsetAdjustmentBehavior: "never", contentContainerStyle: [styles.listContent, listContentStyle, styles.listContentFill], style: styles.list }) });
87
105
  }
88
106
  const TranscriptRowView = memo(function TranscriptRowView2({
89
107
  item,
@@ -95,14 +113,14 @@ const TranscriptRowView = memo(function TranscriptRowView2({
95
113
  const attachments = (_a = item.attachments) != null ? _a : [];
96
114
  const text = isUser ? displayUserMessageText(item.content) : item.content;
97
115
  if (isUser) {
98
- return /* @__PURE__ */ jsxs(View, { style: styles.userCol, children: [
116
+ return /* @__PURE__ */ jsxs(Pressable, { style: styles.userCol, onPress: dismissKeyboard, accessible: false, children: [
99
117
  attachments.length > 0 ? /* @__PURE__ */ jsx(MessageAttachmentPreviews, { attachments, align: "end", isDark }) : null,
100
118
  text ? /* @__PURE__ */ jsx(View, { style: styles.userBubble, children: /* @__PURE__ */ jsx(Text, { style: styles.userText, children: text }) }) : null
101
119
  ] });
102
120
  }
103
121
  const markdownColor = isDark ? "#e2e8f0" : "#111827";
104
122
  const codeBg = isDark ? "#1e293b" : "#f3f4f6";
105
- return /* @__PURE__ */ jsxs(View, { style: styles.assistantCol, children: [
123
+ return /* @__PURE__ */ jsxs(Pressable, { style: styles.assistantCol, onPress: dismissKeyboard, accessible: false, children: [
106
124
  attachments.length > 0 ? /* @__PURE__ */ jsx(MessageAttachmentPreviews, { attachments, align: "start", isDark }) : null,
107
125
  text ? item.streaming ? /* @__PURE__ */ jsx(Text, { style: [styles.assistantStreamText, {
108
126
  color: markdownColor
@@ -181,6 +199,10 @@ const styles = StyleSheet.create({
181
199
  paddingBottom: 8,
182
200
  paddingHorizontal: 8
183
201
  },
202
+ listContentFill: {
203
+ flexGrow: 1,
204
+ justifyContent: "flex-end"
205
+ },
184
206
  userCol: {
185
207
  width: "100%",
186
208
  alignItems: "flex-end",