diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx index 0437f9ac..b756bdde 100644 --- a/apps/client/src/components/layouts/global/global-app-shell.tsx +++ b/apps/client/src/components/layouts/global/global-app-shell.tsx @@ -14,6 +14,7 @@ import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar. import { AppHeader } from "@/components/layouts/global/app-header.tsx"; import Aside from "@/components/layouts/global/aside.tsx"; import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx"; +import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx"; import classes from "./app-shell.module.css"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx"; @@ -157,6 +158,10 @@ export default function GlobalAppShell({ {/* Floating AI chat window. Mounted once globally; it is position: fixed and self-hides when closed, so its place in the tree is not critical. */} + {/* Global gitmost native bridge: registers listSpaces / listPages / + createPageWithRecording on window.gitmost so the native host can + create a page with a recording even when no page editor is open. */} + ); } diff --git a/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx b/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx new file mode 100644 index 00000000..a1bef61f --- /dev/null +++ b/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx @@ -0,0 +1,316 @@ +import { useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { getDefaultStore } from "jotai"; +import { WebSocketStatus } from "@hocuspocus/provider"; +import { Editor } from "@tiptap/core"; +import { + pageEditorAtom, + yjsConnectionStatusAtom, +} from "@/features/editor/atoms/editor-atoms.ts"; +import { + getSpaceById, + getSpaces, +} from "@/features/space/services/space-service.ts"; +import { + createPage, + getSidebarPages, +} from "@/features/page/services/page-service.ts"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; +import { + GitmostBridge, + GitmostCreatePagePayload, + GitmostCreatePageResult, + GitmostListPagesPayload, + GitmostListPagesResult, + GitmostListSpacesResult, + gitmostDecodePayloadToFile, + gitmostUploadFileToEditor, +} from "@/features/editor/gitmost/gitmost-recording.ts"; + +// How long to wait for a freshly-navigated page's editor to mount, become +// editable, and connect its Yjs provider before giving up. +const GITMOST_EDITOR_READY_TIMEOUT_MS = 20000; +const GITMOST_EDITOR_POLL_INTERVAL_MS = 120; + +// Poll the (default) jotai store until the editor for `pageId` is mounted, +// editable and its Yjs provider is connected. Resolves the live editor, or null +// on timeout. Reuses pageEditorAtom + yjsConnectionStatusAtom — the same signals +// PageEditor maintains. The storage.pageId check guards against matching a stale +// editor left over from the previously-open page. +function gitmostWaitForEditor( + pageId: string, + timeoutMs: number, +): Promise { + const store = getDefaultStore(); + const deadline = Date.now() + timeoutMs; + return new Promise((resolve) => { + const check = () => { + const editor = store.get(pageEditorAtom) as Editor | null; + const yjsStatus = store.get(yjsConnectionStatusAtom); + // `storage.pageId` is a custom field PageEditor.onCreate sets; it is not + // part of Tiptap's Storage type, so read it through an indexed cast. + const editorPageId = ( + editor?.storage as unknown as Record | undefined + )?.pageId; + const ready = + !!editor && + !editor.isDestroyed && + editor.isEditable && + editorPageId === pageId && + yjsStatus === WebSocketStatus.Connected; + if (ready) { + resolve(editor); + return; + } + if (Date.now() >= deadline) { + resolve(null); + return; + } + setTimeout(check, GITMOST_EDITOR_POLL_INTERVAL_MS); + }; + check(); + }); +} + +// Registers the global gitmost bridge methods that work WITHOUT an open page +// (listSpaces / listPages / createPageWithRecording). Mounted once at the +// app-shell level so the react-router navigate fn and the api-client are +// available even when no page editor is mounted. insertRecording stays in +// PageEditor (tied to the live editable editor). Renders nothing. +export default function GitmostGlobalBridge() { + const navigate = useNavigate(); + // The effect registers the bridge once; reading the latest navigate via a ref + // avoids a stale closure if react-router hands back a new function identity. + const navigateRef = useRef(navigate); + useEffect(() => { + navigateRef.current = navigate; + }, [navigate]); + + useEffect(() => { + const w = window as unknown as { gitmost?: Partial }; + w.gitmost = w.gitmost || {}; + // Advertise the bridge version even before any page editor mounts; do not + // clobber a value already set by an active PageEditor. + if (typeof w.gitmost.version !== "number") w.gitmost.version = 1; + + const listSpaces = async (): Promise => { + try { + const res = await getSpaces({ limit: 100 }); + const spaces = (res?.items ?? []).map((s) => ({ + id: s.id, + name: s.name, + })); + // v1 returns only the first page; flag truncation so the host knows + // more spaces exist. + const truncated = Boolean(res?.meta?.hasNextPage); + return { ok: true, spaces, truncated }; + } catch (err: any) { + console.error("[gitmost] listSpaces failed", err); + return { + ok: false, + error: "list-failed", + message: + err?.response?.data?.message ?? + err?.message ?? + "Failed to list spaces", + }; + } + }; + + const listPages = async ( + payload: GitmostListPagesPayload, + ): Promise => { + try { + const spaceId = payload?.spaceId; + if (!spaceId) { + return { + ok: false, + error: "bad-args", + message: "spaceId is required", + }; + } + const res = await getSidebarPages({ + spaceId, + pageId: payload?.parentPageId, + limit: 100, + }); + const pages = (res?.items ?? []).map((p) => ({ + id: p.id, + title: p.title, + hasChildren: Boolean(p.hasChildren), + })); + // v1 returns only the first page of children; flag truncation so the + // host knows more exist. + const truncated = Boolean(res?.meta?.hasNextPage); + return { ok: true, pages, truncated }; + } catch (err: any) { + console.error("[gitmost] listPages failed", err); + return { + ok: false, + error: "list-failed", + message: + err?.response?.data?.message ?? + err?.message ?? + "Failed to list pages", + }; + } + }; + + const createPageWithRecording = async ( + payload: GitmostCreatePagePayload, + ): Promise => { + try { + const { spaceId, parentPageId, title, base64, filename, mimeType } = + payload || ({} as GitmostCreatePagePayload); + + if (!spaceId) { + return { + ok: false, + error: "no-space", + message: "spaceId is required", + }; + } + + // Validate/decode the recording BEFORE creating the page so a bad + // payload never leaves an empty junk page behind. Per the createPage + // error contract, any decode failure collapses to "insert-failed" (the + // real reason is kept in `message`). + const decoded = gitmostDecodePayloadToFile({ + base64, + filename, + mimeType, + }); + if ("error" in decoded) { + return { + ok: false, + error: "insert-failed", + message: decoded.error.message ?? "Invalid recording payload", + }; + } + + // Resolve the space slug (needed for router navigation); also a + // permission/existence probe -> no-space on failure. + let spaceSlug: string | undefined; + try { + const space = await getSpaceById(spaceId); + spaceSlug = space?.slug; + } catch (err: any) { + console.error("[gitmost] resolve space failed", err); + return { + ok: false, + error: "no-space", + message: + err?.response?.data?.message ?? + err?.message ?? + "Space not found or no access", + }; + } + if (!spaceSlug) { + return { + ok: false, + error: "no-space", + message: "Space not found or no access", + }; + } + + // Create the page (REST). Default title when none is provided. + const defaultTitle = `Recording ${new Date().toLocaleString()}`; + let page; + try { + // `spaceId` is accepted by the create-page endpoint but is not part of + // the shared IPage type; cast to satisfy the createPage signature. + page = await createPage({ + spaceId, + parentPageId: parentPageId ?? undefined, + title: title ?? defaultTitle, + } as any); + } catch (err: any) { + console.error("[gitmost] createPage failed", err); + return { + ok: false, + error: "create-failed", + message: + err?.response?.data?.message ?? + err?.message ?? + "Failed to create page", + }; + } + if (!page?.id || !page?.slugId) { + return { + ok: false, + error: "create-failed", + message: "Failed to create page", + }; + } + + // Reset the shared Yjs status before navigating. The atom is global and + // is NOT reset when a PageEditor unmounts, so it can still hold + // "connected" from a previously-open page; clearing it ensures the + // readiness gate below waits for the NEW page's provider to connect. + getDefaultStore().set(yjsConnectionStatusAtom, ""); + + // Navigate via the router (no full reload). + navigateRef.current(buildPageUrl(spaceSlug, page.slugId, page.title)); + + // Wait for the new page's editor: mounted, editable, Yjs connected. + const editor = await gitmostWaitForEditor( + page.id, + GITMOST_EDITOR_READY_TIMEOUT_MS, + ); + if (!editor) { + return { + ok: false, + error: "editor-timeout", + message: "Editor was not ready in time", + // Return pageId so the host can still surface the created page. + pageId: page.id, + }; + } + + // Same insert path as insertRecording. + const result = await gitmostUploadFileToEditor( + editor, + page.id, + decoded.file, + ); + if (!result.ok) { + return { + ok: false, + error: "insert-failed", + message: result.message ?? "Failed to insert recording", + pageId: page.id, + }; + } + return { ok: true, pageId: page.id }; + } catch (err: any) { + console.error("[gitmost] createPageWithRecording failed", err); + return { + ok: false, + error: "insert-failed", + message: + err?.response?.data?.message ?? + err?.message ?? + "Failed to create page with recording", + }; + } + }; + + w.gitmost.listSpaces = listSpaces; + w.gitmost.listPages = listPages; + w.gitmost.createPageWithRecording = createPageWithRecording; + + return () => { + // Only remove our own registrations (defensive against a future second + // mount having replaced them). + if (w.gitmost) { + if (w.gitmost.listSpaces === listSpaces) delete w.gitmost.listSpaces; + if (w.gitmost.listPages === listPages) delete w.gitmost.listPages; + if (w.gitmost.createPageWithRecording === createPageWithRecording) { + delete w.gitmost.createPageWithRecording; + } + } + }; + }, []); + + return null; +} diff --git a/apps/client/src/features/editor/gitmost/gitmost-recording.ts b/apps/client/src/features/editor/gitmost/gitmost-recording.ts new file mode 100644 index 00000000..c9acec5f --- /dev/null +++ b/apps/client/src/features/editor/gitmost/gitmost-recording.ts @@ -0,0 +1,263 @@ +import { Editor } from "@tiptap/core"; +import { getFileUploadSizeLimit } from "@/lib/config.ts"; +import { formatBytes } from "@/lib"; +import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx"; + +// --- gitmost native bridge: shared types & helpers ------------------------ +// Stable JS-API on `window.gitmost` for the native host (gitmost.app / +// WKWebView). This module holds the parts shared between the open-page bridge +// (insertRecording, in page-editor.tsx) and the global bridge (gitmost-global- +// bridge.tsx): payload decoding/validation and the audio-insert pipeline, so +// both apply identical rules without depending on editor internals. + +export interface GitmostInsertRecordingPayload { + base64: string; // raw file bytes, base64 (no data: prefix) + filename: string; + mimeType: string; // must be an audio/* type +} + +export interface GitmostInsertRecordingResult { + ok: boolean; + attachmentId?: string; + // Machine-readable code: "no-editor" | "bad-type" | "too-large" | "insert-failed" + error?: string; + message?: string; // human-readable, may be surfaced by the host +} + +export interface GitmostSpaceSummary { + id: string; + name: string; +} + +export interface GitmostListSpacesResult { + ok: boolean; + spaces?: GitmostSpaceSummary[]; + // v1 lists only the first page of spaces; true when more exist server-side. + truncated?: boolean; + error?: string; + message?: string; +} + +export interface GitmostListPagesPayload { + spaceId: string; + parentPageId?: string; +} + +export interface GitmostPageSummary { + id: string; + title: string; + hasChildren: boolean; +} + +export interface GitmostListPagesResult { + ok: boolean; + pages?: GitmostPageSummary[]; + // v1 lists only the first page of children; true when more exist server-side. + truncated?: boolean; + error?: string; + message?: string; +} + +export interface GitmostCreatePagePayload { + spaceId: string; + parentPageId?: string; // omit/null = space root + title?: string; // default "Recording " + base64: string; + filename: string; + mimeType: string; +} + +export interface GitmostCreatePageResult { + ok: boolean; + pageId?: string; + // Machine-readable code: "no-space" | "create-failed" | "editor-timeout" | "insert-failed" + error?: string; + message?: string; +} + +// Full bridge surface exposed on `window.gitmost`. Writers attach a subset +// (Partial), so readonly/share pages and no-page states are valid. +export interface GitmostBridge { + ready: boolean; + version: number; + insertRecording: ( + payload: GitmostInsertRecordingPayload, + ) => Promise; + listSpaces: () => Promise; + listPages: (payload: GitmostListPagesPayload) => Promise; + createPageWithRecording: ( + payload: GitmostCreatePagePayload, + ) => Promise; +} + +// Estimate decoded byte length from a base64 string WITHOUT decoding it, so an +// oversized payload can be rejected before the buffer is allocated. +export function gitmostEstimateBase64Bytes(base64: string): number { + const len = base64.length; + if (len === 0) return 0; + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + return Math.floor((len * 3) / 4) - padding; +} + +// Decode a base64 string into bytes in fixed-size chunks. Call recordings can +// be tens of MB; slicing on 4-char boundaries (each slice decodes to whole +// bytes, no carry) keeps each atob() call bounded. Assumes unwrapped base64 +// with no embedded whitespace (per the native-host contract). Throws +// InvalidCharacterError on malformed input. +export function gitmostBase64ToBytes(base64: string): Uint8Array { + const CHUNK = 0x8000 * 4; // multiple of 4 base64 chars + const parts: Uint8Array[] = []; + let total = 0; + for (let i = 0; i < base64.length; i += CHUNK) { + const binary = atob(base64.slice(i, i + CHUNK)); + const bytes = new Uint8Array(binary.length); + for (let j = 0; j < binary.length; j++) { + bytes[j] = binary.charCodeAt(j); + } + parts.push(bytes); + total += bytes.length; + } + // Back the result with an explicit ArrayBuffer so the view is typed + // Uint8Array (not ArrayBufferLike), which `new File([...])` + // accepts as a BlobPart under the lib.dom typings. + const out = new Uint8Array(new ArrayBuffer(total)); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +// Decode + validate a recording payload into a File, or return an error result. +// Shared so insertRecording (open page) and createPageWithRecording (no page +// open) apply identical validation. Error codes: "bad-type" | "too-large" | +// "insert-failed". +export function gitmostDecodePayloadToFile( + payload: GitmostInsertRecordingPayload, +): { file: File } | { error: GitmostInsertRecordingResult } { + const { filename, mimeType } = + payload || ({} as GitmostInsertRecordingPayload); + let base64 = payload?.base64; + + if (typeof mimeType !== "string" || !mimeType.startsWith("audio/")) { + return { + error: { ok: false, error: "bad-type", message: "Not an audio file" }, + }; + } + if (typeof base64 !== "string" || base64.length === 0) { + return { + error: { ok: false, error: "insert-failed", message: "Empty payload" }, + }; + } + + // Defensively strip an accidental data:*;base64, prefix. + const marker = base64.indexOf("base64,"); + if (base64.startsWith("data:") && marker !== -1) { + base64 = base64.slice(marker + "base64,".length); + } + + const sizeLimit = getFileUploadSizeLimit(); + // Reject oversized payloads before allocating the decode buffer. + if (gitmostEstimateBase64Bytes(base64) > sizeLimit) { + return { + error: { + ok: false, + error: "too-large", + message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`, + }, + }; + } + + let bytes: Uint8Array; + try { + bytes = gitmostBase64ToBytes(base64); + } catch (decodeErr: any) { + return { + error: { + ok: false, + error: "insert-failed", + message: decodeErr?.message ?? "Invalid base64 payload", + }, + }; + } + + const file = new File([bytes], filename || "recording", { type: mimeType }); + + // Exact size check (the pre-decode estimate is approximate). + if (file.size > sizeLimit) { + return { + error: { + ok: false, + error: "too-large", + message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`, + }, + }; + } + + return { file }; +} + +// Insert an already-decoded recording File into a live editor via the existing +// audio pipeline (placeholder -> POST /api/files/upload -> `audio` node, +// Yjs-synced). Returns the attachment id on success. +export async function gitmostUploadFileToEditor( + editor: Editor, + pageId: string, + file: File, +): Promise { + try { + // Insert at the cursor, falling back to the end of the document. + const pos = editor.state.selection?.to ?? editor.state.doc.content.size; + + // uploadAudioAction returns the attachment on success and undefined when + // the upload failed (the pipeline swallows the upload error and shows its + // own notification). + const attachment = (await (uploadAudioAction( + file, + editor, + pos, + pageId, + ) as unknown as Promise<{ id?: string } | undefined>)); + + if (attachment?.id) { + return { ok: true, attachmentId: attachment.id }; + } + return { ok: false, error: "insert-failed", message: "Upload failed" }; + } catch (err: any) { + // Never swallow: log the raw error and surface the real reason. + console.error("[gitmost] audio upload into editor failed", err); + return { + ok: false, + error: "insert-failed", + message: err?.response?.data?.message ?? err?.message ?? "Insert failed", + }; + } +} + +// Full insert path used by the open-page bridge (insertRecording): guard the +// editor, validate/decode the payload, then upload. Never throws — resolves to +// a result code. +export async function gitmostInsertRecordingIntoEditor( + editor: Editor | null, + pageId: string, + payload: GitmostInsertRecordingPayload, +): Promise { + try { + // Only a live, editable editor may receive a recording. + if (!editor || editor.isDestroyed || !editor.isEditable) { + return { ok: false, error: "no-editor", message: "No editable page open" }; + } + const decoded = gitmostDecodePayloadToFile(payload); + if ("error" in decoded) return decoded.error; + return await gitmostUploadFileToEditor(editor, pageId, decoded.file); + } catch (err: any) { + // The bridge must never throw — surface any unexpected failure as a code. + console.error("[gitmost] insertRecording failed", err); + return { + ok: false, + error: "insert-failed", + message: err?.response?.data?.message ?? err?.message ?? "Insert failed", + }; + } +} diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 984f8128..cc7e7b5c 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -65,9 +65,13 @@ import { useIdle } from "@/hooks/use-idle.ts"; import { queryClient } from "@/main.tsx"; import { IPage } from "@/features/page/types/page.types.ts"; import { useParams } from "react-router-dom"; -import { extractPageSlugId, platformModifierKey, formatBytes } from "@/lib"; -import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx"; -import { getFileUploadSizeLimit } from "@/lib/config.ts"; +import { extractPageSlugId, platformModifierKey } from "@/lib"; +import { + GitmostBridge, + GitmostInsertRecordingPayload, + GitmostInsertRecordingResult, + gitmostInsertRecordingIntoEditor, +} from "@/features/editor/gitmost/gitmost-recording.ts"; import { FIVE_MINUTES } from "@/lib/constants.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts"; import { jwtDecode } from "jwt-decode"; @@ -88,71 +92,6 @@ interface PageEditorProps { canComment?: boolean; } -// --- gitmost native bridge ------------------------------------------------ -// Stable JS-API on `window.gitmost` for the native host (gitmost.app / -// WKWebView) to insert a recorded audio file into the current page as an -// `audio` block, without depending on editor internals (atoms/Tiptap/Yjs). -interface GitmostInsertRecordingPayload { - base64: string; // raw file bytes, base64 (no data: prefix) - filename: string; - mimeType: string; // must be an audio/* type -} - -interface GitmostInsertRecordingResult { - ok: boolean; - attachmentId?: string; - // Machine-readable code: "no-editor" | "bad-type" | "too-large" | "insert-failed" - error?: string; - message?: string; // human-readable, may be surfaced by the host -} - -interface GitmostBridge { - ready: boolean; - version: number; - insertRecording: ( - payload: GitmostInsertRecordingPayload, - ) => Promise; -} - -// Estimate decoded byte length from a base64 string WITHOUT decoding it, so an -// oversized payload can be rejected before the buffer is allocated. -function gitmostEstimateBase64Bytes(base64: string): number { - const len = base64.length; - if (len === 0) return 0; - const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; - return Math.floor((len * 3) / 4) - padding; -} - -// Decode a base64 string into bytes in fixed-size chunks. Call recordings can -// be tens of MB; slicing on 4-char boundaries (each slice decodes to whole -// bytes, no carry) keeps each atob() call bounded. Assumes unwrapped base64 -// with no embedded whitespace (per the native-host contract). Throws -// InvalidCharacterError on malformed input. -function gitmostBase64ToBytes(base64: string): Uint8Array { - const CHUNK = 0x8000 * 4; // multiple of 4 base64 chars - const parts: Uint8Array[] = []; - let total = 0; - for (let i = 0; i < base64.length; i += CHUNK) { - const binary = atob(base64.slice(i, i + CHUNK)); - const bytes = new Uint8Array(binary.length); - for (let j = 0; j < binary.length; j++) { - bytes[j] = binary.charCodeAt(j); - } - parts.push(bytes); - total += bytes.length; - } - // Back the result with an explicit ArrayBuffer so the view is typed - // Uint8Array (not ArrayBufferLike), which `new File([...])` - // accepts as a BlobPart under the lib.dom typings. - const out = new Uint8Array(new ArrayBuffer(total)); - let offset = 0; - for (const part of parts) { - out.set(part, offset); - offset += part.length; - } - return out; -} - export default function PageEditor({ pageId, editable, @@ -435,89 +374,10 @@ export default function PageEditor({ w.gitmost.version = 1; w.gitmost.ready = true; - const insertRecording = async ( + const insertRecording = ( payload: GitmostInsertRecordingPayload, - ): Promise => { - try { - const { filename, mimeType } = payload || ({} as GitmostInsertRecordingPayload); - let base64 = payload?.base64; - - // Only a live, editable editor may receive a recording. - if (!editor || editor.isDestroyed || !editor.isEditable) { - return { ok: false, error: "no-editor", message: "No editable page open" }; - } - if (typeof mimeType !== "string" || !mimeType.startsWith("audio/")) { - return { ok: false, error: "bad-type", message: "Not an audio file" }; - } - if (typeof base64 !== "string" || base64.length === 0) { - return { ok: false, error: "insert-failed", message: "Empty payload" }; - } - - // Defensively strip an accidental data:*;base64, prefix. - const marker = base64.indexOf("base64,"); - if (base64.startsWith("data:") && marker !== -1) { - base64 = base64.slice(marker + "base64,".length); - } - - const sizeLimit = getFileUploadSizeLimit(); - // Reject oversized payloads before allocating the decode buffer. - if (gitmostEstimateBase64Bytes(base64) > sizeLimit) { - return { - ok: false, - error: "too-large", - message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`, - }; - } - - let bytes: Uint8Array; - try { - bytes = gitmostBase64ToBytes(base64); - } catch (decodeErr: any) { - return { - ok: false, - error: "insert-failed", - message: decodeErr?.message ?? "Invalid base64 payload", - }; - } - - const file = new File([bytes], filename || "recording", { type: mimeType }); - - // Exact size check (the pre-decode estimate is approximate). - if (file.size > sizeLimit) { - return { - ok: false, - error: "too-large", - message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`, - }; - } - - // Insert at the cursor, falling back to the end of the document. - const pos = editor.state.selection?.to ?? editor.state.doc.content.size; - - // Reuse the existing audio pipeline (placeholder -> POST /api/files/upload - // -> replace with an `audio` node, Yjs-synced). It returns the attachment - // on success and undefined when the upload failed (the pipeline swallows - // the upload error and shows its own notification). - const attachment = (await (uploadAudioAction( - file, - editor, - pos, - pageId, - ) as unknown as Promise<{ id?: string } | undefined>)); - - if (attachment?.id) { - return { ok: true, attachmentId: attachment.id }; - } - return { ok: false, error: "insert-failed", message: "Upload failed" }; - } catch (err: any) { - // The bridge must never throw — surface any unexpected failure as a code. - return { - ok: false, - error: "insert-failed", - message: err?.response?.data?.message ?? err?.message ?? "Insert failed", - }; - } - }; + ): Promise => + gitmostInsertRecordingIntoEditor(editor, pageId, payload); w.gitmost.insertRecording = insertRecording;