Fix media attachment actions (#4849)

## Summary

- Upload photos immediately while keeping videos queued for background
upload.
- Move image annotation and video spoiler actions to thumbnail hover
overlays.
- Preserve the image editor's existing Draw and Spoiler controls.

### Snapshots

#### Image annotation overlay

![Image annotation
overlay](https://raw.githubusercontent.com/block/buzz/87d7e1b1a0774ffef7a1a6cba03baffd63e11bd4/pr-4849--01-image-annotation-overlay.png)

#### Image editor controls

![Image editor
controls](https://raw.githubusercontent.com/block/buzz/87d7e1b1a0774ffef7a1a6cba03baffd63e11bd4/pr-4849--02-image-editor-controls.png)

## Testing

- `pnpm typecheck`
- `pnpm check`
- Focused attachment, drawing, and spoiler smoke tests
- Pre-push desktop tests (4,286 passing)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Honey <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-05 12:17:48 -07:00
committed by GitHub
co-authored by Honey Wes Carl
parent 2034e693a8
commit f2ce575b62
11 changed files with 1139 additions and 139 deletions
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyImetaUpdate, compactImetaSlots } from "./imetaSlots.ts";
// An immediate upload reserves a `null` placeholder and later fills it *by
// index*. Updates written against the compacted list must therefore be mapped
// back onto the slot layout: replacing the array renumbers it under an
// in-flight upload, whose fillSlot would then overwrite an unrelated
// attachment.
const SNAPSHOT = { url: "snapshot.png", sha256: "5555" };
const append = (descriptor) => (current) => [...current, descriptor];
test("compaction hides in-flight placeholders", () => {
const only = { url: "only.png", sha256: "1111" };
assert.deepEqual(compactImetaSlots([null, only, null]), [only]);
assert.deepEqual(compactImetaSlots([]), []);
});
test("a snapshot paste during an in-flight upload does not take its slot", () => {
// Repro: attach a photo (slot 0 reserved, still uploading), then paste an
// agent snapshot. The snapshot must land after the placeholder so the
// photo's fillSlot(0, ...) cannot overwrite it.
const slots = applyImetaUpdate([null], append(SNAPSHOT));
assert.deepEqual(slots, [null, SNAPSHOT]);
// The upload completes and fills its own reserved index.
const photo = { url: "photo.png", sha256: "aaaa" };
const filled = [...slots];
filled[0] = photo;
assert.deepEqual(filled, [photo, SNAPSHOT]);
});
test("an append keeps already-filled attachments at their own indexes", () => {
const first = { url: "first.png", sha256: "1111" };
assert.deepEqual(applyImetaUpdate([first, null], append(SNAPSHOT)), [
first,
null,
SNAPSHOT,
]);
});
test("an updater returning its input leaves the slots untouched", () => {
// handleSnapshotPaste returns `current` unchanged when the snapshot is
// already attached; that must not disturb a reserved placeholder.
const existing = [SNAPSHOT, null];
const slots = applyImetaUpdate(existing, (current) => current);
assert.equal(slots, existing, "same array identity, no re-render churn");
});
test("a removal nulls its slot instead of renumbering", () => {
// Removing an attachment must not shift the index a pending upload holds.
const keep = { url: "keep.png", sha256: "1111" };
const drop = { url: "drop.png", sha256: "2222" };
const slots = applyImetaUpdate([keep, drop, null], (current) =>
current.filter((d) => d.url !== "drop.png"),
);
assert.deepEqual(slots, [keep, null, null]);
});
test("clearing every attachment keeps the reserved placeholders", () => {
const one = { url: "one.png", sha256: "1111" };
assert.deepEqual(
applyImetaUpdate([one, null], () => []),
[null, null],
);
});
test("the updater only ever sees real attachments", () => {
const only = { url: "only.png", sha256: "1111" };
let seen = null;
applyImetaUpdate([null, only, null], (current) => {
seen = current;
return current;
});
assert.deepEqual(seen, [only]);
});
test("descriptors are matched on url and digest together", () => {
// Same url, different bytes: the new descriptor is an append, not a survivor.
const original = { url: "same.png", sha256: "1111" };
const reuploaded = { url: "same.png", sha256: "2222" };
assert.deepEqual(applyImetaUpdate([original, null], append(reuploaded)), [
original,
null,
reuploaded,
]);
});
test("a reorder does not move descriptors out of their slots", () => {
// Reordering cannot be honored while an upload holds an index; keeping the
// existing positions is what protects the pending fillSlot.
const a = { url: "a.png", sha256: "1111" };
const b = { url: "b.png", sha256: "2222" };
const slots = applyImetaUpdate([a, b, null], (current) =>
[...current].reverse(),
);
assert.deepEqual(slots, [a, b, null]);
});
@@ -0,0 +1,65 @@
import type { BlobDescriptor } from "@/shared/api/tauri";
/**
* Slot bookkeeping for composer attachments.
*
* Attachments live in a sparse array: an immediate upload calls `reserveSlots`
* to claim an index up front and fills it by that index when it completes, so
* concurrent uploads publish in the order they were attached. A `null` is a
* placeholder for an upload still in flight.
*
* Consumers of the composer only ever see the compacted list of real
* attachments, so any update expressed against that view has to be mapped back
* onto the slot layout — never applied to it directly.
*/
/**
* Identity of a descriptor. `url` alone can repeat across re-uploads of
* identical bytes, so pair it with the digest.
*/
function descriptorKey(descriptor: BlobDescriptor): string {
return `${descriptor.url}\u0000${descriptor.sha256 ?? ""}`;
}
/** The real attachments, in order, with in-flight placeholders dropped. */
export function compactImetaSlots(
slots: (BlobDescriptor | null)[],
): BlobDescriptor[] {
return slots.filter((d): d is BlobDescriptor => d !== null);
}
/**
* Apply an updater written against the compacted list back onto `slots`.
*
* Replacing the array with the updater's result would renumber it while an
* in-flight upload still holds an index from `reserveSlots`, so that upload's
* `fillSlot` would overwrite an unrelated attachment. Instead:
*
* - survivors stay at the index they already occupy;
* - removals become `null` rather than shifting their neighbours;
* - genuinely new descriptors append after the reserved tail, where no pending
* `fillSlot` can reach them.
*
* An updater that returns its input unchanged (e.g. the snapshot-paste dedupe)
* leaves `slots` exactly as it was, identity included.
*/
export function applyImetaUpdate(
slots: (BlobDescriptor | null)[],
update: (current: BlobDescriptor[]) => BlobDescriptor[],
): (BlobDescriptor | null)[] {
const current = compactImetaSlots(slots);
const next = update(current);
if (next === current) return slots;
const survivingKeys = new Set(next.map(descriptorKey));
const preserved = slots.map((descriptor) =>
descriptor === null || survivingKeys.has(descriptorKey(descriptor))
? descriptor
: null,
);
const presentKeys = new Set(current.map(descriptorKey));
const appended = next.filter(
(descriptor) => !presentKeys.has(descriptorKey(descriptor)),
);
return appended.length > 0 ? [...preserved, ...appended] : preserved;
}
@@ -142,3 +142,280 @@ test("reserveSlots pads if slots array is shorter than expected start index", ()
assert.equal(next[3], null); // reserved
assert.equal(next[4], null); // reserved
});
// ── Draft-boundary epoch guard (pure logic) ───────────────────────────
// Photos/files upload immediately, so an upload can still be in flight when
// the composer swaps drafts (channel switch, post-send clear, edit restore).
// Every wholesale `setPendingImeta` replacement bumps an epoch; uploads pin
// the epoch at start and discard their descriptor if it no longer matches, so
// one draft's attachment can never land in — or overwrite a slot reserved by —
// another draft. Mirrors `isUploadStale` + `fillSlot`/`onUploaded`.
function fillSlotIfCurrent(slots, index, descriptor, epoch, currentEpoch) {
if (epoch !== currentEpoch) return slots;
const next = [...slots];
next[index] = descriptor;
return next;
}
test("upload completing in the same draft fills its slot", () => {
const a = { url: "a.png", sha256: "aaaa" };
const next = fillSlotIfCurrent([null], 0, a, 0, 0);
assert.deepEqual(next, [a]);
});
test("upload completing after a draft switch is discarded", () => {
// Draft A reserves slot 0 at epoch 0, user switches channels (epoch → 1),
// then the upload resolves. It must not write into draft B's slots.
const a = { url: "a.png", sha256: "aaaa" };
const draftBSlots = [null];
const next = fillSlotIfCurrent(draftBSlots, 0, a, 0, 1);
assert.deepEqual(next, [null]);
assert.equal(next, draftBSlots);
});
test("stale upload cannot overwrite a slot the new draft already filled", () => {
// Draft B has its own attachment in slot 0; draft A's late upload targets
// the same index and must leave B's descriptor intact.
const stale = { url: "stale.png", sha256: "aaaa" };
const current = { url: "current.png", sha256: "bbbb" };
const next = fillSlotIfCurrent([current], 0, stale, 0, 2);
assert.deepEqual(next, [current]);
});
test("appending to the current draft does not bump the epoch", () => {
// Only wholesale replacement (`setPendingImeta(array)`) is a draft boundary.
// The updater form appends within the current draft, so in-flight uploads
// for that same draft must still be considered current.
let epoch = 0;
const bumpIfReplacement = (action) => {
if (typeof action !== "function") epoch += 1;
};
bumpIfReplacement((current) => [...current, { url: "pasted.png" }]);
assert.equal(epoch, 0);
bumpIfReplacement([]);
assert.equal(epoch, 1);
});
// ── Cancel guard for stale previews (pure logic) ───────────────────────
// The epoch bump makes completions discard their descriptors, but the old
// preview row (and its cancel button) can still be on screen. Cancelling it
// must not null a slot in the draft now on screen, because the preview carries
// the *previous* draft's slotIndex. Mirrors `cancelUpload`'s `isStalePreview`.
function cancelSlotIndex(preview, currentEpoch) {
if (preview?.slotIndex === undefined) return undefined;
const isStale =
preview.uploadEpoch !== undefined && preview.uploadEpoch !== currentEpoch;
return isStale ? undefined : preview.slotIndex;
}
test("cancelling a preview from the current draft nulls its slot", () => {
assert.equal(cancelSlotIndex({ slotIndex: 1, uploadEpoch: 3 }, 3), 1);
});
test("cancelling a stale preview does not null the new draft's slot", () => {
// Draft A reserved slot 0 at epoch 0; draft B now owns slot 0. Cancelling
// A's leftover preview must leave B's attachment intact.
assert.equal(cancelSlotIndex({ slotIndex: 0, uploadEpoch: 0 }, 1), undefined);
});
test("cancelling a preview with no slot is a no-op for slots", () => {
// `handlePaperclip`'s native-picker preview has no reserved slot.
assert.equal(cancelSlotIndex({ uploadEpoch: 0 }, 0), undefined);
});
// ── Retiring in-flight uploads at a draft boundary (pure logic) ────────
// Bumping the epoch alone discards descriptors but leaves the previous draft's
// preview rows on screen and its uploads counted, which keeps `isUploading`
// true and holds the *new* draft's send gate closed. A wholesale replacement
// must therefore retire those uploads outright. Mirrors `beginNewDraftEpoch`.
function beginNewDraftEpoch(state) {
const next = {
epoch: state.epoch + 1,
active: new Set(state.active),
canceled: new Set(state.canceled),
previews: state.previews,
uploadingCount: state.uploadingCount,
};
if (next.active.size === 0) return next;
// Mirrors the real callback: snapshot, clear the live set, then schedule the
// updaters. `applyUpdates` below runs them afterwards, the way React does.
const retiredIds = new Set(next.active);
const retiredCount = retiredIds.size;
next.active.clear();
for (const id of retiredIds) next.canceled.add(id);
next.pendingUpdates = [
(s) => {
s.previews = s.previews.filter((preview) => !retiredIds.has(preview.id));
},
(s) => {
s.uploadingCount = Math.max(0, s.uploadingCount - retiredCount);
},
];
return next;
}
/** Run the scheduled state updaters, as React does after the event handler. */
function applyUpdates(state) {
for (const update of state.pendingUpdates ?? []) update(state);
state.pendingUpdates = [];
return state;
}
test("a draft boundary retires in-flight uploads so the new draft can send", () => {
// Draft A has one upload in flight; switching to draft B must leave B with
// no previews and nothing counted as uploading.
const after = applyUpdates(
beginNewDraftEpoch({
epoch: 0,
active: new Set([1]),
canceled: new Set(),
previews: [{ id: 1, slotIndex: 0, uploadEpoch: 0 }],
uploadingCount: 1,
}),
);
assert.equal(after.epoch, 1);
assert.deepEqual(after.previews, []);
assert.equal(after.uploadingCount, 0);
assert.equal(after.active.size, 0);
// Canceled so the late completion/error paths stay silent in the new draft.
assert.ok(after.canceled.has(1));
});
test("retiring several concurrent uploads clears the count exactly once each", () => {
const after = applyUpdates(
beginNewDraftEpoch({
epoch: 4,
active: new Set([7, 8, 9]),
canceled: new Set(),
previews: [{ id: 7 }, { id: 8 }, { id: 9 }],
uploadingCount: 3,
}),
);
assert.equal(after.uploadingCount, 0);
assert.deepEqual(after.previews, []);
});
test("a draft boundary with no uploads in flight still advances the epoch", () => {
const after = applyUpdates(
beginNewDraftEpoch({
epoch: 2,
active: new Set(),
canceled: new Set(),
previews: [],
uploadingCount: 0,
}),
);
assert.equal(after.epoch, 3);
assert.equal(after.uploadingCount, 0);
});
test("the retired count never drives uploadingCount negative", () => {
// Defensive: a preview already settled by finishUpload must not be
// double-decremented into a negative count that would wedge the gate.
const after = applyUpdates(
beginNewDraftEpoch({
epoch: 0,
active: new Set([1, 2]),
canceled: new Set(),
previews: [{ id: 1 }, { id: 2 }],
uploadingCount: 1,
}),
);
assert.equal(after.uploadingCount, 0);
});
test("retirement holds even though the live active set is cleared first", () => {
// Regression: the updaters must not read the live `active` set, which is
// emptied before React runs them. Closing over it filtered against an empty
// set and subtracted 0, leaving the stale preview and a stuck send gate.
const state = beginNewDraftEpoch({
epoch: 0,
active: new Set([1]),
canceled: new Set(),
previews: [{ id: 1 }],
uploadingCount: 1,
});
assert.equal(state.active.size, 0, "live set is cleared before updates run");
// Updates land only now — after the clear — exactly as React schedules them.
applyUpdates(state);
assert.deepEqual(state.previews, []);
assert.equal(state.uploadingCount, 0);
});
test("replayed updaters stay idempotent", () => {
// React may invoke an updater more than once (StrictMode double-render).
const state = beginNewDraftEpoch({
epoch: 0,
active: new Set([1]),
canceled: new Set(),
previews: [{ id: 1 }],
uploadingCount: 1,
});
const updates = state.pendingUpdates;
for (const update of updates) update(state);
for (const update of updates) update(state);
assert.deepEqual(state.previews, []);
assert.equal(state.uploadingCount, 0);
});
// ── Edit mode while an upload is in flight ────────────────────────────
// Immediate photo/file uploads reserve null slots that are absent from the
// compacted `pendingImeta` snapshot. MessageComposer therefore rejects edit
// entry while an upload is active, leaving the current draft and upload epoch
// untouched. Once the upload settles, normal edit snapshot/restore proceeds.
function attemptEditModeRoundTrip({ isUploading, draft = [] }) {
const uploaded = { sha256: "ffff", url: "in-flight.png" };
const editTargetImeta = [{ sha256: "eeee", url: "edit-target.png" }];
let slots = [...draft];
let epoch = 0;
if (isUploading) {
slots = [...slots, null];
return {
editEntered: false,
epoch,
restoredDraft: slots,
};
}
const snapshot = [...slots];
epoch += 1;
slots = editTargetImeta;
epoch += 1;
slots = snapshot;
return {
editEntered: true,
epoch,
restoredDraft: slots,
uploaded,
};
}
test("edit entry is rejected without replacing a draft that is uploading", () => {
const existing = { sha256: "aaaa", url: "already-there.png" };
const result = attemptEditModeRoundTrip({
draft: [existing],
isUploading: true,
});
assert.equal(result.editEntered, false);
assert.equal(result.epoch, 0, "the current draft epoch must not be retired");
assert.deepEqual(result.restoredDraft, [existing, null]);
});
test("edit entry proceeds normally after uploads settle", () => {
const existing = { sha256: "aaaa", url: "already-there.png" };
const result = attemptEditModeRoundTrip({
draft: [existing],
isUploading: false,
});
assert.equal(result.editEntered, true);
assert.equal(result.epoch, 2);
assert.deepEqual(result.restoredDraft, [existing]);
});
@@ -5,7 +5,10 @@ import {
pickAndUploadMedia,
uploadMediaBytes,
} from "@/shared/api/tauri";
import { uploadMediaFile } from "@/shared/api/tauriMedia";
import type { QueuedMediaAttachment } from "./backgroundMediaUploadStore";
import { applyImetaUpdate, compactImetaSlots } from "./imetaSlots";
import { isVideoFile, videoMimeForFile } from "./videoFileType";
/**
* First 4 hex chars of the sha256 — used as a short display name.
@@ -33,6 +36,12 @@ export type UploadingAttachmentPreview = {
slotIndex?: number;
spoilered?: boolean;
type?: string;
/**
* Upload epoch this preview was created in. Cancel handling compares it
* against the current epoch so a preview left over from a replaced draft
* cannot null a slot belonging to the draft now on screen.
*/
uploadEpoch?: number;
};
/** Correlation id for the Rust `media-upload-progress` events. */
@@ -85,9 +94,16 @@ type CapturedVideoPoster = {
async function captureVideoPosterFrame(
file: File,
): Promise<CapturedVideoPoster | null> {
if (!file.type.startsWith("video/")) return null;
const videoMime = videoMimeForFile(file);
if (!videoMime) return null;
const objectUrl = URL.createObjectURL(file);
// A blob URL inherits the File's own MIME type, so a video whose type is
// empty or `application/octet-stream` would be rejected by the <video>
// element and yield no poster. Re-type the bytes with the MIME we derived
// from the extension; `slice` wraps the same bytes without copying them.
const objectUrl = URL.createObjectURL(
file.type === videoMime ? file : file.slice(0, file.size, videoMime),
);
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
@@ -136,7 +152,7 @@ async function captureVideoPosterFrame(
}
type UseMediaUploadOptions = {
/** Keep newly selected files local until the message is submitted. */
/** Keep newly selected videos local until the message is submitted. */
deferUploadsUntilSend?: boolean;
};
@@ -151,6 +167,10 @@ export function useMediaUpload({
const queueUntilSend =
deferUploadsUntilSend &&
(!e2eConfig || e2eConfig.mock?.deferredComposerUploads === true);
const shouldQueueFile = React.useCallback(
(file: File) => queueUntilSend && isVideoFile(file),
[queueUntilSend],
);
const [uploadState, setUploadState] = React.useState<UploadState>({
status: "idle",
});
@@ -204,6 +224,13 @@ export function useMediaUpload({
}, []);
const activeUploadingPreviewIdsRef = React.useRef(new Set<number>());
const canceledUploadingPreviewIdsRef = React.useRef(new Set<number>());
/**
* Incremented whenever the composer's attachment set is replaced wholesale
* (draft/channel switch, post-send clear, edit restore). Uploads capture the
* epoch at start and discard their result if it no longer matches, so an
* upload started against one draft can never land in another.
*/
const uploadEpochRef = React.useRef(0);
// ── Drag-over visual indicator state ───────────────────────────────
const [isDragOver, setIsDragOver] = React.useState(false);
@@ -220,7 +247,7 @@ export function useMediaUpload({
);
const pendingImeta = React.useMemo(
() => imetaSlots.filter((d): d is BlobDescriptor => d !== null),
() => compactImetaSlots(imetaSlots),
[imetaSlots],
);
@@ -295,7 +322,7 @@ export function useMediaUpload({
const previewUrl = file.type.startsWith("image/")
? URL.createObjectURL(file)
: undefined;
if (file.type.startsWith("video/")) {
if (isVideoFile(file)) {
void captureVideoPosterFrame(file).then((poster) => {
if (poster) updateQueuedVideoPoster(id, poster.posterUrl);
});
@@ -383,10 +410,18 @@ export function useMediaUpload({
setUploadingPreviews((prev) => [
...prev,
{ id, filename: file?.name, slotIndex, type: file?.type },
{
id,
filename: file?.name,
slotIndex,
// Normalize an extension-detected video to its MIME type so the
// preview renders (and offers a spoiler toggle) like any other video.
type: file ? (videoMimeForFile(file) ?? file.type) : undefined,
uploadEpoch: uploadEpochRef.current,
},
]);
if (file?.type.startsWith("video/")) {
if (file && isVideoFile(file)) {
void captureVideoPosterFrame(file).then((poster) => {
if (!poster || isUploadCanceled(id)) return;
setUploadingPreviews((prev) =>
@@ -413,13 +448,56 @@ export function useMediaUpload({
[removeUploadingPreview],
);
/**
* Mark a draft boundary: the composer's attachment set was replaced
* wholesale (channel/draft switch, post-send clear, edit-target restore), so
* every upload in flight belongs to a draft that is no longer on screen.
*
* Bumping the epoch makes those uploads discard their descriptors, and
* retiring them drops their preview rows and their share of
* `uploadingCount` immediately — otherwise the previous draft's uploads
* would appear under the new draft and hold its send gate closed until they
* finished. Marking them canceled also suppresses their completion and error
* paths, so a failure the user has switched away from cannot raise a banner.
*
* Bump and retire are deliberately one operation: an epoch bump without the
* retire is what left stale previews (and a stuck send gate) behind.
*/
const beginNewDraftEpoch = React.useCallback(() => {
uploadEpochRef.current += 1;
const activeIds = activeUploadingPreviewIdsRef.current;
if (activeIds.size === 0) return;
// Snapshot before clearing the live set: the state updaters below run
// lazily (and may be replayed), so they must not close over a set that
// this callback empties before React invokes them — that would filter
// against an empty set and subtract 0, leaving the stale preview and a
// stuck send gate in the new draft.
const retiredIds = new Set(activeIds);
const retiredCount = retiredIds.size;
activeIds.clear();
for (const id of retiredIds) {
canceledUploadingPreviewIdsRef.current.add(id);
}
setUploadingPreviews((prev) =>
prev.filter((preview) => !retiredIds.has(preview.id)),
);
setUploadingCount((count) => Math.max(0, count - retiredCount));
}, []);
const cancelUpload = React.useCallback(
(previewId: number) => {
canceledUploadingPreviewIdsRef.current.add(previewId);
const slotIndex = uploadingPreviewsRef.current.find(
(preview) => preview.id === previewId,
)?.slotIndex;
if (slotIndex !== undefined) {
const preview = uploadingPreviewsRef.current.find(
(candidate) => candidate.id === previewId,
);
const slotIndex = preview?.slotIndex;
// Only null the slot when the preview still belongs to the draft on
// screen. A preview left over from a replaced draft carries that draft's
// slotIndex, which may now address a different attachment.
const isStalePreview =
preview?.uploadEpoch !== undefined &&
preview.uploadEpoch !== uploadEpochRef.current;
if (slotIndex !== undefined && !isStalePreview) {
setImetaSlots((prev) => {
if (slotIndex >= prev.length) return prev;
const next = [...prev];
@@ -447,10 +525,29 @@ export function useMediaUpload({
return startIndex;
}, []);
/**
* True when the composer's attachment set was replaced since `epoch` was
* captured, meaning an upload that started then belongs to a draft that is
* no longer on screen and must not write its descriptor.
*/
const isUploadStale = React.useCallback(
(epoch: number) => epoch !== uploadEpochRef.current,
[],
);
/** Fill a previously-reserved slot by index. */
const fillSlot = React.useCallback(
(index: number, descriptor: BlobDescriptor, previewId?: number) => {
(
index: number,
descriptor: BlobDescriptor,
previewId?: number,
epoch = uploadEpochRef.current,
) => {
if (isUploadCanceled(previewId)) return;
if (isUploadStale(epoch)) {
finishUpload(previewId);
return;
}
setImetaSlots((prev) => {
const next = [...prev];
next[index] = descriptor;
@@ -458,18 +555,26 @@ export function useMediaUpload({
});
finishUpload(previewId);
},
[finishUpload, isUploadCanceled],
[finishUpload, isUploadCanceled, isUploadStale],
);
/** Append a single descriptor (no pre-reserved slot). */
const onUploaded = React.useCallback(
(descriptor: BlobDescriptor, previewId?: number) => {
(
descriptor: BlobDescriptor,
previewId?: number,
epoch = uploadEpochRef.current,
) => {
if (isUploadCanceled(previewId)) return;
if (isUploadStale(epoch)) {
finishUpload(previewId);
return;
}
nextSlotRef.current += 1;
setImetaSlots((prev) => [...prev, descriptor]);
finishUpload(previewId);
},
[finishUpload, isUploadCanceled],
[finishUpload, isUploadCanceled, isUploadStale],
);
const onUploadError = React.useCallback(
@@ -481,6 +586,37 @@ export function useMediaUpload({
[finishUpload, isUploadCanceled],
);
const uploadFiles = React.useCallback(
(files: File[]) => {
if (files.length === 0) return;
setUploadingCount((count) => count + files.length);
const baseIndex = reserveSlots(files.length);
// Pin the epoch at start: if the composer swaps drafts mid-flight, these
// completions are discarded rather than written into the new draft.
const epoch = uploadEpochRef.current;
for (let index = 0; index < files.length; index++) {
const file = files[index];
const slotIndex = baseIndex + index;
const previewId = reserveUploadingPreview(file, slotIndex);
// Fire-and-forget each upload concurrently — slot preserves order.
void (async () => {
try {
const descriptor = await uploadMediaFile(
file,
uploadProgressId(previewId),
);
fillSlot(slotIndex, descriptor, previewId, epoch);
} catch (err) {
onUploadError(err, previewId);
}
})();
}
},
[fillSlot, onUploadError, reserveSlots, reserveUploadingPreview],
);
const handlePaperclip = React.useCallback(async () => {
if (queueUntilSend) {
const input = document.createElement("input");
@@ -488,7 +624,11 @@ export function useMediaUpload({
input.multiple = true;
input.addEventListener(
"change",
() => queueFiles(Array.from(input.files ?? [])),
() => {
const files = Array.from(input.files ?? []);
queueFiles(files.filter(shouldQueueFile));
uploadFiles(files.filter((file) => !shouldQueueFile(file)));
},
{ once: true },
);
input.click();
@@ -501,10 +641,12 @@ export function useMediaUpload({
// descriptor when we get them back.
const previewId = reserveUploadingPreview();
setUploadingCount((c) => c + 1);
const epoch = uploadEpochRef.current;
try {
const descriptors = await pickAndUploadMedia(uploadProgressId(previewId));
if (isUploadCanceled(previewId)) return;
finishUpload(previewId);
if (isUploadStale(epoch)) return;
for (const descriptor of descriptors) {
nextSlotRef.current += 1;
setImetaSlots((prev) => [...prev, descriptor]);
@@ -517,9 +659,12 @@ export function useMediaUpload({
queueUntilSend,
finishUpload,
isUploadCanceled,
isUploadStale,
onUploadError,
queueFiles,
reserveUploadingPreview,
shouldQueueFile,
uploadFiles,
]);
const handleDrop = React.useCallback(
@@ -534,44 +679,10 @@ export function useMediaUpload({
// (active-content + executables) and size caps; everything else uploads.
const validFiles = files;
if (queueUntilSend) {
queueFiles(validFiles);
return;
}
setUploadingCount((c) => c + validFiles.length);
const baseIndex = reserveSlots(validFiles.length);
for (let i = 0; i < validFiles.length; i++) {
const file = validFiles[i];
const slotIndex = baseIndex + i;
const previewId = reserveUploadingPreview(file, slotIndex);
// Fire-and-forget each upload concurrently — slot preserves order
(async () => {
try {
const buffer = await file.arrayBuffer();
if (isUploadCanceled(previewId)) return;
const descriptor = await uploadMediaBytes(
[...new Uint8Array(buffer)],
file.name,
uploadProgressId(previewId),
);
fillSlot(slotIndex, descriptor, previewId);
} catch (err) {
onUploadError(err, previewId);
}
})();
}
queueFiles(validFiles.filter(shouldQueueFile));
uploadFiles(validFiles.filter((file) => !shouldQueueFile(file)));
},
[
reserveSlots,
queueUntilSend,
fillSlot,
isUploadCanceled,
onUploadError,
queueFiles,
reserveUploadingPreview,
],
[queueFiles, shouldQueueFile, uploadFiles],
);
const handleDragEnter = React.useCallback(
@@ -639,74 +750,38 @@ export function useMediaUpload({
event.preventDefault();
if (queueUntilSend) {
queueFiles(mediaFiles);
return;
}
setUploadingCount((c) => c + mediaFiles.length);
const baseIndex = reserveSlots(mediaFiles.length);
for (let i = 0; i < mediaFiles.length; i++) {
const file = mediaFiles[i];
const slotIndex = baseIndex + i;
const previewId = reserveUploadingPreview(file, slotIndex);
(async () => {
try {
const buffer = await file.arrayBuffer();
if (isUploadCanceled(previewId)) return;
const descriptor = await uploadMediaBytes(
[...new Uint8Array(buffer)],
file.name,
uploadProgressId(previewId),
);
fillSlot(slotIndex, descriptor, previewId);
} catch (err) {
onUploadError(err, previewId);
}
})();
}
queueFiles(mediaFiles.filter(shouldQueueFile));
uploadFiles(mediaFiles.filter((file) => !shouldQueueFile(file)));
},
[
reserveSlots,
queueUntilSend,
fillSlot,
isUploadCanceled,
onUploadError,
queueFiles,
reserveUploadingPreview,
],
[queueFiles, shouldQueueFile, uploadFiles],
);
/** Upload a File directly — used by Tiptap's editorProps.handlePaste. */
const uploadFile = React.useCallback(
async (file: File) => {
if (queueUntilSend) {
if (shouldQueueFile(file)) {
queueFiles([file]);
return;
}
const previewId = reserveUploadingPreview(file);
setUploadingCount((c) => c + 1);
const epoch = uploadEpochRef.current;
try {
const buffer = await file.arrayBuffer();
if (isUploadCanceled(previewId)) return;
const descriptor = await uploadMediaBytes(
[...new Uint8Array(buffer)],
file.name,
const descriptor = await uploadMediaFile(
file,
uploadProgressId(previewId),
);
onUploaded(descriptor, previewId);
onUploaded(descriptor, previewId, epoch);
} catch (err) {
onUploadError(err, previewId);
}
},
[
queueUntilSend,
isUploadCanceled,
onUploaded,
onUploadError,
queueFiles,
reserveUploadingPreview,
shouldQueueFile,
],
);
@@ -791,16 +866,38 @@ export function useMediaUpload({
/** Public setter — replaces all slots (used by MessageComposer to clear/restore). */
const setPendingImeta = React.useCallback(
(action: React.SetStateAction<BlobDescriptor[]>) => {
// A wholesale replacement means the composer's contents were swapped out
// from under any in-flight upload: draft/channel switch, post-send clear,
// or edit-target restore. Bump the epoch so those uploads discard their
// results instead of landing in (or overwriting a slot reserved by) the
// draft that is now on screen. The updater form is an append against the
// *current* draft (e.g. agent-snapshot paste), so it must NOT bump.
if (typeof action !== "function") {
beginNewDraftEpoch();
setImetaSlots(() => {
nextSlotRef.current = action.length;
return action;
});
return;
}
setImetaSlots((prev) => {
const current = prev.filter((d): d is BlobDescriptor => d !== null);
const next = typeof action === "function" ? action(current) : action;
nextSlotRef.current = next.length;
return next;
const result = applyImetaUpdate(prev, action);
nextSlotRef.current = result.length;
return result;
});
},
[],
[beginNewDraftEpoch],
);
/**
* True while any attachment upload is in flight.
*
* Send paths must gate on this: with `deferUploadsUntilSend`, only videos
* are queued locally, so an in-flight photo/file is in neither
* `pendingImeta` nor `queuedAttachments`. Sending mid-flight would publish
* the message without that attachment and land the descriptor in an
* already-cleared composer.
*/
const isUploading = uploadingCount > 0;
const queuedPreviews = React.useMemo<UploadingAttachmentPreview[]>(
() =>
@@ -809,7 +906,7 @@ export function useMediaUpload({
id: attachment.id,
posterUrl: attachment.previewUrl,
spoilered: attachment.spoilered,
type: attachment.file.type,
type: videoMimeForFile(attachment.file) ?? attachment.file.type,
})),
[queuedAttachments],
);
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isVideoFile, videoMimeForFile } from "./videoFileType.ts";
// ── MIME type is authoritative when present ───────────────────────────
test("a video MIME type is detected as a video", () => {
assert.equal(isVideoFile({ name: "clip.mp4", type: "video/mp4" }), true);
assert.equal(
videoMimeForFile({ name: "clip.mp4", type: "video/mp4" }),
"video/mp4",
);
});
test("an uppercase video MIME type is normalized", () => {
assert.equal(
videoMimeForFile({ name: "clip.MOV", type: "VIDEO/QUICKTIME" }),
"video/quicktime",
);
});
test("a non-video MIME type wins over a video extension", () => {
// A GIF misnamed `.mp4` is still an image — never queue it as a video.
assert.equal(isVideoFile({ name: "loop.mp4", type: "image/gif" }), false);
assert.equal(
videoMimeForFile({ name: "loop.mp4", type: "image/gif" }),
undefined,
);
});
test("an image is not a video", () => {
assert.equal(isVideoFile({ name: "photo.png", type: "image/png" }), false);
});
// ── Extension fallback when the MIME type is missing or opaque ────────
test("an empty MIME type falls back to the filename extension", () => {
assert.equal(isVideoFile({ name: "clip.mp4", type: "" }), true);
assert.equal(videoMimeForFile({ name: "clip.mp4", type: "" }), "video/mp4");
});
test("an absent MIME type falls back to the filename extension", () => {
assert.equal(videoMimeForFile({ name: "clip.webm" }), "video/webm");
});
test("an octet-stream MIME type falls back to the filename extension", () => {
assert.equal(
videoMimeForFile({ name: "clip.mov", type: "application/octet-stream" }),
"video/quicktime",
);
assert.equal(
videoMimeForFile({ name: "clip.mkv", type: "binary/octet-stream" }),
"video/x-matroska",
);
});
test("every supported video extension is recognized without a MIME type", () => {
for (const [extension, mime] of [
["avi", "video/x-msvideo"],
["m4v", "video/mp4"],
["mkv", "video/x-matroska"],
["mov", "video/quicktime"],
["mp4", "video/mp4"],
["webm", "video/webm"],
]) {
assert.equal(
videoMimeForFile({ name: `clip.${extension}`, type: "" }),
mime,
);
}
});
test("extension matching is case-insensitive", () => {
assert.equal(videoMimeForFile({ name: "CLIP.MP4", type: "" }), "video/mp4");
});
test("a non-video extension with no MIME type is not a video", () => {
assert.equal(isVideoFile({ name: "notes.txt", type: "" }), false);
assert.equal(
isVideoFile({ name: "archive.zip", type: "application/octet-stream" }),
false,
);
});
test("an extensionless file with no MIME type is not a video", () => {
assert.equal(isVideoFile({ name: "clip", type: "" }), false);
assert.equal(isVideoFile({ type: "" }), false);
});
test("a dotfile is not treated as having an extension", () => {
// `.mp4` as an entire basename is a hidden file, not an mp4 named "".
assert.equal(isVideoFile({ name: ".mp4", type: "" }), false);
});
test("a trailing dot is not treated as an extension", () => {
assert.equal(isVideoFile({ name: "clip.", type: "" }), false);
});
test("only the last extension is consulted", () => {
assert.equal(videoMimeForFile({ name: "clip.mp4.txt", type: "" }), undefined);
assert.equal(
videoMimeForFile({ name: "notes.txt.mp4", type: "" }),
"video/mp4",
);
});
@@ -0,0 +1,71 @@
/**
* Video detection for composer attachments, kept DOM-free so the branch logic
* is unit-testable without a webview.
*
* The deferred-upload split routes videos to the queued/background path and
* everything else to an immediate foreground upload, so this predicate decides
* which path a file takes. A MIME-only check is not enough: file-picker,
* drag/drop, and clipboard `File` objects can arrive with an empty or generic
* `application/octet-stream` type for perfectly valid videos (no OS MIME
* database entry, network shares, some Linux desktops), and those videos would
* otherwise upload in the foreground and block Send until the transcode
* finishes.
*
* A concrete MIME type stays authoritative — `image/gif` named `clip.mp4` is
* an image. The filename extension is consulted only when the MIME type tells
* us nothing.
*/
/** Extension → MIME, used only when a file carries no usable MIME type. */
const VIDEO_MIME_BY_EXTENSION = new Map([
["avi", "video/x-msvideo"],
["m4v", "video/mp4"],
["mkv", "video/x-matroska"],
["mov", "video/quicktime"],
["mp4", "video/mp4"],
["webm", "video/webm"],
]);
/** MIME types that carry no format information — treat as "unknown". */
const OPAQUE_MIME_TYPES = new Set([
"application/octet-stream",
"binary/octet-stream",
]);
function isUsableMimeType(type: string | undefined): boolean {
if (!type) return false;
return !OPAQUE_MIME_TYPES.has(type.toLowerCase());
}
/** The lowercased extension of a filename, or undefined when it has none. */
function filenameExtension(filename: string | undefined): string | undefined {
if (!filename) return undefined;
const lastDot = filename.lastIndexOf(".");
if (lastDot <= 0 || lastDot === filename.length - 1) return undefined;
return filename.slice(lastDot + 1).toLowerCase();
}
type VideoFileCandidate = {
name?: string;
type?: string;
};
/**
* The video MIME type to use for `file`, or undefined when it is not a video.
*
* Returns the file's own MIME type when it is a usable `video/*` type, and
* otherwise falls back to an extension-derived type for files whose MIME is
* missing or opaque.
*/
export function videoMimeForFile(file: VideoFileCandidate): string | undefined {
const type = file.type?.toLowerCase();
if (isUsableMimeType(type)) {
return type?.startsWith("video/") ? type : undefined;
}
return VIDEO_MIME_BY_EXTENSION.get(filenameExtension(file.name) ?? "");
}
/** Whether `file` should be treated as a video by the composer. */
export function isVideoFile(file: VideoFileCandidate): boolean {
return videoMimeForFile(file) !== undefined;
}
@@ -5,6 +5,7 @@ import {
Bot,
FileText,
HatGlasses,
LineSquiggle,
Pencil,
Play,
UploadCloud,
@@ -36,6 +37,30 @@ import { Toggle } from "@/shared/ui/toggle";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { ComposerImageEditor } from "./ComposerImageEditor";
/**
* Reveal-on-interaction for the composer's media action buttons.
*
* These stay invisible until the thumbnail is hovered, but `display: none`
* cannot hold focus, which would leave keyboard-only users unable to reach
* them at all. Hiding with `opacity-0` instead keeps them in the tab order,
* and `pointer-events-none` until hover/focus means a mouse behaves exactly as
* it did before — an invisible overlay never swallows a click. Keyboard focus
* and Enter are unaffected by `pointer-events`.
*/
const COMPOSER_MEDIA_REVEAL_CLASS =
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100";
/** Corner "remove attachment" badge on a composer thumbnail. */
const COMPOSER_MEDIA_REMOVE_CLASS = cn(
"absolute -right-1 -top-1 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-foreground text-background",
COMPOSER_MEDIA_REVEAL_CLASS,
);
const COMPOSER_MEDIA_HOVER_ACTION_CLASS = cn(
"absolute inset-0 z-[1] flex items-center justify-center rounded-2xl bg-black/35 text-white backdrop-blur-[1px] hover:bg-black/45",
COMPOSER_MEDIA_REVEAL_CLASS,
);
/** Dashed-border overlay shown when a file is dragged over the composer form. */
export function DropZoneOverlay({ className }: { className?: string }) {
return (
@@ -63,7 +88,7 @@ type ComposerAttachmentsProps = {
onCancelUpload?: (previewId: number) => void;
/** Remove a local attachment that has not started uploading yet. */
onRemoveQueued?: (previewId: number) => void;
/** Toggle spoiler state for a local attachment before it receives a URL. */
/** Toggle spoiler state for a queued video before it receives a URL. */
onToggleQueuedSpoiler?: (previewId: number) => void;
/** Local previews that are queued for upload when the message is sent. */
queuedPreviews?: UploadingAttachmentPreview[];
@@ -293,9 +318,13 @@ const MediaAttachmentItem = React.forwardRef<
const handleRevert = React.useCallback(() => {
onRevert?.(attachment.url);
}, [attachment.url, onRevert]);
const handleOpenLightbox = React.useCallback(() => {
setOpen(true);
}, []);
return (
<motion.div
data-testid="composer-media-attachment"
ref={ref}
layout
initial={false}
@@ -441,12 +470,6 @@ const MediaAttachmentItem = React.forwardRef<
className={cn(
LIGHTBOX_BUTTON_CLASS,
"h-auto min-w-0",
// Active state driven by component state, not
// Radix's data-state: the TooltipTrigger clobbers
// the Toggle's data-state attribute. Swap the
// circular pill for the shared button radius with
// a visible ring so a spoilered attachment reads
// as "selected" on the dark lightbox backdrop.
isSpoilered &&
"rounded-lg bg-white/25 text-white ring-2 ring-white",
)}
@@ -492,15 +515,51 @@ const MediaAttachmentItem = React.forwardRef<
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<button
aria-label="Remove attachment"
type="button"
onClick={() => onRemove(attachment.url)}
className="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-foreground text-background group-hover:flex"
className={COMPOSER_MEDIA_REMOVE_CLASS}
>
<X className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent>Remove attachment</TooltipContent>
</Tooltip>
{canEdit ? (
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<button
className={COMPOSER_MEDIA_HOVER_ACTION_CLASS}
data-testid="composer-attachment-annotate"
onClick={handleOpenLightbox}
type="button"
>
<LineSquiggle className="h-5 w-5" />
<span className="sr-only">Draw on image</span>
</button>
</TooltipTrigger>
<TooltipContent>Draw on image</TooltipContent>
</Tooltip>
) : null}
{isVideo && onToggleSpoiler ? (
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<button
aria-label={isSpoilered ? "Remove spoiler" : "Mark as spoiler"}
aria-pressed={isSpoilered}
className={COMPOSER_MEDIA_HOVER_ACTION_CLASS}
data-testid="composer-video-spoiler"
onClick={() => onToggleSpoiler(attachment.url)}
type="button"
>
<HatGlasses className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent>
{isSpoilered ? "Remove spoiler" : "Mark as spoiler"}
</TooltipContent>
</Tooltip>
) : null}
</div>
</motion.div>
);
@@ -589,9 +648,10 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<button
aria-label="Remove attachment"
type="button"
onClick={() => onRemove(attachment.url)}
className="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-foreground text-background group-hover:flex"
className={COMPOSER_MEDIA_REMOVE_CLASS}
>
<X className="h-2.5 w-2.5" />
</button>
@@ -620,13 +680,13 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
);
})}
{queuedPreviews.map((preview) => {
const isMedia =
preview.type?.startsWith("image/") ||
preview.type?.startsWith("video/");
const isVideo = preview.type?.startsWith("video/") ?? false;
const isMedia = preview.type?.startsWith("image/") || isVideo;
return (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="group relative"
data-testid="composer-queued-media-attachment"
exit={{ opacity: 0, scale: 0.8 }}
initial={{ opacity: 0, scale: 0.8 }}
key={`queued-attachment-${preview.id}`}
@@ -670,7 +730,7 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
<TooltipTrigger asChild>
<button
aria-label="Remove attachment"
className="absolute -right-1 -top-1 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-foreground text-background"
className={COMPOSER_MEDIA_REMOVE_CLASS}
onClick={() => onRemoveQueued(preview.id)}
type="button"
>
@@ -680,24 +740,23 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
<TooltipContent>Remove attachment</TooltipContent>
</Tooltip>
) : null}
{isMedia && onToggleQueuedSpoiler ? (
{isVideo && onToggleQueuedSpoiler ? (
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Toggle
<button
aria-label={
preview.spoilered
? "Remove spoiler"
: "Mark as spoiler"
}
className="absolute -bottom-1 -left-1 z-10 h-4 w-4 rounded-full bg-foreground text-background hover:bg-foreground"
onPressedChange={() =>
onToggleQueuedSpoiler(preview.id)
}
pressed={preview.spoilered}
aria-pressed={preview.spoilered}
className={COMPOSER_MEDIA_HOVER_ACTION_CLASS}
data-testid="composer-queued-video-spoiler"
onClick={() => onToggleQueuedSpoiler(preview.id)}
type="button"
>
<HatGlasses className="h-2.5 w-2.5" />
</Toggle>
<HatGlasses className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent>
{preview.spoilered ? "Remove spoiler" : "Mark as spoiler"}
@@ -329,8 +329,8 @@ function MessageComposerImpl({
}, [isDeferredEditPending, onDeferredEditPendingChange]);
// biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger
React.useEffect(() => {
if (editTarget && media.isUploading) return onCancelEdit?.();
if (editTarget) {
// Preserve the user's in-flight draft while editing another message.
preEditSnapshotRef.current = {
content: syncComposerContentFromEditor(),
pendingImeta: [...media.pendingImetaRef.current],
@@ -555,6 +555,7 @@ function MessageComposerImpl({
(!trimmed && !hasMedia) ||
disabledRef.current ||
isSendingRef.current ||
isUploadingRef.current ||
mentionSendFlow.isPreparingMentionSend
) {
return;
@@ -804,14 +805,13 @@ function MessageComposerImpl({
const sendDisabled = React.useMemo(
() =>
composerDisabled ||
(editTarget !== null && media.isUploading) ||
media.isUploading ||
mentionSendFlow.isPreparingMentionSend ||
(isContentEmpty &&
media.pendingImeta.length === 0 &&
media.queuedAttachments.length === 0),
[
composerDisabled,
editTarget,
media.isUploading,
mentionSendFlow.isPreparingMentionSend,
isContentEmpty,
+35 -4
View File
@@ -1,11 +1,13 @@
import { expect, type Page, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const ORIGINAL_SHA = "a".repeat(64);
const EDITED_SHA = "b".repeat(64);
const ORIGINAL_URL = "https://example.com/e2e/draw-original.svg";
const EDITED_URL = "https://example.com/e2e/draw-edited.svg";
const PR_SNAPSHOT_DIR = "test-results/video-upload-photo-scope";
const ORIGINAL_DESCRIPTOR = {
url: ORIGINAL_URL,
@@ -67,6 +69,31 @@ test.beforeEach(async ({ page }) => {
});
});
test("image annotation overlay and editor controls", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByRole("button", { name: "Attach image" }).click();
const composer = page.getByTestId("message-composer");
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
await composer.getByTestId("composer-media-attachment").hover();
await expect(page.getByTestId("composer-attachment-annotate")).toBeVisible();
await waitForAnimations(page);
await composer.screenshot({
path: `${PR_SNAPSHOT_DIR}/01-image-annotation-overlay.png`,
});
await page.getByTestId("composer-attachment-annotate").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(page.getByTestId("composer-attachment-edit")).toBeVisible();
await expect(page.getByTestId("composer-attachment-spoiler")).toBeVisible();
await waitForAnimations(page);
await dialog.screenshot({
path: `${PR_SNAPSHOT_DIR}/02-image-editor-controls.png`,
});
});
test("draw on an uploaded image, save replaces it, revert restores in place", async ({
page,
}) => {
@@ -80,7 +107,8 @@ test("draw on an uploaded image, save replaces it, revert restores in place", as
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
// Open the composer lightbox.
await composer.getByAltText("Attachment aaaa").click();
await composer.getByTestId("composer-media-attachment").hover();
await page.getByTestId("composer-attachment-annotate").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src="${ORIGINAL_URL}"]`)).toBeVisible();
@@ -132,7 +160,8 @@ test("draw on an uploaded image, save replaces it, revert restores in place", as
expect(uploadCommandCount).toBe(1);
// Reopen the lightbox on the annotated attachment to revert.
await composer.getByAltText("Attachment bbbb").click();
await composer.getByTestId("composer-media-attachment").hover();
await page.getByTestId("composer-attachment-annotate").click();
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src="${EDITED_URL}"]`)).toBeVisible();
@@ -160,12 +189,14 @@ test("spoiler marking survives drawing on the attachment", async ({ page }) => {
// Spoiler the attachment from its lightbox (media spoilers are
// per-attachment; the text spoiler control no longer affects media),
// then draw on it.
await composer.getByAltText("Attachment aaaa").click();
await composer.getByTestId("composer-media-attachment").hover();
await page.getByTestId("composer-attachment-annotate").click();
await page.getByTestId("composer-attachment-spoiler").click();
await page.keyboard.press("Escape");
await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible();
await composer.getByAltText("Attachment aaaa").click();
await composer.getByTestId("composer-media-attachment").hover();
await page.getByTestId("composer-attachment-annotate").click();
await page.getByTestId("composer-attachment-edit").click();
await drawStrokeOnCanvas(page);
+198 -5
View File
@@ -5,6 +5,15 @@ import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
async function openMoreActionsMenu(page: Page, messageId: string) {
const row = page.locator(`[data-message-id="${messageId}"]`);
await row.hover();
await page.getByTestId(`more-actions-${messageId}`).click();
await expect(page.locator('[role="menuitem"]').first()).toBeVisible({
timeout: 5_000,
});
}
// Exercises the generic file-attachment UI contract end-to-end through the
// mock Tauri bridge: paperclip upload → composer chip → send → FileCard in the
// timeline. This guards the frontend wiring (the riskiest, previously
@@ -51,12 +60,100 @@ async function chooseLargeVideo(page: Page) {
});
}
async function choosePhoto(page: Page) {
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("button", { name: "Attach image" }).click(),
]);
await chooser.setFiles({
buffer: Buffer.from("photo"),
mimeType: "image/png",
name: "photo.png",
});
}
test("photos upload before Send without a queued spoiler control", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } };
}
).__BUZZ_E2E__;
if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000;
});
await page.getByTestId("channel-general").click();
await choosePhoto(page);
await expect(page.getByTestId("upload-progress")).toBeVisible();
await expect(page.getByTestId("composer-queued-video-spoiler")).toHaveCount(
0,
);
// Photos upload immediately, so they are in neither `pendingImeta` nor the
// queued list until the upload lands: Send stays blocked so the message
// cannot publish without the attachment.
await expect(page.getByTestId("send-message")).toBeDisabled();
await expect(page.getByTestId("upload-progress")).toHaveCount(0, {
timeout: 5_000,
});
await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0);
await expect(page.getByTestId("send-message")).toBeEnabled();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("upload_media_bytes_raw");
});
test("opening edit during an immediate photo upload preserves the draft", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } };
}
).__BUZZ_E2E__;
if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000;
});
await page.getByTestId("channel-general").click();
await choosePhoto(page);
await expect(page.getByTestId("upload-progress")).toBeVisible();
await openMoreActionsMenu(page, "mock-general-welcome");
await page.getByTestId("edit-message-mock-general-welcome").click();
// Edit entry is rejected while the compacted draft cannot represent the
// reserved upload slot. The upload remains current and lands in the draft.
await expect(page.getByTestId("edit-target")).toHaveCount(0);
await expect(page.getByTestId("upload-progress")).toBeVisible();
await expect(page.getByTestId("upload-progress")).toHaveCount(0, {
timeout: 5_000,
});
await expect(page.getByTestId("message-composer")).toContainText(
"quarterly-report.pdf",
);
// Once settled, the same edit action enters edit mode normally.
await openMoreActionsMenu(page, "mock-general-welcome");
await page.getByTestId("edit-message-mock-general-welcome").click();
await expect(page.getByTestId("edit-target")).toBeVisible();
});
test("upload a file and see a FileCard in the timeline", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// The paperclip queues the local file without starting its upload.
// Non-video files keep the established immediate-upload behavior.
await chooseQuarterlyReport(page);
// The composer shows a chip with the original filename.
@@ -103,13 +200,23 @@ test("sends immediately and keeps upload progress across channels", async ({
if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000;
});
await page.getByTestId("channel-general").click();
await chooseQuarterlyReport(page);
await chooseLargeVideo(page);
await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0);
await expect(page.getByTestId("composer-video-spoiler")).toHaveCount(0);
const queuedSpoiler = page.getByTestId("composer-queued-video-spoiler");
// Revealed on hover rather than removed from the DOM: the control stays
// focusable so keyboard users can reach it, but is transparent and
// click-through until the thumbnail is hovered or focused.
await expect(queuedSpoiler).toHaveCSS("opacity", "0");
await expect(queuedSpoiler).toHaveCSS("pointer-events", "none");
await page.getByTestId("composer-queued-media-attachment").hover();
await expect(queuedSpoiler).toBeVisible();
await expect(queuedSpoiler).toHaveCSS("opacity", "1");
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-composer")).not.toContainText(
"quarterly-report.pdf",
"large-video.mp4",
);
await expect(page.getByTestId("composer-upload-progress")).toBeVisible();
@@ -226,7 +333,7 @@ test("canceling a background upload prevents the message from publishing", async
if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000;
});
await page.getByTestId("channel-general").click();
await chooseQuarterlyReport(page);
await chooseLargeVideo(page);
await page.getByTestId("send-message").click();
await page.getByTestId("composer-upload-cancel").click();
@@ -259,7 +366,7 @@ test("upload progress floats above the dock and lifts Jump to latest", async ({
await expect(jumpToLatest).toBeVisible();
const restingBox = await jumpToLatest.boundingBox();
await chooseQuarterlyReport(page);
await chooseLargeVideo(page);
await page.getByTestId("send-message").click();
const uploadMotion = page.getByTestId("composer-upload-progress-motion");
await expect(uploadMotion).toBeVisible();
@@ -458,3 +565,89 @@ test("forum posts emit a FileCard for generic attachments, not a broken image",
)
.toContain("download_file");
});
test("a queued attachment can be removed without a mouse", async ({ page }) => {
// Regression: the queued remove badge is revealed on hover, but hiding it
// with `display: none` made it unfocusable, leaving keyboard-only users no
// way to drop a queued video before sending.
await page.goto("/");
await page.getByTestId("channel-general").click();
await chooseLargeVideo(page);
const queued = page.getByTestId("composer-queued-media-attachment");
await expect(queued).toBeVisible();
const remove = queued.getByRole("button", { name: "Remove attachment" });
// Focusable while transparent — this is what `display: none` prevented.
await remove.focus();
await expect(remove).toBeFocused();
// Focus reveals it, so the user can see what they are about to activate.
await expect(remove).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
await expect(queued).toHaveCount(0);
await expect(page.getByTestId("message-composer")).not.toContainText(
"large-video.mp4",
);
});
test("an uploaded attachment's remove button is named and keyboard-operable", async ({
page,
}) => {
// Companion to the queued case: these badges are now in the tab order, so
// every icon-only remove button needs an accessible name a screen reader can
// read. Images and non-media files render through different branches, so
// both are checked here.
await installMockBridge(page, {
deferredComposerUploads: true,
uploadDescriptors: [
{
url: `https://mock.relay/media/${"b".repeat(64)}.png`,
sha256: "b".repeat(64),
size: 2048,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "320x200",
filename: "photo.png",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
const composer = page.getByTestId("message-composer");
const remove = composer.getByRole("button", { name: "Remove attachment" });
// Image attachment (MediaAttachmentItem).
await choosePhoto(page);
await expect(composer.getByTestId("composer-media-attachment")).toBeVisible();
await expect(remove).toHaveCount(1);
await remove.focus();
await expect(remove).toBeFocused();
await expect(remove).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
await expect(composer.getByTestId("composer-media-attachment")).toHaveCount(
0,
);
});
test("a non-media attachment's remove button is named and keyboard-operable", async ({
page,
}) => {
// The file-card branch renders its own remove badge, so it needs the same
// accessible name as the image and queued ones.
await page.goto("/");
await page.getByTestId("channel-general").click();
await chooseQuarterlyReport(page);
const composer = page.getByTestId("message-composer");
await expect(composer).toContainText("quarterly-report.pdf");
const remove = composer.getByRole("button", { name: "Remove attachment" });
await expect(remove).toHaveCount(1);
await remove.focus();
await expect(remove).toBeFocused();
await expect(remove).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
await expect(composer).not.toContainText("quarterly-report.pdf");
});
+2 -1
View File
@@ -102,7 +102,8 @@ test("image attachments can be marked and sent as hidden spoilers", async ({
await expect(composer.getByAltText("Attachment cccc")).toBeVisible();
// Media spoilers are toggled per-attachment from the lightbox.
await composer.getByAltText("Attachment cccc").click();
await composer.getByTestId("composer-media-attachment").hover();
await page.getByTestId("composer-attachment-annotate").click();
await page.getByTestId("composer-attachment-spoiler").click();
await page.keyboard.press("Escape");
await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible();