diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 94a1b21e..984f8128 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -65,7 +65,9 @@ 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 } from "@/lib"; +import { extractPageSlugId, platformModifierKey, formatBytes } from "@/lib"; +import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx"; +import { getFileUploadSizeLimit } from "@/lib/config.ts"; import { FIVE_MINUTES } from "@/lib/constants.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts"; import { jwtDecode } from "jwt-decode"; @@ -86,6 +88,71 @@ 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, @@ -354,6 +421,118 @@ export default function PageEditor({ }, }); + // Expose the gitmost native bridge only while an editable page editor is + // mounted. Registering/tearing down here ties `ready` + `insertRecording` + // to the lifetime of the current editable editor: readonly/share pages and + // page switches re-run this effect (deps: live editable flag + pageId), + // recreating the closure over the active editor/pageId so a recording always + // targets whatever page is active at call time. + useEffect(() => { + if (!editor || !editor.isEditable) return; + + const w = window as unknown as { gitmost?: Partial }; + w.gitmost = w.gitmost || {}; + w.gitmost.version = 1; + w.gitmost.ready = true; + + const insertRecording = async ( + 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", + }; + } + }; + + w.gitmost.insertRecording = insertRecording; + + return () => { + // Only tear down if our registration is still the active one. With + // React's mount-before-unmount ordering, a newer PageEditor instance may + // have already replaced the bridge; clearing it here would disable the + // live editor's bridge. + if (w.gitmost && w.gitmost.insertRecording === insertRecording) { + w.gitmost.ready = false; + delete w.gitmost.insertRecording; + } + }; + }, [editor, pageId, editorIsEditable]); + const debouncedUpdateContent = useDebouncedCallback((newContent: any) => { const pageData = queryClient.getQueryData(["pages", slugId]); diff --git a/packages/editor-ext/src/lib/audio/audio-upload.ts b/packages/editor-ext/src/lib/audio/audio-upload.ts index 82a41f47..7455de44 100644 --- a/packages/editor-ext/src/lib/audio/audio-upload.ts +++ b/packages/editor-ext/src/lib/audio/audio-upload.ts @@ -128,6 +128,11 @@ const handleAudioUpload = .run(); disposePreviewFile(); } + + // Return the uploaded attachment so callers that await this (e.g. the + // gitmost native bridge) can report success and the attachment id. + // Existing fire-and-forget callers ignore the return value. + return attachment; } catch (error) { clearTimeout(insertPlaceholderTimeout);