import { ok } from "@atcute/client"; import { CODEC_RAW, create, toString } from "@atcute/cid"; import { mainSchema as uploadBlobSchema } from "@atcute/atproto/types/repo/uploadBlob"; import { isBlob, type Blob } from "@atcute/lexicons/interfaces"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { createClient, mintServiceAuth } from "#lib/auth/agent.ts"; import { buildUrl, toResponseError } from "#lib/api/_request.ts"; import type { BobbinContext } from "#lib/api/client.ts"; import { resolveMiniDoc } from "#lib/api/identity.ts"; import { nsidOf, type Did } from "#lib/api/syntax.ts"; import type * as OrgTangledMarkupMarkdown from "#lib/api/lexicons/types/org/tangled/markup/markdown.ts"; const UPLOAD_BLOB = nsidOf("com.atproto.repo.uploadBlob"); // an image dropped into the editor, linked by cid before it's uploaded export interface PendingImage { cid: string; file: File; // local preview url url: string; } // pre-computed CID export const blobCidOf = async (file: File): Promise => toString(await create(CODEC_RAW, new Uint8Array(await file.arrayBuffer()))); export const uploadBlob = async ( agent: OAuthUserAgent, bobbin: BobbinContext, owner: Did, file: File ): Promise => { if (owner === agent.sub) { const { blob } = await ok(createClient(agent).call(uploadBlobSchema, { input: file })); if (!isBlob(blob)) throw new Error("The PDS answered with a legacy blob."); return blob; } const [{ pds }, token] = await Promise.all([ resolveMiniDoc(bobbin, owner), mintServiceAuth(agent, { aud: owner, lxm: UPLOAD_BLOB }) ]); const response = await fetch(buildUrl(pds, UPLOAD_BLOB), { method: "POST", headers: { "content-type": file.type, accept: "application/json", authorization: `Bearer ${token}` }, body: file }); if (!response.ok) throw await toResponseError(response); const { blob } = (await response.json()) as { blob?: unknown }; if (!isBlob(blob)) throw new Error("The PDS answered with a malformed blob."); return blob; }; export const uploadPending = async ( agent: OAuthUserAgent, bobbin: BobbinContext, owner: Did, pending: readonly PendingImage[], text: string ): Promise => { const linked = [...new Map(pending.map((image) => [image.cid, image])).values()].filter( (image) => text.includes(image.cid) ); return Promise.all( linked.map(async ({ cid, file }) => { const blob = await uploadBlob(agent, bobbin, owner, file); if (blob.ref.$link !== cid) throw new Error(`"${file.name}" was stored as ${blob.ref.$link}, not ${cid}.`); return blob; }) ); }; type MarkdownBlob = NonNullable[number]; export const filterBlobsFromMarkdown = ( existing: readonly MarkdownBlob[] | undefined, added: readonly Blob[] | undefined, text: string ): MarkdownBlob[] | undefined => { const seen = new Set(); const pinned = [ ...(existing ?? []), ...(added ?? []).map((image) => ({ $type: "org.tangled.markup.embed.image" as const, image })) ].filter((embed) => { const blob = "image" in embed ? embed.image : embed.video; const cid = isBlob(blob) ? blob.ref.$link : blob.cid; if (seen.has(cid) || !text.includes(cid)) return false; seen.add(cid); return true; }); return pinned.length ? pinned : undefined; };