mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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 editor controls  ## 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:
co-authored by
Honey
Wes
Carl
parent
2034e693a8
commit
f2ce575b62
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user