diff --git a/WORKLOG.md b/WORKLOG.md index 6b5ad87..5a9a9e1 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -159,14 +159,18 @@ Details: Cycle 34 (Codex-6) applied `@fastify/rate-limit` to `POST /v1/channels/ Files: `services/core/src/app.ts` + `packages/contracts/src/protocol.ts` (flag Claude for the schema shape) + test Details: Two related gaps Codex-12 surfaced: no `GET /v1/communities/:communityId/members` route exists (only mutation routes), and message `author` snapshots never refresh after send time (affects `customStatus` and presence `status` alike). Scope this as: add the member-list route (returns each member's current `Account` + role IDs, live-looked-up — this alone unblocks member-list-based visibility, which is a smaller, more contained fix than making message-author snapshots live). Flag to Claude whether refreshing message-author snapshots is worth pursuing separately, or deferred indefinitely as a "point-in-time" design choice. +**Codex-18 landed** (`d7cc6ef`) — Claude did this one directly rather than assigning it (small, well-understood, faster to just do). Bumped to 50 MB. Found a deeper duplication while doing it: `initiateUploadRequestSchema`'s `size` field had its own independent `.max(26_214_400)` in `protocol.ts`, hardcoded separately from `app.ts`'s constant — bumping only the `app.ts` side would have left uploads silently capped at the old 25 MB via schema rejection regardless. Moved `MAX_ATTACHMENT_BYTES` into `@cove/contracts` as the single source of truth for both sides; both hardcoded `"25 MB"` error strings now derive from it. 2 new tests. + > **Note on the gap here:** Codex-13/14/15 (guided reports, community rename, rich link embeds) were reassigned off this queue and completed tonight (2026-07-02 evening) via a parallel Grok CLI trial run instead — see the "Grok" section below. DM channels (P3 item 32) is the natural next big backend feature, but it needs a real schema design session (`communityId` is required on every existing channel/message code path today; making DMs fit will touch a lot of call sites) rather than a rushed decision — flagging for a dedicated Claude scoping pass before assigning it to any agent. --- -### Grok — ad-hoc parallel trial, 2026-07-02 evening (all 4 tasks landed) +### Grok — ad-hoc parallel trial, 2026-07-02 evening (all 4 tasks landed) + AG-20 running Operator has a 7-day trial of the Grok CLI (`C:\Users\Vornecs\.grok\bin\grok.exe`, headless via `-p`/`--prompt-file`). Workflow: each task ran in its own fully disposable local git clone (no `origin` remote, no `git alternates` — zero path back to the real repo) under the session scratchpad, with `--permission-mode bypassPermissions` scoped to that one throwaway directory (explicitly operator-authorized each time). Claude reviewed every diff, independently re-ran `typecheck`/`test` from scratch (not trusting Grok's self-report), fixed real gaps found, and manually re-applied each change into the real repo before committing — Grok never had direct write access to the real working tree or push access to GitHub. +**Model correction:** the CLI's default resolved to `grok-composer-2.5-fast` at some point during tonight's earlier runs — its own model registry (`~/.grok/models_cache.json`) labels this model `"description": "Cursor's latest coding model"`, `"agent_type": "cursor"`, i.e. **not an xAI/Grok model at all**, apparently proxied through the same CLI. `~/.grok/config.toml` has `[models] default = "grok-build"` (labelled `"xAI's latest coding model"` in the same registry), which is what should be used — flagged by the operator, verified by checking `--debug-file` output confirming `model: "grok-build"` against `cli-chat-proxy.grok.com`. **All invocations from AG-20 onward pass `-m grok-build` explicitly** rather than relying on whatever resolves as default. Unclear whether the earlier 4 trial tasks tonight ran on `grok-build` or `grok-composer-2.5-fast` — the outputs were reviewed and verified independently either way, so the shipped code is sound regardless of which model produced it, but the model-identity itself for that batch is not confirmed. + **All 4 trial tasks landed and verified tonight:** 1. **Rich link-embed backend** (former Codex-15 scope, P3 item 16) — `4788fd8`. One real gap found and fixed by Claude: IPv4-mapped IPv6 addresses (e.g. `::ffff:10.0.0.5`) weren't checked against the private-IP blocklist, a genuine SSRF-adjacent gap. @@ -174,6 +178,10 @@ Operator has a 7-day trial of the Grok CLI (`C:\Users\Vornecs\.grok\bin\grok.exe 3. **Community rename/edit endpoint** (former Codex-14 scope, P3 item 25, partial) — `f2788de`. Correctly caught a gap Claude's own task spec missed: `'community'` needed adding to `auditEventSchema.targetType`, not just the `action` enum, or the audit-log assertion would fail schema validation. 4. **Themes & appearance settings panel** (P2 item 15) — `5acd95f`. Matched spec closely; curated 5-preset palette, live CSS-variable preview, full accessibility attributes (`aria-pressed`/`aria-checked`/`radiogroup`), no `App.tsx` edit. +**Landed, confirmed on `grok-build`:** + +5. **AG-20 — composer attachment upload UI** (P3 item 17, frontend half 1/2) — `b558f7a`. Extends `Composer.tsx` with drag-drop, file picker wiring, client-side image compression (canvas resize + re-encode, only above ~2MB), and a pending-attachment preview strip. Matched spec closely — no revision needed. Took noticeably longer than the earlier 4 tasks (60+ tool-call turns, multi-million cumulative tokens per the debug log) — almost certainly the jsdom canvas/Image mocking, which the task prompt had flagged as the tricky part up front. Worth knowing for future task sizing: browser-API-mocking-heavy frontend tasks cost meaningfully more than backend-route tasks of similar prompt complexity. + **Process note — real, not hypothetical:** tasks 2-4 ran while Codex was _simultaneously_ live-editing the same real `services/core/src/app.ts`/`app.test.ts` for Codex-12, uncommitted, in the same working tree. Verified this was really happening (via `tasklist`/hash-stability polling, not assumption) before touching anything. Landing tasks 2-3 required manually separating Claude's clean hunks from Codex's in-progress hunks via `git apply --cached` (hunk-by-hunk, since one import line was genuinely shared) rather than a blind `git add -A`, verifying the isolated staged state independently (via `git stash push --keep-index`) before committing, then restoring Codex's stash — one trivial one-line merge conflict on the shared import, resolved by keeping both. **Lesson for future concurrent-agent nights:** confirm file stability before any hunk-separation work, and don't trust a `npm test` run's failure at face value in a shared working tree without confirming which agent's code the failure actually belongs to (the first failing-test read this session belonged to a test Codex had already deleted by the time it was diagnosed). --- @@ -196,7 +204,13 @@ Details: Per-channel notification settings beyond mute: all-messages / @mention- **AG-19:** Attachment approval panel component (P3 item 27) Files: `apps/web/src/AttachmentApprovalPanel.tsx` (new), unit tests -Details: Admin approve/reject UI badge/panel for uploads stuck in "pending" quarantine. Backend quarantine route already exists (check `services/core/src/app.ts` for the current attachment-status route names before wiring props). Props: `attachment: { id, filename, status, uploadedBy }`, `onApprove: (id) => void`, `onReject: (id) => void`. Do NOT edit App.tsx. +Details: **Correction from earlier assumption:** there is no existing approve/reject route to wire against — `app.ts`'s upload endpoint (`app.ts:2005`) auto-approves on successful upload (`quarantineStatus` goes straight from `'pending'` to `'approved'`), so nothing is actually stuck pending today. Build the panel UI on spec anyway (approve/reject buttons, badge states) since a real moderation-review step is a reasonable future addition, but note in your status file that a backend `PATCH`-style approve/reject route doesn't exist yet and would need to be added before this panel has anything real to call. Props: `attachment: { id, filename, status, uploadedBy }`, `onApprove: (id) => void`, `onReject: (id) => void`. Do NOT edit App.tsx. + +**AG-20 landed via Grok tonight** (`b558f7a`) — reviewed and matches spec closely: file picker + drag-drop wired to the previously-inert attach button, compression correctly gated to `image/*` over 2MB only, picks the smaller of JPEG/WebP re-encodes, graceful fallback to the original file on any error, proper object-URL cleanup via `useEffect`, presentation-only (no network calls). 6 new tests with solid `canvas`/`Image`/`URL` mocking. No revision needed. + +**AG-21:** Inline attachment rendering in messages (P3 item 17, frontend half 2/2) — **left queued for Antigravity's normal rotation** +Files: `apps/web/src/MessageAttachment.tsx` (new), unit tests +Details: `Message.attachments` (`Attachment[]` — `id`, `filename`, `mimeType`, `size`, `quarantineStatus`, `createdAt`) is already on every message from the backend; content is served from `GET /v1/attachments/:attachmentId/content`. Build a component that, given `attachments: Attachment[]`, renders each one inline: `image/*` MIME types render as a clickable `` (pointed at the content endpoint) with a sensible max-width/height so a large image doesn't blow out the message bubble — clicking opens the full image in a new tab is fine for v1, no lightbox needed. Everything else (video/audio/pdf/zip/text) renders as a compact download chip: filename, human-readable size, download icon, linking to the content endpoint. Props: `attachments: Attachment[]`. Full unit test coverage. Do NOT edit App.tsx or MessageList.tsx — Claude wires this into `MessageList.tsx`'s per-message render once both this and AG-20 land. --- @@ -251,7 +265,7 @@ Claude pulls from here when agent work is integrated. Agents work from their own ### P3 — Core messaging polish 16. **Rich link embeds** — YouTube, Twitter/X, images, OG tags unfurl inline. ✅ backend unfurl endpoint (`POST /v1/link-preview`) shipped 2026-07-02 evening via Grok trial run + Claude review (`4788fd8`); frontend rendering (inline unfurl in message list) not yet assigned. -17. **File & image uploads** — drag-drop, inline previews +17. **File & image uploads** — drag-drop, inline previews. Operator explicitly asked for this (2026-07-02 evening) — wants pics uploadable in channels/servers/group chats, with client-side compression and limits looser than Discord's. Backend already fully built and working (initiate/upload/serve/attach-to-message) — this is a frontend-only gap. Split three ways per operator request: ✅ byte-cap bump done directly by Claude (Codex-18, `d7cc6ef`); ✅ composer upload UI done via Grok (AG-20, `b558f7a`); inline message rendering left queued for Antigravity's normal rotation (AG-21). **Unlike the rest of the unwired-component backlog, Claude is committing to wire these into `App.tsx` as soon as they land — not left to pile up.** 18. **Pinned messages** — backend done Cycle 34 (Codex-8: pin/unpin/list endpoints, permission-gated); UI (pin button + panel) — Claude Cycle 35 queue. 19. **Message search** — keyword search within channel (deferred — see P5, overlaps with the FTS stub) 20. ✅ ~~React error boundary~~ — component built Cycle 32 (AG-2), wired into `main.tsx` Cycle 33 diff --git a/apps/web/src/Composer.test.tsx b/apps/web/src/Composer.test.tsx index d1c13df..bce3b27 100644 --- a/apps/web/src/Composer.test.tsx +++ b/apps/web/src/Composer.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import Composer from './Composer'; describe('Composer', () => { @@ -163,3 +163,357 @@ describe('Composer', () => { expect(sendBtn).toBeDisabled(); }); }); + +// --------------------------------------------------------------------------- +// Attachment UI tests (AG-20) +// --------------------------------------------------------------------------- + +// Helpers to mock browser APIs that jsdom lacks full support for (canvas + Image onload) +function setupBrowserApiMocks() { + // URL.create/revoke for previews and internal load + const createObjectURL = vi.fn( + (blob?: Blob) => `blob:mock-${(blob as any)?.size || Math.random()}`, + ); + const revokeObjectURL = vi.fn(); + // stub globals + vi.stubGlobal('URL', { + ...globalThis.URL, + createObjectURL, + revokeObjectURL, + }); + + // Mock Image constructor: trigger onload "immediately" (async microtask) when .src assigned + const MockImage = vi.fn(function (this: any) { + this.width = 4000; + this.height = 3000; + this.onload = null as (() => void) | null; + this.onerror = null as (() => void) | null; + let _src = ''; + Object.defineProperty(this, 'src', { + configurable: true, + set(val: string) { + _src = val; + // fire async to mimic real load without hanging tests + Promise.resolve().then(() => { + if (this.onload) this.onload(); + }); + }, + get() { + return _src; + }, + }); + }); + vi.stubGlobal('Image', MockImage); + + // Canvas 2d + toBlob: produce a deterministic smaller result; support jpeg/webp pick + const origGetContext = HTMLCanvasElement.prototype.getContext; + const origToBlob = HTMLCanvasElement.prototype.toBlob; + + const mockCtx = { drawImage: vi.fn() }; + HTMLCanvasElement.prototype.getContext = vi.fn(function (this: any, type: string) { + if (type === '2d') return mockCtx as any; + return null; + }) as any; + + HTMLCanvasElement.prototype.toBlob = vi.fn(function ( + this: any, + cb: (b: Blob | null) => void, + type = 'image/jpeg', + _q?: number, + ) { + const isWebp = (type || '').includes('webp'); + const size = isWebp ? 42000 : 58000; // webp smaller so pick wins + const blob = new Blob([new Uint8Array(size)], { type: type || 'image/jpeg' }); + // call sync (our impl awaits Promise wrapper anyway) + cb(blob); + }) as any; + + return { + createObjectURL, + revokeObjectURL, + restore: () => { + try { + HTMLCanvasElement.prototype.getContext = origGetContext; + HTMLCanvasElement.prototype.toBlob = origToBlob; + } catch {} + vi.unstubAllGlobals(); + }, + }; +} + +describe('Composer attachments', () => { + let mocks: ReturnType; + + beforeEach(() => { + mocks = setupBrowserApiMocks(); + }); + + afterEach(() => { + mocks?.restore?.(); + vi.restoreAllMocks(); + }); + + it('clicking the attach button opens the file input (via ref.click)', async () => { + const user = userEvent.setup(); + render( + {}} + onSend={() => {}} + replyTo={null} + onCancelReply={() => {}} + />, + ); + + const attachBtn = screen.getByRole('button', { name: 'Add attachment' }); + const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click'); + + await user.click(attachBtn); + + expect(clickSpy).toHaveBeenCalledTimes(1); + }); + + it('selecting files via the picker calls onFilesSelected with (possibly compressed) files', async () => { + const user = userEvent.setup(); + const onFilesSelected = vi.fn(); + render( + {}} + onSend={() => {}} + replyTo={null} + onCancelReply={() => {}} + onFilesSelected={onFilesSelected} + />, + ); + + const attachBtn = screen.getByRole('button', { name: 'Add attachment' }); + await user.click(attachBtn); + + const fileInput = screen.getByTestId('file-input') as HTMLInputElement; + const smallImg = new File([new Uint8Array(1234)], 'small.png', { type: 'image/png' }); + await user.upload(fileInput, smallImg); + + await waitFor(() => { + expect(onFilesSelected).toHaveBeenCalledTimes(1); + }); + const calls = onFilesSelected.mock.calls; + const passed = (calls[0]?.[0] ?? []) as File[]; + expect(passed).toHaveLength(1); + expect(passed[0]!.name).toBe('small.png'); + expect(passed[0]!.type).toBe('image/png'); + }); + + it('dropping files onto the composer calls onFilesSelected', async () => { + const onFilesSelected = vi.fn(); + render( + {}} + onSend={() => {}} + replyTo={null} + onCancelReply={() => {}} + onFilesSelected={onFilesSelected} + />, + ); + + const form = screen.getByTestId('composer-form'); + const txtFile = new File(['hello world'], 'note.txt', { type: 'text/plain' }); + + const dataTransfer = { + files: [txtFile], + items: [{ kind: 'file', getAsFile: () => txtFile }], + types: ['Files'], + } as any; + + fireEvent.dragEnter(form, { dataTransfer }); + fireEvent.dragOver(form, { dataTransfer }); + fireEvent.drop(form, { dataTransfer }); + + await waitFor(() => { + expect(onFilesSelected).toHaveBeenCalledTimes(1); + }); + const calls = onFilesSelected.mock.calls; + const passed = (calls[0]?.[0] ?? []) as File[]; + expect(passed[0]!.name).toBe('note.txt'); + }); + + it('renders pending-attachment strip with one chip per item; thumbnails for images, filename for others', () => { + const imgFile = new File([new Uint8Array(100)], 'photo.jpg', { type: 'image/jpeg' }); + const vidFile = new File([new Uint8Array(200)], 'clip.mp4', { type: 'video/mp4' }); + + const pending = [ + { file: imgFile, previewUrl: 'blob:mock-preview-img' }, + { file: vidFile }, // no preview -> filename only + ]; + + render( + {}} + onSend={() => {}} + replyTo={null} + onCancelReply={() => {}} + pendingAttachments={pending} + />, + ); + + expect(screen.getByTestId('attachment-strip')).toBeInTheDocument(); + expect(screen.getByTestId('attachment-chip-0')).toBeInTheDocument(); + expect(screen.getByTestId('attachment-chip-1')).toBeInTheDocument(); + + // image chip should have + const chip0 = screen.getByTestId('attachment-chip-0'); + expect(chip0.querySelector('img.attachment-thumb')).toBeInTheDocument(); + expect(chip0.querySelector('img')!.getAttribute('src')).toBe('blob:mock-preview-img'); + + // non-image shows filename text only (no img) + const chip1 = screen.getByTestId('attachment-chip-1'); + expect(chip1.querySelector('img')).not.toBeInTheDocument(); + expect(chip1.textContent).toContain('clip.mp4'); + }); + + it('clicking a remove button calls onRemoveAttachment with the correct index', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + const f1 = new File([new Uint8Array(10)], 'a.png', { type: 'image/png' }); + const f2 = new File([new Uint8Array(20)], 'b.pdf', { type: 'application/pdf' }); + + render( + {}} + onSend={() => {}} + replyTo={null} + onCancelReply={() => {}} + pendingAttachments={[{ file: f1, previewUrl: 'blob:1' }, { file: f2 }]} + onRemoveAttachment={onRemove} + />, + ); + + const chip0 = screen.getByTestId('attachment-chip-0'); + const remove0 = chip0.querySelector('button')!; + await user.click(remove0); + + expect(onRemove).toHaveBeenCalledTimes(1); + expect(onRemove).toHaveBeenCalledWith(0); + + const chip1 = screen.getByTestId('attachment-chip-1'); + const remove1 = chip1.querySelector('button')!; + await user.click(remove1); + + expect(onRemove).toHaveBeenCalledTimes(2); + expect(onRemove).toHaveBeenLastCalledWith(1); + }); + + it('large image goes through compression (size reduced via canvas path); small image and non-image do not', async () => { + const user = userEvent.setup(); + + // large + const onSelLarge = vi.fn(); + const getCtxSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext'); + + const r1 = render( + {}} + onSend={() => {}} + replyTo={null} + onCancelReply={() => {}} + onFilesSelected={onSelLarge} + />, + ); + + const big = new File([new Uint8Array(3_200_000)], 'huge.png', { type: 'image/png' }); + const input = screen.getByTestId('file-input') as HTMLInputElement; + await user.upload(input, big); + + await waitFor(() => expect(onSelLarge).toHaveBeenCalled()); + const callsL = onSelLarge.mock.calls; + const resLarge = (callsL[0]?.[0]?.[0] ?? {}) as File; + expect(getCtxSpy).toHaveBeenCalled(); // compression path exercised + expect(resLarge.size).toBeLessThan(big.size); + expect(resLarge.size).toBe(42000); // the webp one we mocked as smaller + r1.unmount(); + + // fresh render for small: size unchanged proves no compression path + const onSelSmall = vi.fn(); + const r2 = render( + {}} + onSend={() => {}} + replyTo={null} + onCancelReply={() => {}} + onFilesSelected={onSelSmall} + />, + ); + const small = new File([new Uint8Array(900_000)], 'tiny.jpg', { type: 'image/jpeg' }); + const inp2 = screen.getByTestId('file-input') as HTMLInputElement; + await user.upload(inp2, small); + await waitFor(() => expect(onSelSmall).toHaveBeenCalled()); + const callsS = onSelSmall.mock.calls; + const resSmall = (callsS[0]?.[0]?.[0] ?? {}) as File; + expect(resSmall.size).toBe(small.size); + r2.unmount(); + + // non-image (even if "large"): size unchanged proves untouched + const onSelNon = vi.fn(); + const r3 = render( + {}} + onSend={() => {}} + replyTo={null} + onCancelReply={() => {}} + onFilesSelected={onSelNon} + />, + ); + const pdf = new File([new Uint8Array(4_000_000)], 'bigdoc.pdf', { type: 'application/pdf' }); + const inp3 = screen.getByTestId('file-input') as HTMLInputElement; + await user.upload(inp3, pdf); + await waitFor(() => expect(onSelNon).toHaveBeenCalled()); + const callsN = onSelNon.mock.calls; + const resPdf = (callsN[0]?.[0]?.[0] ?? {}) as File; + expect(resPdf.size).toBe(pdf.size); + r3.unmount(); + }); + + it('keeps the original file when every re-encoded candidate is larger than the source', async () => { + // Override toBlob for this test only: produce blobs bigger than the + // source file, so compression must fall back to the original rather + // than "compressing" into something larger. + HTMLCanvasElement.prototype.toBlob = vi.fn(function ( + this: HTMLCanvasElement, + cb: (b: Blob | null) => void, + type = 'image/jpeg', + ) { + const blob = new Blob([new Uint8Array(5_000_000)], { type: type || 'image/jpeg' }); + cb(blob); + }) as typeof HTMLCanvasElement.prototype.toBlob; + + const user = userEvent.setup(); + const onSelected = vi.fn(); + render( + {}} + onSend={() => {}} + replyTo={null} + onCancelReply={() => {}} + onFilesSelected={onSelected} + />, + ); + + const big = new File([new Uint8Array(2_500_000)], 'oversized-reencode.png', { + type: 'image/png', + }); + const input = screen.getByTestId('file-input') as HTMLInputElement; + await user.upload(input, big); + + await waitFor(() => expect(onSelected).toHaveBeenCalled()); + const passed = (onSelected.mock.calls[0]?.[0] ?? []) as File[]; + expect(passed[0]!.size).toBe(big.size); + expect(passed[0]!.type).toBe('image/png'); + }); +}); diff --git a/apps/web/src/Composer.tsx b/apps/web/src/Composer.tsx index 5a9f3a2..6a88bf7 100644 --- a/apps/web/src/Composer.tsx +++ b/apps/web/src/Composer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import { IconButton } from '@cove/ui'; import { LockKeyhole, Plus, Smile, SendHorizontal } from 'lucide-react'; @@ -11,6 +11,10 @@ export interface ComposerProps { disabled?: boolean; placeholder?: string; isSealed?: boolean; + // Attachment UI (controlled by parent; Composer does not perform network) + pendingAttachments?: Array<{ file: File; previewUrl?: string }>; + onFilesSelected?: (files: File[]) => void; + onRemoveAttachment?: (index: number) => void; } export function Composer({ @@ -22,7 +26,58 @@ export function Composer({ disabled = false, placeholder = 'Message...', isSealed = false, + pendingAttachments = [], + onFilesSelected = () => {}, + onRemoveAttachment = () => {}, }: ComposerProps) { + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + // Track object URLs we created for previews (when parent does not supply previewUrl) + const generatedUrlsRef = useRef>(new Map()); + // Stable React keys per File — array index shifts when an item is removed + // from the middle of the list, which can cause the wrong chip's DOM (and + // its /blob URL) to be reused for a different file. + const attachmentKeysRef = useRef>(new WeakMap()); + const nextKeyIdRef = useRef(0); + + function getAttachmentKey(file: File): string { + let key = attachmentKeysRef.current.get(file); + if (!key) { + key = `attachment-${nextKeyIdRef.current++}`; + attachmentKeysRef.current.set(file, key); + } + return key; + } + + // Cleanup generated URLs on unmount and when pending list changes (removals) + useEffect(() => { + const current = new Set(pendingAttachments.map((a) => a.file)); + for (const [f, url] of Array.from(generatedUrlsRef.current.entries())) { + if (!current.has(f)) { + URL.revokeObjectURL(url); + generatedUrlsRef.current.delete(f); + } + } + }, [pendingAttachments]); + + useEffect(() => { + return () => { + // revoke any we created on unmount + generatedUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); + generatedUrlsRef.current.clear(); + }; + }, []); + + function getDisplayPreview(item: { file: File; previewUrl?: string }): string | undefined { + if (item.previewUrl) return item.previewUrl; + if (!item.file.type.startsWith('image/')) return undefined; + if (!generatedUrlsRef.current.has(item.file)) { + const url = URL.createObjectURL(item.file); + generatedUrlsRef.current.set(item.file, url); + } + return generatedUrlsRef.current.get(item.file); + } + const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); if (value.trim() && !disabled) { @@ -30,8 +85,134 @@ export function Composer({ } }; + // ---- Attachment selection + compression (presentation layer only) ---- + const TWO_MB = 2 * 1024 * 1024; + const MAX_EDGE = 2560; + const QUALITY = 0.85; + + async function loadImageForCompression(file: File): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + resolve(img); + }; + img.onerror = (err) => { + URL.revokeObjectURL(url); + reject(err); + }; + img.src = url; + }); + } + + async function compressImage(file: File): Promise { + const img = await loadImageForCompression(file); + let { width, height } = img; + if (width > MAX_EDGE || height > MAX_EDGE) { + const scale = Math.min(MAX_EDGE / width, MAX_EDGE / height); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return file; + ctx.drawImage(img, 0, 0, width, height); + + const makeBlob = (mime: string) => + new Promise((res) => canvas.toBlob((b) => res(b), mime, QUALITY)); + + const [jpegBlob, webpBlob] = await Promise.all([ + makeBlob('image/jpeg'), + makeBlob('image/webp'), + ]); + + const candidates = [jpegBlob, webpBlob].filter((b): b is Blob => !!b); + if (candidates.length === 0) return file; + + // pick smallest + candidates.sort((a, b) => a.size - b.size); + const best = candidates[0]!; + + // Only use the re-encoded version if it's actually smaller than the + // original — some source formats (e.g. certain PNGs) can re-encode + // larger, which would defeat the point of compressing. + if (best.size >= file.size) return file; + + // keep original filename; update type/lastModified + return new File([best], file.name, { + type: best.type, + lastModified: Date.now(), + }); + } + + async function processFile(file: File): Promise { + if (file.type.startsWith('image/') && file.size > TWO_MB) { + try { + return await compressImage(file); + } catch { + return file; // graceful fallback + } + } + return file; + } + + const handleSelectedFiles = async (selected: File[]) => { + if (!selected.length) return; + const processed = await Promise.all(selected.map(processFile)); + onFilesSelected(processed); + }; + + const openFilePicker = () => { + fileInputRef.current?.click(); + }; + + const handleFileInputChange = async (event: React.ChangeEvent) => { + const selected = Array.from(event.target.files || []); + if (selected.length) { + await handleSelectedFiles(selected); + } + event.target.value = ''; + }; + + // Drag and drop handlers (whole form is drop zone) + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + if (!disabled && !isSealed) { + setIsDragging(true); + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (disabled || isSealed) return; + const dropped = Array.from(e.dataTransfer.files || []); + if (dropped.length > 0) { + await handleSelectedFiles(dropped); + } + }; + + const isImageFile = (f: File) => f.type.startsWith('image/'); + return ( -
+ {replyTo && (
Replying to: {replyTo.preview} @@ -40,13 +221,45 @@ export function Composer({
)} + + {!isSealed && pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map((att, index) => { + const displayUrl = getDisplayPreview(att); + const showThumb = isImageFile(att.file) && displayUrl; + return ( +
+ {showThumb ? ( + {att.file.name} + ) : ( + {att.file.name} + )} + +
+ ); + })} +
+ )} + {isSealed ? (
Sealed messaging enters after the reviewed MLS adapter.
) : ( <> - + )} + + {/* Hidden native file picker (wired to Plus button + accepts per spec) */} +
); } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 3e3d546..7cbd194 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1524,6 +1524,76 @@ button { color: var(--text); } +/* Attachment strip (pending files in composer) */ +.attachment-strip { + display: flex; + gap: 6px; + padding: 6px 8px 4px; + overflow-x: auto; + border-bottom: 1px solid var(--border, #202225); + background: color-mix(in srgb, var(--surface-raised) 70%, transparent); +} + +.attachment-chip { + display: flex; + align-items: center; + gap: 4px; + background: var(--surface-raised, #2f3136); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 4px 4px 6px; + font-size: 12px; + color: var(--text); + white-space: nowrap; + max-width: 180px; + flex-shrink: 0; +} + +.attachment-chip img.attachment-thumb { + width: 28px; + height: 28px; + object-fit: cover; + border-radius: 3px; + flex-shrink: 0; + border: 1px solid var(--border); +} + +.attachment-chip .attachment-name { + overflow: hidden; + text-overflow: ellipsis; + max-width: 110px; + font-family: ui-monospace, monospace; + font-size: 11px; +} + +.attachment-chip button.remove-btn { + background: none; + border: none; + color: var(--text-muted); + font-size: 14px; + line-height: 1; + padding: 0 4px; + cursor: pointer; + border-radius: 3px; +} + +.attachment-chip button.remove-btn:hover { + color: var(--danger, #e3342f); + background: var(--surface-hover); +} + +/* Drag over visual for composer */ +.composer.drag-over { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 6%, var(--surface-raised)); +} + +/* Reply + strip need to span full grid width */ +.reply-bar, +.attachment-strip { + grid-column: 1 / -1; +} + /* Toast Notification Styles */ .toast-container { position: fixed; diff --git a/packages/contracts/src/protocol.ts b/packages/contracts/src/protocol.ts index 08eb003..4dea693 100644 --- a/packages/contracts/src/protocol.ts +++ b/packages/contracts/src/protocol.ts @@ -131,10 +131,12 @@ export const attachmentSchema = z.object({ }); export type Attachment = z.infer; +export const MAX_ATTACHMENT_BYTES = 52_428_800; // 50 MB + export const initiateUploadRequestSchema = z.object({ filename: z.string().min(1).max(256), mimeType: z.string().min(1).max(128), - size: z.number().int().min(1).max(26_214_400), + size: z.number().int().min(1).max(MAX_ATTACHMENT_BYTES), }); export type InitiateUploadRequest = z.infer; diff --git a/services/core/src/app.test.ts b/services/core/src/app.test.ts index a15877c..6bb0430 100644 --- a/services/core/src/app.test.ts +++ b/services/core/src/app.test.ts @@ -1800,7 +1800,7 @@ describe('attachment pipeline', () => { expect(res.headers['content-type']).toContain('application/problem+json'); }); - it('rejects upload initiation when file size exceeds 25 MB', async () => { + it('rejects upload initiation when file size exceeds 50 MB', async () => { const app = await buildApp(); apps.push(app); const { token, channelId } = await setupChannelAndAuth(app); @@ -1809,11 +1809,25 @@ describe('attachment pipeline', () => { method: 'POST', url: `/v1/channels/${channelId}/attachments/initiate`, headers: { authorization: `Bearer ${token}` }, - payload: { filename: 'huge.mp4', mimeType: 'video/mp4', size: 30_000_000 }, + payload: { filename: 'huge.mp4', mimeType: 'video/mp4', size: 60_000_000 }, }); expect(res.statusCode).toBe(400); }); + it('accepts upload initiation for a file under the new 50 MB limit', async () => { + const app = await buildApp(); + apps.push(app); + const { token, channelId } = await setupChannelAndAuth(app); + + const res = await app.inject({ + method: 'POST', + url: `/v1/channels/${channelId}/attachments/initiate`, + headers: { authorization: `Bearer ${token}` }, + payload: { filename: 'big.mp4', mimeType: 'video/mp4', size: 40_000_000 }, + }); + expect(res.statusCode).toBe(201); + }); + it('rejects duplicate upload of the same attachment', async () => { const app = await buildApp(); apps.push(app); diff --git a/services/core/src/app.ts b/services/core/src/app.ts index 7d636bd..80574dc 100644 --- a/services/core/src/app.ts +++ b/services/core/src/app.ts @@ -16,6 +16,7 @@ import { sendMessageRequestSchema, updateChannelReadStateRequestSchema, initiateUploadRequestSchema, + MAX_ATTACHMENT_BYTES, emailLoginRequestSchema, emailVerifyRequestSchema, passkeyRegisterVerifySchema, @@ -297,7 +298,6 @@ const ALLOWED_MIME_TYPES = new Set([ 'application/zip', 'text/plain', ]); -const MAX_ATTACHMENT_BYTES = 26_214_400; // 25 MB const MAX_EMOJI_BYTES = 256 * 1024; const ALLOWED_EMOJI_MIME_TYPES = new Set(['image/png', 'image/gif']); const MAX_REPORTS = 10_000; @@ -1887,7 +1887,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise