feat(editor): add gitmost bridge for listing spaces/pages and creating a page with a recording
Extend the window.gitmost native-host bridge with three methods that work
when no page is open, registered globally at the app-shell level (not in
page-editor.tsx) so the react-router navigate fn and the api-client are
available:
- listSpaces(): reuse getSpaces() -> [{id, name}], flags truncation.
- listPages({spaceId, parentPageId?}): reuse getSidebarPages()
-> [{id, title, hasChildren}], first page only (truncated flag).
- createPageWithRecording({spaceId, parentPageId?, title?, base64,
filename, mimeType}): validate/decode the audio first (so a bad payload
leaves no junk page), resolve the space slug via getSpaceById (no-space
probe), createPage(), navigate via the router (no reload), wait for the
new page's editor to be mounted+editable+Yjs-connected, then run the same
uploadAudioAction path as insertRecording. Resolve-only error contract:
no-space | create-failed | editor-timeout | insert-failed.
DRY: extract the base64 decode/validate + audio-insert pipeline from
page-editor.tsx into features/editor/gitmost/gitmost-recording.ts; the
existing insertRecording now delegates to it (behavior unchanged).
Mount GitmostGlobalBridge once in GlobalAppShell. Before navigating, reset
the shared yjsConnectionStatusAtom so the readiness gate waits for the NEW
page's provider to connect instead of a stale "connected" from a previously
open page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.
|
|||||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
import Aside from "@/components/layouts/global/aside.tsx";
|
import Aside from "@/components/layouts/global/aside.tsx";
|
||||||
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.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 classes from "./app-shell.module.css";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
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
|
{/* 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. */}
|
and self-hides when closed, so its place in the tree is not critical. */}
|
||||||
<AiChatWindow />
|
<AiChatWindow />
|
||||||
|
{/* 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. */}
|
||||||
|
<GitmostGlobalBridge />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Editor | null> {
|
||||||
|
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<string, unknown> | 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<GitmostBridge> };
|
||||||
|
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<GitmostListSpacesResult> => {
|
||||||
|
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<GitmostListPagesResult> => {
|
||||||
|
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<GitmostCreatePageResult> => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
263
apps/client/src/features/editor/gitmost/gitmost-recording.ts
Normal file
263
apps/client/src/features/editor/gitmost/gitmost-recording.ts
Normal file
@@ -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 <timestamp>"
|
||||||
|
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<GitmostInsertRecordingResult>;
|
||||||
|
listSpaces: () => Promise<GitmostListSpacesResult>;
|
||||||
|
listPages: (payload: GitmostListPagesPayload) => Promise<GitmostListPagesResult>;
|
||||||
|
createPageWithRecording: (
|
||||||
|
payload: GitmostCreatePagePayload,
|
||||||
|
) => Promise<GitmostCreatePageResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<ArrayBuffer> {
|
||||||
|
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<ArrayBuffer> (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<ArrayBuffer>;
|
||||||
|
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<GitmostInsertRecordingResult> {
|
||||||
|
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<GitmostInsertRecordingResult> {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,9 +65,13 @@ import { useIdle } from "@/hooks/use-idle.ts";
|
|||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { extractPageSlugId, platformModifierKey, formatBytes } from "@/lib";
|
import { extractPageSlugId, platformModifierKey } from "@/lib";
|
||||||
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
|
import {
|
||||||
import { getFileUploadSizeLimit } from "@/lib/config.ts";
|
GitmostBridge,
|
||||||
|
GitmostInsertRecordingPayload,
|
||||||
|
GitmostInsertRecordingResult,
|
||||||
|
gitmostInsertRecordingIntoEditor,
|
||||||
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
@@ -88,71 +92,6 @@ interface PageEditorProps {
|
|||||||
canComment?: boolean;
|
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<GitmostInsertRecordingResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<ArrayBuffer> {
|
|
||||||
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<ArrayBuffer> (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({
|
export default function PageEditor({
|
||||||
pageId,
|
pageId,
|
||||||
editable,
|
editable,
|
||||||
@@ -435,89 +374,10 @@ export default function PageEditor({
|
|||||||
w.gitmost.version = 1;
|
w.gitmost.version = 1;
|
||||||
w.gitmost.ready = true;
|
w.gitmost.ready = true;
|
||||||
|
|
||||||
const insertRecording = async (
|
const insertRecording = (
|
||||||
payload: GitmostInsertRecordingPayload,
|
payload: GitmostInsertRecordingPayload,
|
||||||
): Promise<GitmostInsertRecordingResult> => {
|
): Promise<GitmostInsertRecordingResult> =>
|
||||||
try {
|
gitmostInsertRecordingIntoEditor(editor, pageId, payload);
|
||||||
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<ArrayBuffer>;
|
|
||||||
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;
|
w.gitmost.insertRecording = insertRecording;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user