diff --git a/ai/alttext.test.ts b/ai/alttext.test.ts new file mode 100644 index 000000000..56b97155c --- /dev/null +++ b/ai/alttext.test.ts @@ -0,0 +1,238 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { MockLanguageModelV3 } from "ai/test"; +import { generateAltText } from "./alttext.ts"; + +// A 1×1 transparent GIF as a data URL — avoids network downloads in tests. +const DATA_URL = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + +test("generateAltText() returns trimmed text from the model response", async () => { + const model = new MockLanguageModelV3({ + doGenerate: async () => ({ + content: [{ type: "text", text: " A cat sitting on a keyboard. \n" }], + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + warnings: [], + }), + }); + + const result = await generateAltText({ + model, + imageUrl: DATA_URL, + language: "en", + }); + + assert.equal(result, "A cat sitting on a keyboard."); +}); + +test("generateAltText() sends an image file part to the model", async () => { + let hasImageFilePart = false; + const model = new MockLanguageModelV3({ + doGenerate: async (options) => { + for (const message of options.prompt) { + if (message.role !== "user") continue; + for (const part of message.content) { + if ( + part.type === "file" && + typeof part.mediaType === "string" && + part.mediaType.startsWith("image/") + ) { + hasImageFilePart = true; + } + } + } + return { + content: [{ type: "text", text: "A description." }], + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + warnings: [], + }; + }, + }); + + await generateAltText({ model, imageUrl: DATA_URL, language: "en" }); + + assert.ok(hasImageFilePart, "model should receive an image file part"); +}); + +test("generateAltText() sends a system prompt to the model", async () => { + let capturedSystem: string | undefined; + const model = new MockLanguageModelV3({ + doGenerate: async (options) => { + const sysMsg = options.prompt.find((m) => m.role === "system"); + if (sysMsg?.role === "system") capturedSystem = sysMsg.content; + return { + content: [{ type: "text", text: "A description." }], + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + warnings: [], + }; + }, + }); + + await generateAltText({ model, imageUrl: DATA_URL, language: "en" }); + + assert.ok(capturedSystem != null, "model should receive a system prompt"); + assert.ok(capturedSystem.length > 0, "system prompt should not be empty"); +}); + +test("generateAltText() uses a Korean system prompt for Korean language", async () => { + let capturedSystem: string | undefined; + const model = new MockLanguageModelV3({ + doGenerate: async (options) => { + const sysMsg = options.prompt.find((m) => m.role === "system"); + if (sysMsg?.role === "system") capturedSystem = sysMsg.content; + return { + content: [{ type: "text", text: "설명입니다." }], + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + warnings: [], + }; + }, + }); + + await generateAltText({ model, imageUrl: DATA_URL, language: "ko" }); + + assert.ok(capturedSystem != null, "system prompt should be set"); + assert.ok( + capturedSystem.includes("한국어") || capturedSystem.includes("접근성"), + "Korean prompt should contain Korean-specific text", + ); +}); + +test("generateAltText() falls back to English prompt for unsupported locales", async () => { + let capturedSystem: string | undefined; + const model = new MockLanguageModelV3({ + doGenerate: async (options) => { + const sysMsg = options.prompt.find((m) => m.role === "system"); + if (sysMsg?.role === "system") capturedSystem = sysMsg.content; + return { + content: [{ type: "text", text: "A description." }], + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + warnings: [], + }; + }, + }); + + await generateAltText({ model, imageUrl: DATA_URL, language: "ar" }); + + assert.ok(capturedSystem != null, "system prompt should be set"); + assert.ok( + capturedSystem.includes("accessibility") || + capturedSystem.includes("English"), + "should fall back to English prompt for unsupported locales", + ); +}); + +test("generateAltText() includes note context in the user text when provided", async () => { + let capturedTextPart: string | undefined; + const model = new MockLanguageModelV3({ + doGenerate: async (options) => { + const userMsg = options.prompt.find((m) => m.role === "user"); + if (userMsg?.role === "user") { + const textPart = userMsg.content.find((p) => p.type === "text"); + if (textPart?.type === "text") capturedTextPart = textPart.text; + } + return { + content: [{ type: "text", text: "A cat." }], + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + warnings: [], + }; + }, + }); + + await generateAltText({ + model, + imageUrl: DATA_URL, + language: "en", + context: "My home office setup", + }); + + assert.ok(capturedTextPart?.includes("My home office setup")); +}); + +test("generateAltText() does not add context hint when context is absent", async () => { + let capturedTextPart: string | undefined; + const model = new MockLanguageModelV3({ + doGenerate: async (options) => { + const userMsg = options.prompt.find((m) => m.role === "user"); + if (userMsg?.role === "user") { + const textPart = userMsg.content.find((p) => p.type === "text"); + if (textPart?.type === "text") capturedTextPart = textPart.text; + } + return { + content: [{ type: "text", text: "A photo." }], + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + warnings: [], + }; + }, + }); + + await generateAltText({ model, imageUrl: DATA_URL, language: "en" }); + + assert.ok(capturedTextPart != null); + assert.ok( + !capturedTextPart.toLowerCase().includes("context:"), + "no context hint should appear when context is absent", + ); +}); diff --git a/ai/alttext.ts b/ai/alttext.ts new file mode 100644 index 000000000..64201acca --- /dev/null +++ b/ai/alttext.ts @@ -0,0 +1,77 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + isLocale, + type Locale, + negotiateLocale, +} from "@hackerspub/models/i18n"; +import { generateText, type LanguageModel } from "ai"; + +const MAX_CONTEXT_LENGTH = 1000; +const MAX_ALT_TEXT_TOKENS = 200; + +const PROMPT_LANGUAGES: Locale[] = ( + await readdir( + join(import.meta.dirname!, "prompts", "alttext"), + { withFileTypes: true }, + ) +).map((f) => f.name.replace(/\.md$/, "")).filter(isLocale); + +const promptCache = new Map(); + +async function getAltTextPrompt(language: string): Promise { + let locale: Intl.Locale; + try { + locale = new Intl.Locale(language); + } catch { + locale = new Intl.Locale("en"); + } + const promptLocale = negotiateLocale(locale, PROMPT_LANGUAGES) ?? + new Intl.Locale("en"); + const cacheKey = promptLocale.baseName; + const cached = promptCache.get(cacheKey); + if (cached != null) return cached; + const promptPath = join( + import.meta.dirname!, + "prompts", + "alttext", + `${cacheKey}.md`, + ); + const content = await readFile(promptPath, "utf8"); + promptCache.set(cacheKey, content); + return content; +} + +export interface AltTextOptions { + model: LanguageModel; + imageUrl: string; + language: string; + context?: string; +} + +export async function generateAltText( + options: AltTextOptions, +): Promise { + const { model, imageUrl, language } = options; + const context = options.context?.slice(0, MAX_CONTEXT_LENGTH); + const systemPrompt = await getAltTextPrompt(language); + + const textContent = context + ? `Generate alt text for this image. Context from the accompanying note: ${context}` + : "Generate alt text for this image."; + + const result = await generateText({ + model, + system: systemPrompt, + maxOutputTokens: MAX_ALT_TEXT_TOKENS, + messages: [{ + role: "user", + content: [ + { type: "image", image: new URL(imageUrl) }, + { type: "text", text: textContent }, + ], + }], + }); + + return result.text.trim(); +} diff --git a/ai/deno.json b/ai/deno.json index c835860e6..dcccb0393 100644 --- a/ai/deno.json +++ b/ai/deno.json @@ -3,6 +3,7 @@ "version": "0.2.0", "exports": { ".": "./mod.ts", + "./alttext": "./alttext.ts", "./summary": "./summary.ts", "./translate": "./translate.ts" } diff --git a/ai/mod.ts b/ai/mod.ts index 449216320..f800e830f 100644 --- a/ai/mod.ts +++ b/ai/mod.ts @@ -1,2 +1,3 @@ +export { generateAltText } from "./alttext.ts"; export { summarize } from "./summary.ts"; export { translate } from "./translate.ts"; diff --git a/ai/package.json b/ai/package.json index 9812eda2c..3e760df0c 100644 --- a/ai/package.json +++ b/ai/package.json @@ -3,6 +3,7 @@ "type": "module", "exports": { ".": "./mod.ts", + "./alttext": "./alttext.ts", "./*": "./*.ts" }, "dependencies": { diff --git a/ai/prompts/alttext/en.md b/ai/prompts/alttext/en.md new file mode 100644 index 000000000..3ce9d5a53 --- /dev/null +++ b/ai/prompts/alttext/en.md @@ -0,0 +1,9 @@ +You are an accessibility assistant. Generate concise, descriptive alt text for the provided image so that visually impaired users understand what the image shows. + +Rules: +- Write 1–3 short sentences describing the image objectively. +- Focus on the main subject, action, setting, and any relevant text visible in the image. +- Do not begin with "Image of", "Photo of", or similar redundant phrases. +- Do not include personal opinions or interpretations. +- Write in English. +- Keep it under 150 characters when possible. diff --git a/ai/prompts/alttext/ja.md b/ai/prompts/alttext/ja.md new file mode 100644 index 000000000..c65a40888 --- /dev/null +++ b/ai/prompts/alttext/ja.md @@ -0,0 +1,9 @@ +あなたはアクセシビリティアシスタントです。視覚障害のあるユーザーが画像の内容を理解できるよう、提供された画像に対して簡潔で説明的な代替テキストを生成してください。 + +ルール: +- 画像を客観的に説明する1〜3つの短い文を書きます。 +- 主な被写体、動作、背景、画像に見えるテキストに焦点を当てます。 +- 「画像:」「写真:」などの不要な接頭辞で始めないでください。 +- 個人的な意見や解釈は含めないでください。 +- 日本語で書きます。 +- できるだけ150文字以内に収めます。 diff --git a/ai/prompts/alttext/ko.md b/ai/prompts/alttext/ko.md new file mode 100644 index 000000000..fb4d7784e --- /dev/null +++ b/ai/prompts/alttext/ko.md @@ -0,0 +1,9 @@ +당신은 접근성 보조 도구입니다. 시각 장애인 사용자가 이미지의 내용을 이해할 수 있도록 제공된 이미지에 대한 간결하고 서술적인 대체 텍스트를 생성하세요. + +규칙: +- 이미지를 객관적으로 설명하는 1–3개의 짧은 문장을 작성합니다. +- 주요 피사체, 행동, 배경, 이미지에 보이는 관련 텍스트에 초점을 맞춥니다. +- "이미지:", "사진:", "그림:" 등 불필요한 접두사로 시작하지 않습니다. +- 개인적인 의견이나 해석은 포함하지 않습니다. +- 한국어로 작성합니다. +- 가능한 한 150자 이내로 유지합니다. diff --git a/ai/prompts/alttext/zh-CN.md b/ai/prompts/alttext/zh-CN.md new file mode 100644 index 000000000..2326a56af --- /dev/null +++ b/ai/prompts/alttext/zh-CN.md @@ -0,0 +1,9 @@ +您是一个无障碍辅助工具。请为提供的图像生成简洁、描述性的替代文本,以便视觉障碍用户了解图像内容。 + +规则: +- 用1–3个简短句子客观描述图像。 +- 重点关注图像中的主要主体、动作、背景及可见文本。 +- 不要以"图像:"、"照片:"等冗余短语开头。 +- 不包含个人意见或解读。 +- 用简体中文书写。 +- 尽量控制在150个字符以内。 diff --git a/ai/prompts/alttext/zh-TW.md b/ai/prompts/alttext/zh-TW.md new file mode 100644 index 000000000..c397c5378 --- /dev/null +++ b/ai/prompts/alttext/zh-TW.md @@ -0,0 +1,9 @@ +您是一個無障礙輔助工具。請為提供的圖像生成簡潔、描述性的替代文字,以便視覺障礙使用者了解圖像內容。 + +規則: +- 用1–3個簡短句子客觀描述圖像。 +- 重點關注圖像中的主要主體、動作、背景及可見文字。 +- 不要以「圖像:」、「照片:」等冗贅短語開頭。 +- 不包含個人意見或解讀。 +- 用繁體中文書寫。 +- 盡量控制在150個字元以內。 diff --git a/deno.lock b/deno.lock index 0d9e1bf39..03e9da3e9 100644 --- a/deno.lock +++ b/deno.lock @@ -177,6 +177,7 @@ "npm:@types/node@*": "24.2.0", "npm:@types/relay-runtime@^19.0.2": "19.0.2", "npm:@vertana/context-web@~0.1.1": "0.1.1_@standard-schema+spec@1.1.0_ai@6.0.108__zod@4.2.1", + "npm:ai@6": "6.0.108_zod@4.2.1", "npm:ai@^6.0.3": "6.0.108_zod@4.2.1", "npm:ai@^6.0.86": "6.0.108_zod@4.2.1", "npm:apns2@^12.2.0": "12.2.0", diff --git a/graphql/ai.ts b/graphql/ai.ts index 3da47f75f..f85e8acb5 100644 --- a/graphql/ai.ts +++ b/graphql/ai.ts @@ -1,5 +1,8 @@ import { anthropic } from "@ai-sdk/anthropic"; import { google } from "@ai-sdk/google"; +// TODO: make model IDs configurable via env vars so they can be swapped +// when preview models are promoted or deprecated. +export const altTextGenerator = google("gemini-3.1-flash-lite-preview"); export const summarizer = google("gemini-3-flash-preview"); -export const translator = anthropic("claude-sonnet-4-5-20250929"); +export const translator = anthropic("claude-sonnet-4-6"); diff --git a/graphql/builder.ts b/graphql/builder.ts index f4277652d..bd1c70bc7 100644 --- a/graphql/builder.ts +++ b/graphql/builder.ts @@ -13,6 +13,7 @@ import type { Transport } from "@upyo/core"; import { getTableConfig } from "drizzle-orm/pg-core"; import type DataLoader from "dataloader"; import type { Disk } from "flydrive"; +import type { LanguageModel } from "ai"; import { GraphQLScalarType, Kind } from "graphql"; import { DateResolver, @@ -42,6 +43,7 @@ export type ValuesOfEnumType = T extends PothosSchemaTypes.EnumRef ? V : never; export interface ServerContext { + altTextGenerator: LanguageModel; db: Database; kv: Keyv; disk: Disk; diff --git a/graphql/main.ts b/graphql/main.ts index a4775ce98..739c7a5b3 100644 --- a/graphql/main.ts +++ b/graphql/main.ts @@ -41,6 +41,7 @@ Deno.serve({ port: 8080 }, async (req, info) => { return federation.fetch(req, { contextData: { db, kv, disk, models } }); } return yogaServer.fetch(req, { + altTextGenerator: models.altTextGenerator, db, kv, disk, diff --git a/graphql/medium-upload.test.ts b/graphql/medium-upload.test.ts index 0b7b04634..06f31f2fd 100644 --- a/graphql/medium-upload.test.ts +++ b/graphql/medium-upload.test.ts @@ -144,3 +144,289 @@ test("handleMediumUploadProxy rejects bodies shorter than content length", async assert.equal(response.status, 413); assert.throws(() => disk.getBytes(session.key)); }); + +test("handleMediumUploadProxy responds to OPTIONS preflight with CORS headers", async () => { + const { kv } = createTestKv(); + const disk = createTestDisk(); + const accountId = crypto.randomUUID() as Uuid; + const session = await createMediumUploadSession( + kv, + accountId, + "image/png", + 4, + ); + + const response = await handleMediumUploadProxy( + new Request( + `http://localhost/medium-uploads/${session.id}?token=${session.token}`, + { + method: "OPTIONS", + headers: { + "Origin": "http://localhost:5173", + "Access-Control-Request-Method": "PUT", + "Access-Control-Request-Headers": "Content-Type", + }, + }, + ), + kv, + disk, + ); + + assert.ok(response != null); + assert.equal(response.status, 204); + assert.equal( + response.headers.get("Access-Control-Allow-Origin"), + "http://localhost:5173", + ); + assert.ok( + response.headers.get("Access-Control-Allow-Methods")?.includes("PUT"), + ); + assert.ok( + response.headers.get("Vary")?.includes("Origin"), + "preflight should include Vary: Origin", + ); + assert.ok( + response.headers.get("Access-Control-Allow-Headers")?.includes( + "Content-Type", + ), + "preflight should allow Content-Type header", + ); +}); + +// Helper used by the CORS-on-error tests below. +const TEST_ORIGIN = "http://localhost:5173"; +function assertCors(response: Response) { + assert.equal( + response.headers.get("Access-Control-Allow-Origin"), + TEST_ORIGIN, + ); + assert.ok( + response.headers.get("Vary")?.includes("Origin"), + "response should carry Vary: Origin", + ); +} + +test("handleMediumUploadProxy includes CORS headers on 405 wrong method", async () => { + const { kv } = createTestKv(); + const disk = createTestDisk(); + const accountId = crypto.randomUUID() as Uuid; + const session = await createMediumUploadSession( + kv, + accountId, + "image/png", + 4, + ); + + const response = await handleMediumUploadProxy( + new Request( + `http://localhost/medium-uploads/${session.id}?token=${session.token}`, + { method: "PATCH", headers: { "Origin": TEST_ORIGIN } }, + ), + kv, + disk, + ); + + assert.ok(response != null); + assert.equal(response.status, 405); + assertCors(response); +}); + +test("handleMediumUploadProxy includes CORS headers on 404 invalid UUID", async () => { + const { kv } = createTestKv(); + const disk = createTestDisk(); + + const response = await handleMediumUploadProxy( + new Request( + "http://localhost/medium-uploads/not-a-valid-uuid", + { method: "PUT", headers: { "Origin": TEST_ORIGIN } }, + ), + kv, + disk, + ); + + assert.ok(response != null); + assert.equal(response.status, 404); + assertCors(response); +}); + +test("handleMediumUploadProxy includes CORS headers on 403 wrong token", async () => { + const { kv } = createTestKv(); + const disk = createTestDisk(); + const accountId = crypto.randomUUID() as Uuid; + const session = await createMediumUploadSession( + kv, + accountId, + "image/png", + 4, + ); + const bytes = new Uint8Array([1, 2, 3, 4]); + + const response = await handleMediumUploadProxy( + new Request( + `http://localhost/medium-uploads/${session.id}?token=wrong`, + { + method: "PUT", + headers: { + "Origin": TEST_ORIGIN, + "Content-Type": "image/png", + "Content-Length": "4", + }, + body: bytes, + }, + ), + kv, + disk, + ); + + assert.ok(response != null); + assert.equal(response.status, 403); + assertCors(response); +}); + +test("handleMediumUploadProxy includes CORS headers on 415 wrong content-type", async () => { + const { kv } = createTestKv(); + const disk = createTestDisk(); + const accountId = crypto.randomUUID() as Uuid; + const session = await createMediumUploadSession( + kv, + accountId, + "image/png", + 4, + ); + const bytes = new Uint8Array([1, 2, 3, 4]); + + const response = await handleMediumUploadProxy( + new Request( + `http://localhost/medium-uploads/${session.id}?token=${session.token}`, + { + method: "PUT", + headers: { + "Origin": TEST_ORIGIN, + "Content-Type": "text/plain", + "Content-Length": "4", + }, + body: bytes, + }, + ), + kv, + disk, + ); + + assert.ok(response != null); + assert.equal(response.status, 415); + assertCors(response); +}); + +test("handleMediumUploadProxy includes CORS headers on 411 missing content-length", async () => { + const { kv } = createTestKv(); + const disk = createTestDisk(); + const accountId = crypto.randomUUID() as Uuid; + const session = await createMediumUploadSession( + kv, + accountId, + "image/png", + 4, + ); + + const response = await handleMediumUploadProxy( + new Request( + `http://localhost/medium-uploads/${session.id}?token=${session.token}`, + { + method: "PUT", + headers: { + "Origin": TEST_ORIGIN, + "Content-Type": "image/png", + // no Content-Length header + }, + body: new Uint8Array([1, 2, 3, 4]), + }, + ), + kv, + disk, + ); + + assert.ok(response != null); + assert.equal(response.status, 411); + assertCors(response); +}); + +test("handleMediumUploadProxy includes CORS headers on 413 oversized body", async () => { + const { kv } = createTestKv(); + const disk = createTestDisk(); + const accountId = crypto.randomUUID() as Uuid; + const session = await createMediumUploadSession( + kv, + accountId, + "image/png", + 4, + ); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3, 4])); + controller.enqueue(new Uint8Array([5])); + controller.close(); + }, + }); + + const response = await handleMediumUploadProxy( + new Request( + `http://localhost/medium-uploads/${session.id}?token=${session.token}`, + { + method: "PUT", + headers: { + "Origin": TEST_ORIGIN, + "Content-Type": "image/png", + "Content-Length": "4", + }, + body, + }, + ), + kv, + disk, + ); + + assert.ok(response != null); + assert.equal(response.status, 413); + assertCors(response); +}); + +test("handleMediumUploadProxy includes CORS origin header on successful PUT", async () => { + const { kv } = createTestKv(); + const disk = createTestDisk(); + const accountId = crypto.randomUUID() as Uuid; + const session = await createMediumUploadSession( + kv, + accountId, + "image/png", + 4, + ); + const bytes = new Uint8Array([1, 2, 3, 4]); + + const response = await handleMediumUploadProxy( + new Request( + `http://localhost/medium-uploads/${session.id}?token=${session.token}`, + { + method: "PUT", + headers: { + "Content-Type": "image/png", + "Content-Length": "4", + "Origin": "http://localhost:5173", + }, + body: bytes, + }, + ), + kv, + disk, + ); + + assert.ok(response != null); + assert.equal(response.status, 204); + assert.equal( + response.headers.get("Access-Control-Allow-Origin"), + "http://localhost:5173", + ); + assert.ok( + response.headers.get("Vary")?.includes("Origin"), + "PUT success should include Vary: Origin", + ); +}); diff --git a/graphql/medium-upload.ts b/graphql/medium-upload.ts index 913271299..c914140ce 100644 --- a/graphql/medium-upload.ts +++ b/graphql/medium-upload.ts @@ -10,6 +10,61 @@ import { validateUuid } from "@hackerspub/models/uuid"; const KV_NAMESPACE = "medium-upload"; export const MEDIUM_UPLOAD_TTL_MS = 30 * 60 * 1000; +const MEDIUM_OWNER_NAMESPACE = "medium-owner"; +// A shared "upload active" marker lets us distinguish "no one has uploaded +// this medium recently" (window expired for all) from "someone else uploaded +// it and the window is still open". Keyed separately from per-account +// entries so that two users uploading the same image (content-hash dedup) +// each retain independent ownership markers. +const MEDIUM_UPLOAD_WINDOW_NAMESPACE = "medium-upload-window"; +// Long enough for a user to compose and post a note with the image. +const MEDIUM_OWNER_TTL_MS = 2 * 60 * 60 * 1000; + +export function getMediumOwnerKey(mediumId: Uuid, accountId: Uuid): string { + return `${MEDIUM_OWNER_NAMESPACE}/${mediumId}/${accountId}`; +} + +export function getMediumUploadWindowKey(mediumId: Uuid): string { + return `${MEDIUM_UPLOAD_WINDOW_NAMESPACE}/${mediumId}`; +} + +export async function setMediumOwner( + kv: Keyv, + mediumId: Uuid, + accountId: Uuid, +): Promise { + const ownerKey = getMediumOwnerKey(mediumId, accountId); + const windowKey = getMediumUploadWindowKey(mediumId); + // Keyv adapters can return false (not throw) when throwOnErrors is disabled. + if ((await kv.set(ownerKey, true, MEDIUM_OWNER_TTL_MS)) !== true) { + throw new Error("KV write failed for medium owner key"); + } + try { + if ((await kv.set(windowKey, true, MEDIUM_OWNER_TTL_MS)) !== true) { + throw new Error("KV write failed for medium upload window key"); + } + } catch (error) { + await kv.delete(ownerKey).catch(() => {}); + throw error; + } +} + +export async function isMediumOwner( + kv: Keyv, + mediumId: Uuid, + accountId: Uuid, +): Promise { + return (await kv.get(getMediumOwnerKey(mediumId, accountId))) === + true; +} + +export async function isMediumUploadWindowActive( + kv: Keyv, + mediumId: Uuid, +): Promise { + return (await kv.get(getMediumUploadWindowKey(mediumId))) === true; +} + export interface MediumUploadSession { id: Uuid; accountId: Uuid; @@ -90,6 +145,15 @@ async function readRequestBody( return bytes; } +function corsHeaders(request: Request): Record { + const origin = request.headers.get("Origin"); + if (origin == null) return {}; + return { + "Access-Control-Allow-Origin": origin, + "Vary": "Origin", + }; +} + export async function handleMediumUploadProxy( request: Request, kv: Keyv, @@ -98,20 +162,39 @@ export async function handleMediumUploadProxy( const url = new URL(request.url); const match = url.pathname.match(/^\/medium-uploads\/([^/]+)$/); if (match == null) return undefined; + + // Handle CORS preflight for cross-origin uploads from the web-next frontend. + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: { + ...corsHeaders(request), + "Access-Control-Allow-Methods": "PUT, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Content-Length", + "Access-Control-Max-Age": "86400", + }, + }); + } + if (request.method !== "PUT") { return new Response("Method Not Allowed", { status: 405, + headers: corsHeaders(request), }); } const uploadId = match[1]; if (!validateUuid(uploadId)) { return new Response("Not Found", { status: 404, + headers: corsHeaders(request), }); } const session = await getMediumUploadSession(kv, uploadId); if (session == null || url.searchParams.get("token") !== session.token) { - return new Response("Forbidden", { status: 403 }); + return new Response("Forbidden", { + status: 403, + headers: corsHeaders(request), + }); } const contentType = request.headers.get("Content-Type")?.split(";")[0] .trim(); @@ -122,11 +205,17 @@ export async function handleMediumUploadProxy( contentType as typeof SUPPORTED_MEDIUM_IMAGE_TYPES[number], ) ) { - return new Response("Unsupported Media Type", { status: 415 }); + return new Response("Unsupported Media Type", { + status: 415, + headers: corsHeaders(request), + }); } const contentLength = request.headers.get("Content-Length"); if (contentLength == null || !/^\d+$/.test(contentLength)) { - return new Response("Length Required", { status: 411 }); + return new Response("Length Required", { + status: 411, + headers: corsHeaders(request), + }); } const length = Number(contentLength); if ( @@ -134,7 +223,10 @@ export async function handleMediumUploadProxy( length !== session.contentLength || length > MAX_STREAMING_MEDIUM_IMAGE_SIZE ) { - return new Response("Payload Too Large", { status: 413 }); + return new Response("Payload Too Large", { + status: 413, + headers: corsHeaders(request), + }); } const bytes = await readRequestBody( request, @@ -145,8 +237,11 @@ export async function handleMediumUploadProxy( bytes.byteLength !== session.contentLength || bytes.byteLength > MAX_STREAMING_MEDIUM_IMAGE_SIZE ) { - return new Response("Payload Too Large", { status: 413 }); + return new Response("Payload Too Large", { + status: 413, + headers: corsHeaders(request), + }); } await disk.put(session.key, bytes, { contentType: session.contentType }); - return new Response(null, { status: 204 }); + return new Response(null, { status: 204, headers: corsHeaders(request) }); } diff --git a/graphql/medium.test.ts b/graphql/medium.test.ts new file mode 100644 index 000000000..a20698e4d --- /dev/null +++ b/graphql/medium.test.ts @@ -0,0 +1,376 @@ +import { assertEquals } from "@std/assert/equals"; +import { encodeGlobalID } from "@pothos/plugin-relay"; +import { execute, parse } from "graphql"; +import { MockLanguageModelV3 } from "ai/test"; +import { mediumTable } from "@hackerspub/models/schema"; +import { generateUuidV7, type Uuid } from "@hackerspub/models/uuid"; +import { + getMediumOwnerKey, + getMediumUploadWindowKey, +} from "./medium-upload.ts"; +import { schema } from "./mod.ts"; +import { + createTestKv, + insertAccountWithActor, + makeGuestContext, + makeUserContext, + withRollback, +} from "../test/postgres.ts"; + +// MockLanguageModelV3 declares support for the test disk URL pattern so +// the AI SDK does not attempt to download the image during tests. +const TEST_MEDIUM_URL_PATTERN = /^http:\/\/localhost\/media\/.+/; + +function makeAltTextModel(responseText: string): MockLanguageModelV3 { + return new MockLanguageModelV3({ + supportedUrls: { "image/*": [TEST_MEDIUM_URL_PATTERN] }, + doGenerate: async () => ({ + content: [{ type: "text", text: responseText }], + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + warnings: [], + }), + }); +} + +const generatedAltTextQuery = parse(` + query GeneratedAltText($id: ID!, $language: Locale!) { + node(id: $id) { + ... on Medium { + generatedAltText(language: $language) + } + } + } +`); + +const generatedAltTextWithContextQuery = parse(` + query GeneratedAltTextWithContext($id: ID!, $language: Locale!, $context: String) { + node(id: $id) { + ... on Medium { + generatedAltText(language: $language, context: $context) + } + } + } +`); + +Deno.test({ + name: "Medium.generatedAltText returns errors for guests", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const mediumId = generateUuidV7(); + await tx.insert(mediumTable).values({ + id: mediumId, + key: `test/medium-${mediumId}.webp`, + type: "image/webp", + }); + + const relayId = encodeGlobalID("Medium", mediumId); + const ctx = makeGuestContext(tx, { + altTextGenerator: makeAltTextModel("A test image."), + }); + + const result = await execute({ + schema, + document: generatedAltTextQuery, + contextValue: ctx, + variableValues: { id: relayId, language: "en" }, + }); + + assertEquals( + result.errors != null, + true, + "should return errors for guest", + ); + }); + }, +}); + +Deno.test({ + name: + "Medium.generatedAltText returns generated text for authenticated users", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const { account } = await insertAccountWithActor(tx, { + username: "alttext_auth_test", + name: "Test User", + email: "alttext_auth@example.com", + }); + + const mediumId = generateUuidV7(); + await tx.insert(mediumTable).values({ + id: mediumId, + key: `test/medium-${mediumId}.webp`, + type: "image/webp", + }); + + const relayId = encodeGlobalID("Medium", mediumId); + const ctx = makeUserContext(tx, account, { + altTextGenerator: makeAltTextModel( + "A cheerful cat sitting on a keyboard.", + ), + }); + + const result = await execute({ + schema, + document: generatedAltTextQuery, + contextValue: ctx, + variableValues: { id: relayId, language: "en" }, + }); + + assertEquals(result.errors, undefined); + const node = (result.data as { node: { generatedAltText: string } }).node; + assertEquals( + node.generatedAltText, + "A cheerful cat sitting on a keyboard.", + ); + }); + }, +}); + +Deno.test({ + name: "Medium.generatedAltText passes context to the AI model", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const { account } = await insertAccountWithActor(tx, { + username: "alttext_ctx_test", + name: "Test User", + email: "alttext_ctx@example.com", + }); + + const mediumId = generateUuidV7(); + await tx.insert(mediumTable).values({ + id: mediumId, + key: `test/medium-${mediumId}.webp`, + type: "image/webp", + }); + + let capturedTextPart: string | undefined; + const model = new MockLanguageModelV3({ + supportedUrls: { "image/*": [TEST_MEDIUM_URL_PATTERN] }, + doGenerate: async (options) => { + const userMsg = options.prompt.find((m) => m.role === "user"); + if (userMsg?.role === "user") { + const textPart = userMsg.content.find((p) => p.type === "text"); + if (textPart?.type === "text") capturedTextPart = textPart.text; + } + return { + content: [{ type: "text", text: "A description." }], + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + warnings: [], + }; + }, + }); + + const relayId = encodeGlobalID("Medium", mediumId); + const ctx = makeUserContext(tx, account, { altTextGenerator: model }); + + await execute({ + schema, + document: generatedAltTextWithContextQuery, + contextValue: ctx, + variableValues: { + id: relayId, + language: "en", + context: "My trip to the mountains", + }, + }); + + assertEquals( + capturedTextPart?.includes("My trip to the mountains"), + true, + "context should be passed to the AI model", + ); + }); + }, +}); + +// Helper: insert a medium row and return its relay ID. +async function insertTestMedium( + tx: Parameters[0], + id: Uuid, +): Promise { + await tx.insert(mediumTable).values({ + id, + key: `test/medium-${id}.webp`, + type: "image/webp", + }); + return encodeGlobalID("Medium", id); +} + +Deno.test({ + name: "generatedAltText: owner allowed while upload window is active", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const { account } = await insertAccountWithActor(tx, { + username: "owner_window_ok", + name: "Owner", + email: "owner_window_ok@example.com", + }); + const mediumId = generateUuidV7(); + const relayId = await insertTestMedium(tx, mediumId); + + const { kv, store } = createTestKv(); + store.set(getMediumOwnerKey(mediumId, account.id), true); + store.set(getMediumUploadWindowKey(mediumId), true); + + const ctx = makeUserContext(tx, account, { + kv, + altTextGenerator: makeAltTextModel("Owner's image."), + }); + const result = await execute({ + schema, + document: generatedAltTextQuery, + contextValue: ctx, + variableValues: { id: relayId, language: "en" }, + }); + assertEquals(result.errors, undefined, "owner should be allowed"); + }); + }, +}); + +Deno.test({ + name: "generatedAltText: non-owner denied while upload window is active", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const { account: owner } = await insertAccountWithActor(tx, { + username: "window_owner", + name: "Owner", + email: "window_owner@example.com", + }); + const { account: other } = await insertAccountWithActor(tx, { + username: "window_other", + name: "Other", + email: "window_other@example.com", + }); + const mediumId = generateUuidV7(); + const relayId = await insertTestMedium(tx, mediumId); + + const { kv, store } = createTestKv(); + store.set(getMediumOwnerKey(mediumId, owner.id), true); + store.set(getMediumUploadWindowKey(mediumId), true); + + const ctx = makeUserContext(tx, other, { + kv, + altTextGenerator: makeAltTextModel("Should not be called."), + }); + const result = await execute({ + schema, + document: generatedAltTextQuery, + contextValue: ctx, + variableValues: { id: relayId, language: "en" }, + }); + assertEquals( + result.errors != null, + true, + "non-owner should be denied during active window", + ); + }); + }, +}); + +Deno.test({ + name: "generatedAltText: any authenticated user allowed after window expires", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const { account } = await insertAccountWithActor(tx, { + username: "expired_window_user", + name: "User", + email: "expired_window_user@example.com", + }); + const mediumId = generateUuidV7(); + const relayId = await insertTestMedium(tx, mediumId); + + // No KV entries at all — simulates the window having expired. + const ctx = makeUserContext(tx, account, { + altTextGenerator: makeAltTextModel("Old image."), + }); + const result = await execute({ + schema, + document: generatedAltTextQuery, + contextValue: ctx, + variableValues: { id: relayId, language: "en" }, + }); + assertEquals( + result.errors, + undefined, + "any authenticated user should succeed after window expires", + ); + }); + }, +}); + +Deno.test({ + name: + "generatedAltText: two accounts uploading identical content both get access", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const { account: accountA } = await insertAccountWithActor(tx, { + username: "dedup_account_a", + name: "Account A", + email: "dedup_a@example.com", + }); + const { account: accountB } = await insertAccountWithActor(tx, { + username: "dedup_account_b", + name: "Account B", + email: "dedup_b@example.com", + }); + // Same medium row shared by content-hash deduplication. + const mediumId = generateUuidV7(); + const relayId = await insertTestMedium(tx, mediumId); + + const { kv, store } = createTestKv(); + store.set(getMediumOwnerKey(mediumId, accountA.id), true); + store.set(getMediumOwnerKey(mediumId, accountB.id), true); + store.set(getMediumUploadWindowKey(mediumId), true); + + for (const account of [accountA, accountB]) { + const ctx = makeUserContext(tx, account, { + kv, + altTextGenerator: makeAltTextModel("Shared image."), + }); + const result = await execute({ + schema, + document: generatedAltTextQuery, + contextValue: ctx, + variableValues: { id: relayId, language: "en" }, + }); + assertEquals( + result.errors, + undefined, + `${account.username} should be allowed`, + ); + } + }); + }, +}); diff --git a/graphql/post.ts b/graphql/post.ts index d5e07575e..48d39b17d 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -1,3 +1,4 @@ +import { generateAltText } from "@hackerspub/ai/alttext"; import { getLogger } from "@logtape/logtape"; import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; import { unreachable } from "@std/assert"; @@ -61,12 +62,15 @@ import { createMediumUploadSession, deleteMediumUploadSession, getMediumUploadSession, + isMediumOwner, + isMediumUploadWindowActive, MEDIUM_UPLOAD_TTL_MS, + setMediumOwner, } from "./medium-upload.ts"; import { Account } from "./account.ts"; import { Actor } from "./actor.ts"; import { builder, Node, type UserContext } from "./builder.ts"; -import { InvalidInputError } from "./error.ts"; +import { InvalidInputError, NotAuthorizedError } from "./error.ts"; import { lookupPostByUrl, parseHttpUrl } from "./lookup.ts"; import { putArticleOgImage } from "./og.ts"; import { PostVisibility, toPostVisibility } from "./postvisibility.ts"; @@ -715,6 +719,45 @@ export const Medium = builder.drizzleNode("mediumTable", { }), }); +builder.drizzleObjectField(Medium, "generatedAltText", (t) => + t.string({ + nullable: true, + description: "AI-generated alternative text for this medium. " + + "Requires authentication. " + + "Within the 2-hour upload window only the uploader may call this " + + "field; after the window expires any authenticated user may call it " + + "(the medium is either publicly referenced or pending orphan cleanup). " + + "Multiple uploaders of identical content each get independent " + + "ownership entries, so content-hash deduplication does not grant " + + "the later uploader access to the earlier one's window. " + + "High-complexity operation (cost 1000). " + + "The context argument is truncated server-side to 1000 characters.", + complexity: 1000, + args: { + language: t.arg({ type: "Locale", required: true }), + context: t.arg({ type: "String", required: false }), + }, + async resolve(medium, args, ctx) { + const session = await ctx.session; + if (session == null) throw new NotAuthenticatedError(); + const isOwner = await isMediumOwner(ctx.kv, medium.id, session.accountId); + if (!isOwner) { + const windowActive = await isMediumUploadWindowActive( + ctx.kv, + medium.id, + ); + if (windowActive) throw new NotAuthorizedError(); + } + const imageUrl = await ctx.disk.getUrl(medium.key); + return await generateAltText({ + model: ctx.altTextGenerator, + imageUrl, + language: (args.language as Intl.Locale).baseName, + context: args.context ?? undefined, + }); + }, + })); + const MediumUploadHeader = builder.simpleObject("MediumUploadHeader", { fields: (t) => ({ name: t.string(), @@ -823,6 +866,9 @@ builder.relayMutationField( quotedPostId, } = args.input; const attachedMedia = media ?? []; + if (attachedMedia.length > 20) { + throw new InvalidInputError("media"); + } let replyTarget: schema.Post & { actor: schema.Actor } | undefined; if (replyTargetId != null) { replyTarget = await ctx.db.query.postTable.findFirst({ @@ -2286,6 +2332,7 @@ builder.relayMutationField( contentType: upload.contentType, }); if (medium == null) throw new InvalidInputError("uploadId"); + await setMediumOwner(ctx.kv, medium.id, session.accountId); return medium; } finally { try { diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 348134641..5423d03c6 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -754,6 +754,11 @@ type Medium implements Node { """SHA-256 hash of the normalized stored content, if known.""" contentHash: Sha256 created: DateTime! + + """ + AI-generated alternative text for this medium. Requires authentication. Within the 2-hour upload window only the uploader may call this field; after the window expires any authenticated user may call it (the medium is either publicly referenced or pending orphan cleanup). Multiple uploaders of identical content each get independent ownership entries, so content-hash deduplication does not grant the later uploader access to the earlier one's window. High-complexity operation (cost 1000). The context argument is truncated server-side to 1000 characters. + """ + generatedAltText(context: String, language: Locale!): String height: Int id: ID! diff --git a/test/postgres.ts b/test/postgres.ts index 608cac6ae..93f85c6c6 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -4,6 +4,7 @@ import { sql } from "drizzle-orm"; import type { ContextData } from "@hackerspub/models/context"; import type { Transaction } from "@hackerspub/models/db"; import type { Transport } from "@upyo/core"; +import { MockLanguageModelV3 } from "ai/test"; import { accountEmailTable, accountTable, @@ -460,6 +461,17 @@ export function createFedCtx( } as unknown as RequestContext; } +function createNoopAltTextModel(): MockLanguageModelV3 { + return new MockLanguageModelV3({ + doGenerate: async () => { + throw new Error( + "altTextGenerator was called in a test that did not expect it. " + + "Pass altTextGenerator in overrides to handle this.", + ); + }, + }); +} + export function makeUserContext( tx: Transaction, account: AuthenticatedAccount, @@ -470,6 +482,7 @@ export function makeUserContext( const fedCtx = overrides.fedCtx ?? createFedCtx(tx, { kv }); return { + altTextGenerator: createNoopAltTextModel(), db: tx, kv, disk: createTestDisk() as UserContext["disk"], @@ -495,6 +508,7 @@ export function makeGuestContext( const fedCtx = overrides.fedCtx ?? createFedCtx(tx, { kv }); return { + altTextGenerator: createNoopAltTextModel(), db: tx, kv, disk: createTestDisk() as UserContext["disk"], diff --git a/web-next/src/components/NoteComposer.tsx b/web-next/src/components/NoteComposer.tsx index 62c81d402..d9f1bee62 100644 --- a/web-next/src/components/NoteComposer.tsx +++ b/web-next/src/components/NoteComposer.tsx @@ -1,7 +1,19 @@ import { fetchQuery, graphql } from "relay-runtime"; -import { createEffect, createSignal, onCleanup, Show } from "solid-js"; +import { createStore, produce } from "solid-js/store"; +import { + createEffect, + createSignal, + For, + onCleanup, + onMount, + Show, +} from "solid-js"; import { createMutation, useRelayEnvironment } from "solid-relay"; import { detectLanguage } from "~/lib/langdet.ts"; +import { + UploadAbortedError, + uploadMediumFile, +} from "~/lib/uploadMediumWithProgress.ts"; import { LanguageSelect } from "~/components/LanguageSelect.tsx"; import { MentionAutocomplete } from "~/components/MentionAutocomplete.tsx"; import { @@ -25,6 +37,7 @@ import IconX from "~icons/lucide/x"; import type { NoteComposerMutation } from "./__generated__/NoteComposerMutation.graphql.ts"; import type { NoteComposerPostByUrlQuery } from "./__generated__/NoteComposerPostByUrlQuery.graphql.ts"; import type { NoteComposerQuotedPostQuery } from "./__generated__/NoteComposerQuotedPostQuery.graphql.ts"; +import type { NoteComposerGeneratedAltTextQuery } from "./__generated__/NoteComposerGeneratedAltTextQuery.graphql.ts"; const NoteComposerMutation = graphql` mutation NoteComposerMutation($input: CreateNoteInput!) { @@ -82,6 +95,43 @@ const NoteComposerPostByUrlQuery = graphql` } `; +const NoteComposerGeneratedAltTextQuery = graphql` + query NoteComposerGeneratedAltTextQuery( + $mediumId: ID! + $language: Locale! + $context: String + ) { + node(id: $mediumId) { + ... on Medium { + generatedAltText(language: $language, context: $context) + } + } + } +`; + +const SUPPORTED_IMAGE_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +]; + +const MAX_MEDIA = 20; + +interface MediaItem { + localId: string; + file: File; + previewUrl: string; + alt: string; + mediumRelayId?: string; + uuid?: string; + uploading: boolean; + uploadProgress: number; + generatingAlt: boolean; + abortUpload?: () => void; + altSubscription?: { unsubscribe: () => void }; +} + interface QuotedPostPreview { typename: "Note" | "Article"; excerpt: string; @@ -121,7 +171,84 @@ export function NoteComposer(props: NoteComposerProps) { const [createNote, isCreating] = createMutation( NoteComposerMutation, ); + const [mediaItems, setMediaItems] = createStore([]); + const [isDraggingOver, setIsDraggingOver] = createSignal(false); + let formRef: HTMLFormElement | undefined; + let removeDragListeners: (() => void) | undefined; let textareaRef: HTMLTextAreaElement | undefined; + let fileInputRef: HTMLInputElement | undefined; + + onCleanup(() => { + removeDragListeners?.(); + for (const item of mediaItems) { + item.abortUpload?.(); + item.altSubscription?.unsubscribe(); + URL.revokeObjectURL(item.previewUrl); + } + }); + + // Use capture-phase listeners so Firefox's native textarea drag handling + // cannot block our handlers. relatedTarget in dragleave tells us whether + // the drag is still inside the form, avoiding the need for a counter. + onMount(() => { + const form = formRef; + if (!form) return; + + const hasFiles = (e: DragEvent) => + e.dataTransfer != null && + Array.from(e.dataTransfer.types).includes("Files"); + + // Debounce dragleave instead of relying on relatedTarget, which browsers + // set to null for OS-file drags even when the cursor is still inside the + // form. dragenter always fires before dragleave, so if the cursor moves + // to a descendant the next dragenter cancels the timer before it fires. + let dragLeaveTimer: ReturnType | undefined; + + const onDragEnter = (e: DragEvent) => { + clearTimeout(dragLeaveTimer); + dragLeaveTimer = undefined; + if (hasFiles(e) && mediaItems.length < MAX_MEDIA) { + setIsDraggingOver(true); + } + }; + + const onDragOver = (e: DragEvent) => { + if (hasFiles(e)) { + e.preventDefault(); + } + }; + + const onDragLeave = () => { + dragLeaveTimer = setTimeout(() => { + dragLeaveTimer = undefined; + setIsDraggingOver(false); + }, 50); + }; + + const onDrop = (e: DragEvent) => { + clearTimeout(dragLeaveTimer); + dragLeaveTimer = undefined; + setIsDraggingOver(false); + if (!hasFiles(e)) return; + e.preventDefault(); + const files = e.dataTransfer!.files; + if (files) addFiles(files); + }; + + const opts = { capture: true } as const; + form.addEventListener("dragenter", onDragEnter, opts); + form.addEventListener("dragover", onDragOver, opts); + form.addEventListener("dragleave", onDragLeave, opts); + form.addEventListener("drop", onDrop, opts); + + removeDragListeners = () => { + clearTimeout(dragLeaveTimer); + form.removeEventListener("dragenter", onDragEnter, opts); + form.removeEventListener("dragover", onDragOver, opts); + form.removeEventListener("dragleave", onDragLeave, opts); + form.removeEventListener("drop", onDrop, opts); + }; + }); // Fetch quoted post preview when quotedPostId changes createEffect(() => { @@ -184,7 +311,102 @@ export function NoteComposer(props: NoteComposerProps) { } }); + const addFiles = (files: FileList | File[]) => { + const fileArray = Array.from(files).filter((f) => + SUPPORTED_IMAGE_TYPES.includes(f.type) + ); + if (fileArray.length === 0) return; + + const current = mediaItems; + const remaining = MAX_MEDIA - current.length; + if (remaining <= 0) { + showToast({ + title: t`Error`, + description: t`You can attach up to ${MAX_MEDIA} images`, + variant: "error", + }); + return; + } + + const toAdd = fileArray.slice(0, remaining); + if (toAdd.length < fileArray.length) { + showToast({ + title: t`Warning`, + description: + t`Some images were skipped because the limit of ${MAX_MEDIA} was reached`, + variant: "warning", + }); + } + // Create handles before inserting items so abortUpload is set from the + // start, avoiding a second setMediaItems pass for each file. + const newItems: MediaItem[] = toAdd.map((file) => { + const localId = crypto.randomUUID(); + const handle = uploadMediumFile(file, (progress) => { + setMediaItems(produce((items) => { + const m = items.find((m) => m.localId === localId); + if (m) m.uploadProgress = progress; + })); + }); + handle.result.then((result) => { + setMediaItems(produce((items) => { + const m = items.find((m) => m.localId === localId); + if (m) { + m.uploading = false; + m.uploadProgress = 100; + m.uuid = result.uuid; + m.mediumRelayId = result.mediumRelayId; + m.abortUpload = undefined; + } + })); + }).catch((err) => { + if (err instanceof UploadAbortedError) return; + setMediaItems(produce((items) => { + const idx = items.findIndex((m) => m.localId === localId); + if (idx !== -1) { + URL.revokeObjectURL(items[idx].previewUrl); + items.splice(idx, 1); + } + })); + showToast({ + title: t`Error`, + description: err instanceof Error && err.message + ? err.message + : t`Failed to upload image`, + variant: "error", + }); + }); + return { + localId, + file, + previewUrl: URL.createObjectURL(file), + alt: "", + uploading: true, + uploadProgress: 0, + generatingAlt: false, + abortUpload: handle.abort, + }; + }); + + setMediaItems(produce((items) => { + items.push(...newItems); + })); + }; + const handlePaste = (e: ClipboardEvent) => { + // Check for pasted images first + const files = e.clipboardData?.files; + if (files && files.length > 0) { + const imageFiles = Array.from(files).filter((f) => + SUPPORTED_IMAGE_TYPES.includes(f.type) + ); + if (imageFiles.length > 0) { + e.preventDefault(); + addFiles(imageFiles); + return; + } + } + + // Fall through to URL-paste-to-quote logic if (effectiveQuotedPostId()) return; const text = e.clipboardData?.getData("text/plain")?.trim(); if (!text || !URL.canParse(text) || !text.match(/^https?:/)) return; @@ -234,6 +456,11 @@ export function NoteComposer(props: NoteComposerProps) { }; const resetForm = () => { + for (const item of mediaItems) { + item.abortUpload?.(); + item.altSubscription?.unsubscribe(); + URL.revokeObjectURL(item.previewUrl); + } setContent(""); setVisibility("PUBLIC"); setLanguage(new Intl.Locale(i18n.locale)); @@ -241,6 +468,7 @@ export function NoteComposer(props: NoteComposerProps) { setQuotedPost(null); setPastedQuoteId(null); setQuoteFetchError(false); + setMediaItems([]); }; const handleSubmit = (e: Event) => { @@ -256,6 +484,24 @@ export function NoteComposer(props: NoteComposerProps) { return; } + const items = mediaItems; + if (items.some((m) => m.uploading)) { + showToast({ + title: t`Error`, + description: t`All images must finish uploading before posting`, + variant: "error", + }); + return; + } + if (items.some((m) => !m.alt.trim())) { + showToast({ + title: t`Error`, + description: t`All images require alt text`, + variant: "error", + }); + return; + } + createNote({ variables: { input: { @@ -264,6 +510,11 @@ export function NoteComposer(props: NoteComposerProps) { visibility: visibility(), quotedPostId: effectiveQuotedPostId() ?? null, replyTargetId: props.replyTargetId ?? null, + media: items.map((m) => ({ + mediumId: m + .uuid! as `${string}-${string}-${string}-${string}-${string}`, + alt: m.alt.trim(), + })), }, }, onCompleted(response) { @@ -301,9 +552,79 @@ export function NoteComposer(props: NoteComposerProps) { }); }; + const handleGenerateAlt = (localId: string) => { + const item = mediaItems.find((m) => m.localId === localId); + if (!item?.mediumRelayId) return; + + setMediaItems(produce((items) => { + const m = items.find((m) => m.localId === localId); + if (m) m.generatingAlt = true; + })); + + const subscription = fetchQuery( + environment(), + NoteComposerGeneratedAltTextQuery, + { + mediumId: item.mediumRelayId, + language: language()?.baseName ?? i18n.locale, + context: content().trim() || undefined, + }, + ).subscribe({ + next(data) { + const medium = data.node; + if (medium && "generatedAltText" in medium) { + setMediaItems(produce((items) => { + const m = items.find((m) => m.localId === localId); + if (m) { + m.generatingAlt = false; + m.alt = medium.generatedAltText ?? m.alt; + m.altSubscription = undefined; + } + })); + } else { + setMediaItems(produce((items) => { + const m = items.find((m) => m.localId === localId); + if (m) { + m.generatingAlt = false; + m.altSubscription = undefined; + } + })); + } + }, + error(err: Error) { + setMediaItems(produce((items) => { + const m = items.find((m) => m.localId === localId); + if (m) { + m.generatingAlt = false; + m.altSubscription = undefined; + } + })); + showToast({ + title: t`Error`, + description: err?.message || t`Failed to generate alt text`, + variant: "error", + }); + }, + }); + setMediaItems(produce((items) => { + const m = items.find((m) => m.localId === localId); + if (m) m.altSubscription = subscription; + })); + }; + return ( -
-
+ (formRef = el)} + onSubmit={handleSubmit} + class={props.class} + > +
{/* Quoted post preview */}
@@ -413,25 +734,183 @@ export function NoteComposer(props: NoteComposerProps) { textareaRef} onComplete={() => { - // Update content signal after autocomplete inserts text if (textareaRef) setContent(textareaRef.value); }} /> -
- + + {/* Toolbar: language, visibility, attach button */} +
-
-
- - + +
+ + (fileInputRef = el)} + type="file" + accept={SUPPORTED_IMAGE_TYPES.join(",")} + multiple + class="hidden" + onChange={(e) => { + const files = e.currentTarget.files; + if (files) addFiles(files); + e.currentTarget.value = ""; + }} />
+ + {/* Media previews */} + 0}> +
+ + {(item, index) => ( +
+ {/* Thumbnail with progress overlay */} +
+ + +
+ + + {item.uploadProgress}% + +
+
+
+ + {/* Alt text input + controls */} +
+