feat(composer): compose spoiler, annotation, and tooltip controls (#1491)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-07-06 16:36:01 -07:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent 36ac5243ff
commit 1aa87bb26b
17 changed files with 1373 additions and 256 deletions
+2
View File
@@ -40,8 +40,10 @@ export default defineConfig({
"**/agent-readiness-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/composer-image-draw.spec.ts",
"**/video-attachment.spec.ts",
"**/spoiler.spec.ts",
"**/composer-tooltip-dismiss.spec.ts",
"**/mentions.spec.ts",
"**/relay-reconnect.spec.ts",
"**/relay-reconnect-affordance.spec.ts",
@@ -129,6 +129,25 @@ pub async fn download_file(
save_bytes_with_dialog(&app, &filename, "All Files", &extensions, &bytes).await
}
/// Fetch relay media bytes for the composer image editor.
///
/// The editor composites the image onto a canvas and needs pixel access.
/// Handing the webview raw bytes over IPC (which it wraps in a same-origin
/// `blob:` URL) keeps the canvas un-tainted without involving CORS — and
/// therefore without any media-proxy header or origin-gate changes.
///
/// Same SSRF validation, size cap, and content policy as the download
/// commands above.
#[tauri::command]
pub async fn fetch_media_bytes(url: String, state: State<'_, AppState>) -> Result<Vec<u8>, String> {
let relay_base = relay_api_base_url_with_override(&state);
validate_download_url(&url, &relay_base)?;
let bytes = fetch_blob_bytes(&url, &state).await?;
detect_and_validate_mime(&bytes)?;
Ok(bytes)
}
/// Fetch blob bytes from a (pre-validated) relay media URL through the app's
/// HTTP client, enforcing the download size cap. The caller is responsible for
/// validating the URL origin and for any content-type checks on the result.
+1
View File
@@ -507,6 +507,7 @@ pub fn run() {
upload_media_bytes,
download_image,
download_file,
fetch_media_bytes,
list_relay_members,
get_my_relay_membership,
add_relay_member,
@@ -1,3 +1,5 @@
import * as React from "react";
import type { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { ComposerAttachments } from "@/features/messages/ui/ComposerAttachments";
@@ -5,9 +7,12 @@ type ComposerMedia = Pick<
ReturnType<typeof useMediaUpload>,
| "isUploading"
| "cancelUpload"
| "originalUrlByUrl"
| "pendingImeta"
| "removeAttachment"
| "revertAttachment"
| "setUploadState"
| "uploadEditedAttachment"
| "uploadState"
| "uploadingCount"
| "uploadingPreviews"
@@ -20,6 +25,13 @@ type ForumComposerMediaStatusProps = {
export function ForumComposerMediaStatus({
media,
}: ForumComposerMediaStatusProps) {
const handleEditSave = React.useCallback(
async (url: string, bytes: Uint8Array) => {
await media.uploadEditedAttachment(url, bytes);
},
[media.uploadEditedAttachment],
);
return (
<>
{media.uploadState.status === "error" ? (
@@ -41,7 +53,10 @@ export function ForumComposerMediaStatus({
attachments={media.pendingImeta}
isUploading={media.isUploading}
onCancelUpload={media.cancelUpload}
onEditSave={handleEditSave}
onRemove={media.removeAttachment}
onRevert={media.revertAttachment}
originalUrlByUrl={media.originalUrlByUrl}
uploadingCount={media.uploadingCount}
uploadingPreviews={media.uploadingPreviews}
/>
@@ -0,0 +1,53 @@
import * as React from "react";
import type { MediaUploadController } from "./useMediaUpload";
type UseAttachmentEditingArgs = {
revertAttachment: MediaUploadController["revertAttachment"];
/** Spoiler-set updater; membership follows the attachment across URL swaps. */
setSpoileredAttachmentUrls: React.Dispatch<React.SetStateAction<Set<string>>>;
uploadEditedAttachment: MediaUploadController["uploadEditedAttachment"];
};
/**
* Composer-side glue for the attachment drawing editor: uploads annotated
* bytes as a replacement / reverts to the pre-edit original, migrating
* spoiler membership from the replaced URL to its replacement so an edited
* spoilered image stays spoilered.
*/
export function useAttachmentEditing({
revertAttachment,
setSpoileredAttachmentUrls,
uploadEditedAttachment,
}: UseAttachmentEditingArgs) {
const migrateSpoileredUrl = React.useCallback(
(fromUrl: string, toUrl: string) => {
setSpoileredAttachmentUrls((current) => {
if (!current.has(fromUrl)) return current;
const next = new Set(current);
next.delete(fromUrl);
next.add(toUrl);
return next;
});
},
[setSpoileredAttachmentUrls],
);
const handleAttachmentEditSave = React.useCallback(
async (url: string, bytes: Uint8Array) => {
const descriptor = await uploadEditedAttachment(url, bytes);
if (descriptor) migrateSpoileredUrl(url, descriptor.url);
},
[migrateSpoileredUrl, uploadEditedAttachment],
);
const handleAttachmentRevert = React.useCallback(
(url: string) => {
const original = revertAttachment(url);
if (original) migrateSpoileredUrl(url, original.url);
},
[migrateSpoileredUrl, revertAttachment],
);
return { handleAttachmentEditSave, handleAttachmentRevert };
}
@@ -205,6 +205,44 @@ export function useMediaUpload() {
const pendingImetaRef = React.useRef(pendingImeta);
pendingImetaRef.current = pendingImeta;
/**
* Pre-edit originals of annotated attachments, keyed by the annotated
* attachment's URL. Powers "revert to original" in the composer lightbox.
* In-memory only — cleared implicitly when the attachment leaves the
* composer (send, remove, draft switch).
*/
const [originalsByUrl, setOriginalsByUrl] = React.useState<
Map<string, BlobDescriptor>
>(() => new Map());
const originalsByUrlRef = React.useRef(originalsByUrl);
originalsByUrlRef.current = originalsByUrl;
/** Annotated URL → original URL (derived; handy for stable list keys). */
const originalUrlByUrl = React.useMemo(() => {
const map = new Map<string, string>();
for (const [url, original] of originalsByUrl) map.set(url, original.url);
return map;
}, [originalsByUrl]);
// Prune originals whose annotated attachment is no longer pending —
// covers remove, cancel, send-clear, and draft restore in one place.
React.useEffect(() => {
setOriginalsByUrl((prev) => {
if (prev.size === 0) return prev;
const liveUrls = new Set(pendingImeta.map((d) => d.url));
let changed = false;
const next = new Map<string, BlobDescriptor>();
for (const [url, original] of prev) {
if (liveUrls.has(url)) {
next.set(url, original);
} else {
changed = true;
}
}
return changed ? next : prev;
});
}, [pendingImeta]);
/** Monotonic slot counter — ensures each batch gets unique indices even
* before React flushes the state update. */
const nextSlotRef = React.useRef(0);
@@ -511,6 +549,80 @@ export function useMediaUpload() {
[isUploadCanceled, onUploaded, onUploadError, reserveUploadingPreview],
);
/**
* Upload an annotated replacement for an existing image attachment and
* swap it into the same slot (attachment order is preserved). The pre-edit
* descriptor is remembered in `originalsByUrl` so the edit can be reverted;
* chained edits keep the earliest original as the single revert point.
*
* Returns the new descriptor, or null if `oldUrl` is no longer pending.
* Rejects on upload failure (after surfacing the standard error banner) so
* callers can keep their editing UI open.
*/
const uploadEditedAttachment = React.useCallback(
async (
oldUrl: string,
bytes: Uint8Array,
): Promise<BlobDescriptor | null> => {
const oldDescriptor = pendingImetaRef.current.find(
(d) => d.url === oldUrl,
);
if (!oldDescriptor) return null;
// The annotated output is always PNG — swap the extension accordingly.
const stem = (oldDescriptor.filename ?? "image").replace(/\.[^.]+$/, "");
const filename = `${stem}.png`;
const previewId = reserveUploadingPreview();
setUploadingCount((c) => c + 1);
try {
const descriptor = await uploadMediaBytes(
[...bytes],
filename,
uploadProgressId(previewId),
);
if (isUploadCanceled(previewId)) return null;
finishUpload(previewId);
setImetaSlots((prev) =>
prev.map((d) => (d?.url === oldUrl ? descriptor : d)),
);
setOriginalsByUrl((prev) => {
const next = new Map(prev);
// Re-editing an annotated image keeps the earliest original.
const original = prev.get(oldUrl) ?? oldDescriptor;
next.delete(oldUrl);
next.set(descriptor.url, original);
return next;
});
return descriptor;
} catch (err) {
onUploadError(err, previewId);
throw err;
}
},
[finishUpload, isUploadCanceled, onUploadError, reserveUploadingPreview],
);
/**
* Swap an annotated attachment back to its pre-edit original (same slot)
* and forget the stored original. Returns the restored descriptor, or null
* if the URL has no recorded original.
*/
const revertAttachment = React.useCallback(
(url: string): BlobDescriptor | null => {
const original = originalsByUrlRef.current.get(url);
if (!original) return null;
setImetaSlots((prev) => prev.map((d) => (d?.url === url ? original : d)));
setOriginalsByUrl((prev) => {
const next = new Map(prev);
next.delete(url);
return next;
});
return original;
},
[],
);
const removeAttachment = React.useCallback((url: string) => {
setImetaSlots((prev) => prev.map((d) => (d?.url === url ? null : d)));
}, []);
@@ -541,11 +653,14 @@ export function useMediaUpload() {
handlePaste,
isDragOver,
isUploading,
originalUrlByUrl,
pendingImeta,
pendingImetaRef,
removeAttachment,
revertAttachment,
setPendingImeta,
setUploadState,
uploadEditedAttachment,
uploadFile,
uploadingCount,
uploadingPreviews,
@@ -561,9 +676,12 @@ export function useMediaUpload() {
handlePaste,
isDragOver,
isUploading,
originalUrlByUrl,
pendingImeta,
removeAttachment,
revertAttachment,
setPendingImeta,
uploadEditedAttachment,
uploadFile,
uploadingCount,
uploadingPreviews,
@@ -1,6 +1,7 @@
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { FileText, HatGlasses, Play, X } from "lucide-react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { FileText, HatGlasses, Pencil, Play, X } from "lucide-react";
import type { BlobDescriptor } from "@/shared/api/tauri";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
@@ -9,9 +10,12 @@ import {
type UploadingAttachmentPreview,
} from "@/features/messages/lib/useMediaUpload";
import { cn } from "@/shared/lib/cn";
import { SimpleImageLightbox } from "@/shared/ui/SimpleImageLightbox";
import { Button } from "@/shared/ui/button";
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
import { Progress } from "@/shared/ui/progress";
import { Toggle } from "@/shared/ui/toggle";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { ComposerImageEditor } from "./ComposerImageEditor";
/** Dashed-border overlay shown when a file is dragged over the composer form. */
export function DropZoneOverlay({ className }: { className?: string }) {
@@ -35,10 +39,20 @@ type ComposerAttachmentsProps = {
onCancelUpload?: (previewId: number) => void;
uploadingCount?: number;
uploadingPreviews?: UploadingAttachmentPreview[];
/** Upload annotated bytes as a replacement for the attachment at `url`. */
onEditSave?: (url: string, bytes: Uint8Array) => Promise<void>;
onRemove: (url: string) => void;
/** Restore the pre-edit original for an annotated attachment. */
onRevert?: (url: string) => void;
/** Annotated attachment URL → original (pre-edit) URL. */
originalUrlByUrl?: ReadonlyMap<string, string>;
onToggleSpoiler?: (url: string) => void;
spoileredUrls?: ReadonlySet<string>;
};
const LIGHTBOX_BUTTON_CLASS =
"rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30";
const COMPOSER_MEDIA_HEIGHT_PX = 55;
const COMPOSER_MEDIA_WIDTH_PX = 55;
@@ -49,6 +63,297 @@ function composerMediaStyle(): React.CSSProperties {
};
}
type MediaAttachmentItemProps = {
attachment: BlobDescriptor;
isSpoilered: boolean;
onEditSave?: (url: string, bytes: Uint8Array) => Promise<void>;
onRemove: (url: string) => void;
onRevert?: (url: string) => void;
onToggleSpoiler?: (url: string) => void;
/** Set when this attachment is an annotated replacement of an original. */
originalUrl?: string;
};
/**
* A single image/video attachment thumbnail with its lightbox dialog.
* Images support an in-lightbox canvas edit mode (freehand drawing) and,
* once annotated, an in-place revert to the original. Save closes the
* dialog; revert keeps it open (the parent keys this item by its original
* URL so the swap doesn't remount it).
*
* Forwards its ref to the root motion.div required by the parent
* `AnimatePresence mode="popLayout"`, which measures exiting children.
*/
const MediaAttachmentItem = React.forwardRef<
HTMLDivElement,
MediaAttachmentItemProps
>(function MediaAttachmentItem(
{
attachment,
isSpoilered,
onEditSave,
onRemove,
onRevert,
onToggleSpoiler,
originalUrl,
},
ref,
) {
const [open, setOpen] = React.useState(false);
const [mode, setMode] = React.useState<"view" | "edit">("view");
const hash = shortHash(attachment.sha256);
const isVideo = attachment.type.startsWith("video/");
const thumbUrl = attachment.thumb
? rewriteRelayUrl(attachment.thumb)
: rewriteRelayUrl(attachment.url);
const videoPosterUrl = attachment.image
? rewriteRelayUrl(attachment.image)
: attachment.thumb
? rewriteRelayUrl(attachment.thumb)
: undefined;
const canEdit = !isVideo && onEditSave !== undefined;
const canRevert =
!isVideo && onRevert !== undefined && originalUrl !== undefined;
const handleOpenChange = React.useCallback((next: boolean) => {
setOpen(next);
if (!next) setMode("view");
}, []);
// Read `mode` via a ref: Radix's dismissable layer (>=1.1.14) registers a
// stable Escape listener, so the handler would otherwise see a stale mode.
const modeRef = React.useRef(mode);
modeRef.current = mode;
const handleEscapeKeyDown = React.useCallback((event: KeyboardEvent) => {
if (modeRef.current === "edit") {
// Escape leaves canvas mode but keeps the lightbox open.
event.preventDefault();
setMode("view");
}
}, []);
const handleEditorSave = React.useCallback(
async (bytes: Uint8Array) => {
if (!onEditSave) return;
await onEditSave(attachment.url, bytes);
// Close on save so rapid save/redraw cycles don't orphan a blob per iteration.
setMode("view");
setOpen(false);
},
[attachment.url, onEditSave],
);
const handleEditorCancel = React.useCallback(() => setMode("view"), []);
const handleRevert = React.useCallback(() => {
onRevert?.(attachment.url);
}, [attachment.url, onRevert]);
return (
<motion.div
ref={ref}
layout
initial={false}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="group relative"
>
<div
className="relative h-[55px] max-w-[55px]"
style={composerMediaStyle()}
>
<DialogPrimitive.Root open={open} onOpenChange={handleOpenChange}>
<DialogPrimitive.Trigger asChild>
<div className="h-full w-full cursor-pointer overflow-hidden rounded-2xl border border-border/70">
{isVideo ? (
<div className="relative flex h-full w-full items-center justify-center bg-muted text-white">
{videoPosterUrl ? (
<img
src={videoPosterUrl}
alt={`Video attachment ${hash}`}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-muted/80" />
)}
<div className="absolute inset-0 bg-black/15" />
<div className="absolute flex h-5 w-5 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm">
<Play className="h-4 w-4 fill-white text-white" />
</div>
</div>
) : (
<img
src={thumbUrl}
alt={`Attachment ${hash}`}
className="h-full w-full object-cover"
/>
)}
{isSpoilered ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/55 text-foreground/70 backdrop-blur-[1px]"
data-composer-media-spoiler=""
>
<HatGlasses className="h-4 w-4" />
</div>
) : null}
</div>
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
MODAL_BACKDROP_BLUR_CLASS,
)}
/>
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex items-center justify-center p-8"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={handleEscapeKeyDown}
>
<DialogPrimitive.Title className="sr-only">
Attachment {hash} preview
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Full-size attachment preview. Press Escape or click outside to
close.
</DialogPrimitive.Description>
{mode === "view" ? (
<DialogPrimitive.Close
className="absolute inset-0 cursor-default"
aria-label="Close lightbox"
/>
) : null}
{mode === "edit" && !isVideo ? (
<ComposerImageEditor
alt={`Attachment ${hash}`}
src={rewriteRelayUrl(attachment.url)}
sourceUrl={attachment.url}
sourceType={attachment.type}
onCancel={handleEditorCancel}
onSave={handleEditorSave}
/>
) : isVideo ? (
// biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available
<video
src={rewriteRelayUrl(attachment.url)}
controls
className={cn(
"relative max-h-[90vh] max-w-[90vw] rounded-lg",
isSpoilered && "blur-2xl brightness-75",
)}
/>
) : (
<img
alt={`Attachment ${hash}`}
className={cn(
"relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain",
isSpoilered && "blur-2xl brightness-75",
)}
src={rewriteRelayUrl(attachment.url)}
/>
)}
{mode === "view" && isSpoilered ? (
/*
* Expanded-media counterpart of the thumbnail spoiler treatment:
* the media itself is blurred above, and this layer centers the
* spoiler glyph. pointer-events-none keeps controls and
* backdrop-close clickable.
*/
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center text-foreground/70"
data-lightbox-media-spoiler=""
>
<HatGlasses className="h-10 w-10" />
</div>
) : null}
{mode === "view" ? (
<div className="absolute right-4 top-4 flex items-center gap-2">
{canRevert ? (
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Button
data-testid="composer-attachment-revert"
onClick={handleRevert}
size="sm"
type="button"
>
Revert
</Button>
</TooltipTrigger>
<TooltipContent>Revert to original</TooltipContent>
</Tooltip>
) : null}
{onToggleSpoiler ? (
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Toggle
aria-label={
isSpoilered ? "Remove spoiler" : "Mark as spoiler"
}
className={cn(
LIGHTBOX_BUTTON_CLASS,
"h-auto min-w-0",
)}
data-testid="composer-attachment-spoiler"
onPressedChange={() =>
onToggleSpoiler(attachment.url)
}
pressed={isSpoilered}
>
<HatGlasses className="h-4 w-4" />
</Toggle>
</TooltipTrigger>
<TooltipContent>
{isSpoilered ? "Remove spoiler" : "Mark as spoiler"}
</TooltipContent>
</Tooltip>
) : null}
{canEdit ? (
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<button
type="button"
className={LIGHTBOX_BUTTON_CLASS}
data-testid="composer-attachment-edit"
onClick={() => setMode("edit")}
>
<Pencil className="h-4 w-4" />
<span className="sr-only">Draw on image</span>
</button>
</TooltipTrigger>
<TooltipContent>Draw on image</TooltipContent>
</Tooltip>
) : null}
<DialogPrimitive.Close className={LIGHTBOX_BUTTON_CLASS}>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
) : null}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<button
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"
>
<X className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent>Remove attachment</TooltipContent>
</Tooltip>
</div>
</motion.div>
);
});
/**
* Thumbnail previews for uploaded attachments in the composer.
* Each attachment shows as a small image with a remove button and
@@ -60,7 +365,11 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
uploadingCount = 0,
uploadingPreviews = [],
onCancelUpload,
onEditSave,
onRemove,
onRevert,
originalUrlByUrl,
onToggleSpoiler,
spoileredUrls,
}: ComposerAttachmentsProps) {
if (attachments.length === 0 && !isUploading) return null;
@@ -85,16 +394,6 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
const isVideo = attachment.type.startsWith("video/");
const isImage = attachment.type.startsWith("image/");
const isFile = !isVideo && !isImage;
const isSpoilered = spoileredUrls?.has(attachment.url) ?? false;
const thumbUrl = attachment.thumb
? rewriteRelayUrl(attachment.thumb)
: rewriteRelayUrl(attachment.url);
const videoPosterUrl = attachment.image
? rewriteRelayUrl(attachment.image)
: attachment.thumb
? rewriteRelayUrl(attachment.thumb)
: undefined;
const mediaStyle = composerMediaStyle();
// Generic file: compact chip with a file icon + filename, plus the
// same remove button. No lightbox (nothing to preview).
@@ -113,13 +412,13 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="group relative"
>
<div className="flex h-5 max-w-[10rem] items-center gap-1 rounded border border-border/70 bg-muted px-1.5">
<div className="flex h-5 max-w-40 items-center gap-1 rounded border border-border/70 bg-muted px-1.5">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-2xs text-muted-foreground">
{label}
</span>
</div>
<Tooltip>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<button
type="button"
@@ -135,39 +434,21 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
);
}
const originalUrl = originalUrlByUrl?.get(attachment.url);
return (
<motion.div
key={attachment.url}
layout
initial={false}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="group relative"
>
<AttachmentMediaLightbox
alt={`Attachment ${hash} preview`}
hash={hash}
isSpoilered={isSpoilered}
isVideo={isVideo}
mediaStyle={mediaStyle}
thumbUrl={thumbUrl}
url={attachment.url}
videoPosterUrl={videoPosterUrl ?? null}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
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"
>
<X className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent>Remove attachment</TooltipContent>
</Tooltip>
</motion.div>
<MediaAttachmentItem
attachment={attachment}
isSpoilered={spoileredUrls?.has(attachment.url) ?? false}
// Annotated attachments keep their original URL as the key so
// the in-place edit/revert URL swap doesn't remount the item
// (which would close its open lightbox dialog).
key={originalUrl ?? attachment.url}
onEditSave={onEditSave}
onRemove={onRemove}
onRevert={onRevert}
onToggleSpoiler={onToggleSpoiler}
originalUrl={originalUrl}
/>
);
})}
{isUploading &&
@@ -210,7 +491,7 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
</div>
</div>
{onCancelUpload && preview.id >= 0 ? (
<Tooltip>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<button
type="button"
@@ -232,83 +513,3 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
</LayoutGroup>
);
});
function AttachmentMediaLightbox({
alt,
hash,
isSpoilered,
isVideo,
mediaStyle,
thumbUrl,
url,
videoPosterUrl,
}: {
alt: string;
hash: string;
isSpoilered: boolean;
isVideo: boolean;
mediaStyle: React.CSSProperties;
thumbUrl: string;
url: string;
videoPosterUrl: string | null;
}) {
const [lightboxOpen, setLightboxOpen] = React.useState(false);
const previewSrc = rewriteRelayUrl(url);
return (
<div className="relative h-[55px] max-w-[55px]" style={mediaStyle}>
<button
className="h-full w-full cursor-pointer overflow-hidden rounded-2xl border border-border/70"
onClick={() => setLightboxOpen(true)}
type="button"
>
{isVideo ? (
<div className="relative flex h-full w-full items-center justify-center bg-muted text-white">
{videoPosterUrl ? (
<img
alt={`Video attachment ${hash}`}
className="h-full w-full object-cover"
src={videoPosterUrl}
/>
) : (
<div className="h-full w-full bg-muted/80" />
)}
<div className="absolute inset-0 bg-black/15" />
<div className="absolute flex h-5 w-5 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm">
<Play className="h-4 w-4 fill-white text-white" />
</div>
</div>
) : (
<img
alt={`Attachment ${hash}`}
className="h-full w-full object-cover"
src={thumbUrl}
/>
)}
{isSpoilered ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/55 text-foreground/70 backdrop-blur-[1px]"
data-composer-media-spoiler=""
>
<HatGlasses className="h-4 w-4" />
</div>
) : null}
</button>
<SimpleImageLightbox
alt={alt}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
src={previewSrc}
>
{isVideo ? (
// biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available
<video
className="relative max-h-[90vh] max-w-[90vw] rounded-lg"
controls
src={previewSrc}
/>
) : undefined}
</SimpleImageLightbox>
</div>
);
}
@@ -27,7 +27,7 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
}: ComposerEmojiPickerProps) {
return (
<Popover onOpenChange={onOpenChange} open={open}>
<Tooltip>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
@@ -0,0 +1,465 @@
import * as React from "react";
import { Loader2, Redo2, Undo2 } from "lucide-react";
import { fetchMediaBytes } from "@/shared/api/tauriMedia";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
type EditorPoint = { x: number; y: number };
/** A committed pen stroke, in natural-image pixel coordinates. */
type EditorStroke = {
color: string;
points: EditorPoint[];
/** Line width in natural-image pixels (already scaled from CSS px). */
width: number;
};
const PEN_COLORS = [
{ label: "Red", value: "#ef4444" },
{ label: "Yellow", value: "#f59e0b" },
{ label: "Green", value: "#22c55e" },
{ label: "Blue", value: "#3b82f6" },
{ label: "White", value: "#ffffff" },
{ label: "Black", value: "#111111" },
] as const;
/** Pen stroke width range, in CSS pixels: five whole-pixel slider stops. */
const PEN_WIDTH_MIN_CSS = 4;
const PEN_WIDTH_MAX_CSS = 12;
const PEN_WIDTH_STEP_CSS = 2;
const PEN_WIDTH_DEFAULT_CSS = 6;
function drawStroke(ctx: CanvasRenderingContext2D, stroke: EditorStroke) {
const [first, ...rest] = stroke.points;
if (!first) return;
ctx.strokeStyle = stroke.color;
ctx.fillStyle = stroke.color;
ctx.lineWidth = stroke.width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (rest.length === 0) {
// Single click — leave a dot instead of an invisible zero-length line.
ctx.beginPath();
ctx.arc(first.x, first.y, stroke.width / 2, 0, Math.PI * 2);
ctx.fill();
return;
}
ctx.beginPath();
ctx.moveTo(first.x, first.y);
for (const point of rest) ctx.lineTo(point.x, point.y);
ctx.stroke();
}
function drawSegment(
ctx: CanvasRenderingContext2D,
from: EditorPoint,
to: EditorPoint,
stroke: EditorStroke,
) {
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
/**
* Composite the source image and strokes into a PNG at natural resolution.
*
* The source bytes are fetched over Tauri IPC and wrapped in a `blob:` URL.
* Blob URLs are same-origin, so the canvas stays un-tainted and `toBlob`
* works without any CORS involvement (the media proxy sends no CORS
* headers, and cross-origin `crossOrigin="anonymous"` loads would need
* them).
*/
async function renderAnnotatedPng(
sourceUrl: string,
sourceType: string,
strokes: EditorStroke[],
): Promise<Uint8Array> {
const bytes = await fetchMediaBytes(sourceUrl);
// The explicit type matters: blob: image decoding is not content-sniffed
// for all formats, so an untyped blob may fail to decode.
const sourceBlob = new Blob([bytes], { type: sourceType });
const blobUrl = URL.createObjectURL(sourceBlob);
try {
const image = new Image();
image.src = blobUrl;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas 2D context unavailable");
ctx.drawImage(image, 0, 0);
for (const stroke of strokes) drawStroke(ctx, stroke);
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, "image/png");
});
if (!blob) throw new Error("PNG encoding failed");
return new Uint8Array(await blob.arrayBuffer());
} finally {
URL.revokeObjectURL(blobUrl);
}
}
type ComposerImageEditorProps = {
alt: string;
/** Resolved (proxy-rewritten) image URL, for display. */
src: string;
/** Original relay media URL — export fetches its bytes over IPC. */
sourceUrl: string;
/** MIME type of the source image (from the blob descriptor). */
sourceType: string;
onCancel: () => void;
/** Upload the annotated PNG; rejection keeps the editor open. */
onSave: (bytes: Uint8Array) => Promise<void>;
};
/**
* Freehand drawing mode for a composer image attachment: the image at
* lightbox size with a canvas overlay, plus a pen toolbar (color, stroke
* width, undo, clear, cancel, save). Strokes are stored in natural-image
* coordinates so the exported PNG matches what's on screen.
*/
export function ComposerImageEditor({
alt,
src,
sourceUrl,
sourceType,
onCancel,
onSave,
}: ComposerImageEditorProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const activeStrokeRef = React.useRef<EditorStroke | null>(null);
// Committed strokes plus the undone strokes available for redo. Kept in
// one state object so undo/redo move strokes between stacks atomically.
const [history, setHistory] = React.useState<{
strokes: EditorStroke[];
undone: EditorStroke[];
}>({ strokes: [], undone: [] });
const strokes = history.strokes;
const [activeColor, setActiveColor] = React.useState<string>(
PEN_COLORS[0].value,
);
const [activeWidthCss, setActiveWidthCss] = React.useState<number>(
PEN_WIDTH_DEFAULT_CSS,
);
const [naturalSize, setNaturalSize] = React.useState<{
height: number;
width: number;
} | null>(null);
const [saving, setSaving] = React.useState(false);
const [saveError, setSaveError] = React.useState<string | null>(null);
const handleImageLoad = React.useCallback(
(event: React.SyntheticEvent<HTMLImageElement>) => {
const { naturalHeight, naturalWidth } = event.currentTarget;
if (naturalWidth > 0 && naturalHeight > 0) {
setNaturalSize({ height: naturalHeight, width: naturalWidth });
}
},
[],
);
// Redraw committed strokes whenever they change (undo/clear/commit).
// Live segments are drawn imperatively during pointermove for latency.
React.useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const stroke of strokes) drawStroke(ctx, stroke);
}, [strokes]);
const undo = React.useCallback(() => {
setHistory((prev) => {
const last = prev.strokes[prev.strokes.length - 1];
if (!last) return prev;
return {
strokes: prev.strokes.slice(0, -1),
undone: [...prev.undone, last],
};
});
}, []);
const redo = React.useCallback(() => {
setHistory((prev) => {
const last = prev.undone[prev.undone.length - 1];
if (!last) return prev;
return {
strokes: [...prev.strokes, last],
undone: prev.undone.slice(0, -1),
};
});
}, []);
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
const isModZ =
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
event.key.toLowerCase() === "z";
if (!isModZ) return;
event.preventDefault();
if (event.shiftKey) {
redo();
} else {
undo();
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [redo, undo]);
const toNaturalPoint = React.useCallback(
(event: React.PointerEvent<HTMLCanvasElement>): EditorPoint | null => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
return {
x: ((event.clientX - rect.left) / rect.width) * canvas.width,
y: ((event.clientY - rect.top) / rect.height) * canvas.height,
};
},
[],
);
const handlePointerDown = React.useCallback(
(event: React.PointerEvent<HTMLCanvasElement>) => {
if (event.button !== 0 || saving) return;
const canvas = canvasRef.current;
const point = toNaturalPoint(event);
if (!canvas || !point) return;
canvas.setPointerCapture(event.pointerId);
const rect = canvas.getBoundingClientRect();
const stroke: EditorStroke = {
color: activeColor,
points: [point],
// Scale the chosen CSS width into natural pixels so the on-screen
// preview matches the exported PNG exactly.
width: Math.max(1, activeWidthCss * (canvas.width / rect.width)),
};
activeStrokeRef.current = stroke;
const ctx = canvas.getContext("2d");
if (ctx) drawStroke(ctx, stroke);
},
[activeColor, activeWidthCss, saving, toNaturalPoint],
);
const handlePointerMove = React.useCallback(
(event: React.PointerEvent<HTMLCanvasElement>) => {
const stroke = activeStrokeRef.current;
const canvas = canvasRef.current;
if (!stroke || !canvas) return;
const point = toNaturalPoint(event);
if (!point) return;
const previous = stroke.points[stroke.points.length - 1];
stroke.points.push(point);
const ctx = canvas.getContext("2d");
if (ctx && previous) drawSegment(ctx, previous, point, stroke);
},
[toNaturalPoint],
);
const commitActiveStroke = React.useCallback(() => {
const stroke = activeStrokeRef.current;
if (!stroke) return;
activeStrokeRef.current = null;
// A new stroke invalidates the redo stack, matching editor conventions.
setHistory((prev) => ({ strokes: [...prev.strokes, stroke], undone: [] }));
}, []);
const handleSave = React.useCallback(async () => {
if (saving || strokes.length === 0) return;
setSaving(true);
setSaveError(null);
try {
const bytes = await renderAnnotatedPng(sourceUrl, sourceType, strokes);
await onSave(bytes);
// On success the parent closes the lightbox and unmounts this component.
} catch {
setSaveError("Could not save the drawing. Please try again.");
setSaving(false);
}
}, [onSave, saving, sourceType, sourceUrl, strokes]);
const hasStrokes = strokes.length > 0;
// The native cursor is hidden over the canvas; this DOM dot follows the
// pointer instead. Unlike a `cursor: url(...)` image, an element sized in
// CSS pixels is guaranteed to match the on-screen stroke width exactly.
// Positioned imperatively during pointermove to avoid re-rendering.
const brushPreviewRef = React.useRef<HTMLDivElement>(null);
const moveBrushPreview = React.useCallback(
(event: React.PointerEvent<HTMLCanvasElement>) => {
const preview = brushPreviewRef.current;
const rect = canvasRef.current?.getBoundingClientRect();
if (!preview || !rect) return;
preview.style.opacity = "1";
preview.style.transform = `translate(${event.clientX - rect.left}px, ${event.clientY - rect.top}px) translate(-50%, -50%)`;
},
[],
);
const hideBrushPreview = React.useCallback(() => {
const preview = brushPreviewRef.current;
if (preview) preview.style.opacity = "0";
}, []);
return (
<div className="relative z-10 flex max-h-full max-w-full flex-col items-center gap-3">
<div className="relative">
<img
alt={alt}
className="pointer-events-none max-h-[75vh] max-w-[85vw] select-none rounded-lg object-contain"
draggable={false}
onLoad={handleImageLoad}
src={src}
/>
{naturalSize ? (
<>
<canvas
aria-label="Drawing canvas"
className="absolute inset-0 h-full w-full cursor-none touch-none rounded-lg"
data-testid="composer-image-editor-canvas"
height={naturalSize.height}
onPointerCancel={commitActiveStroke}
onPointerDown={handlePointerDown}
onPointerEnter={moveBrushPreview}
onPointerLeave={hideBrushPreview}
onPointerMove={(event) => {
handlePointerMove(event);
moveBrushPreview(event);
}}
onPointerUp={commitActiveStroke}
ref={canvasRef}
width={naturalSize.width}
/>
<div
aria-hidden
className="pointer-events-none absolute left-0 top-0 rounded-full opacity-0 ring-1 ring-white/60"
ref={brushPreviewRef}
style={{
backgroundColor: activeColor,
height: `${activeWidthCss}px`,
width: `${activeWidthCss}px`,
}}
/>
</>
) : null}
</div>
<div className="fixed right-4 top-4 z-20 flex items-center gap-3">
<div
className="flex items-center gap-3 animate-in fade-in slide-in-from-right-12 duration-300"
data-testid="composer-image-editor-toolbar"
>
<input
aria-label="Stroke width"
className="h-1 w-12 cursor-pointer appearance-none rounded-full bg-white/25 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
max={PEN_WIDTH_MAX_CSS}
min={PEN_WIDTH_MIN_CSS}
onChange={(event) => setActiveWidthCss(Number(event.target.value))}
step={PEN_WIDTH_STEP_CSS}
type="range"
value={activeWidthCss}
/>
<div className="flex items-center gap-1.5">
{PEN_COLORS.map((color) => (
<button
aria-label={`${color.label} pen`}
aria-pressed={activeColor === color.value}
className={cn(
"flex h-5 w-5 items-center justify-center rounded-full transition-transform",
activeColor === color.value && "scale-110 ring-2 ring-white",
)}
key={color.value}
onClick={() => setActiveColor(color.value)}
type="button"
>
<span
className={cn(
"rounded-full transition-[height,width]",
color.label === "Black" && "ring-1 ring-white/30",
)}
style={{
backgroundColor: color.value,
height: `${activeWidthCss}px`,
width: `${activeWidthCss}px`,
}}
/>
</button>
))}
</div>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<button
aria-label="Undo last stroke"
className="flex h-7 w-7 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 disabled:opacity-40 disabled:hover:bg-transparent"
disabled={!hasStrokes}
onClick={undo}
type="button"
>
<Undo2 className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Undo (Z)</TooltipContent>
</Tooltip>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<button
aria-label="Redo stroke"
className="flex h-7 w-7 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 disabled:opacity-40 disabled:hover:bg-transparent"
disabled={history.undone.length === 0}
onClick={redo}
type="button"
>
<Redo2 className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Redo (Z)</TooltipContent>
</Tooltip>
</div>
<Button
className="text-white hover:bg-white/10 hover:text-white"
disabled={saving}
onClick={onCancel}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
<Button
data-testid="composer-image-editor-save"
disabled={saving || !hasStrokes}
onClick={() => void handleSave()}
size="sm"
type="button"
>
{saving ? <Loader2 className="animate-spin" /> : null}
Save
</Button>
</div>
{saveError ? (
<p className="text-xs text-red-300" role="alert">
{saveError}
</p>
) : null}
</div>
);
}
@@ -3,6 +3,7 @@ import type { Editor } from "@tiptap/react";
import {
Bold,
Code,
HatGlasses,
Italic,
Link,
List,
@@ -28,11 +29,6 @@ type FormattingToolbarProps = {
onLinkButton?: () => void;
};
export type SpoilerToggleState = {
emptySelection: boolean;
nextSpoilered?: boolean;
};
type ActiveStates = {
bold: boolean;
italic: boolean;
@@ -43,6 +39,7 @@ type ActiveStates = {
bulletList: boolean;
orderedList: boolean;
blockquote: boolean;
spoiler: boolean;
};
function getActiveStates(editor: Editor): ActiveStates {
@@ -56,6 +53,7 @@ function getActiveStates(editor: Editor): ActiveStates {
bulletList: editor.isActive("bulletList"),
orderedList: editor.isActive("orderedList"),
blockquote: editor.isActive("blockquote"),
spoiler: isSpoilerFormattingActive(editor),
};
}
@@ -77,12 +75,17 @@ function documentRangeForEmptySelection(editor: Editor): {
return from < to ? { from, to } : null;
}
export function toggleSpoilerFormatting(editor: Editor): SpoilerToggleState {
const emptySelection = editor.state.selection.empty;
/**
* Toggles the text spoiler mark. With a selection, toggles the mark on the
* selected range; with an empty selection, applies/removes spoiler across the
* whole document. Text-only media spoilers are toggled per-attachment in
* the attachment lightbox.
*/
export function toggleSpoilerFormatting(editor: Editor): void {
const range = documentRangeForEmptySelection(editor);
if (!range) {
editor.chain().focus().toggleMark(SPOILER_MARK_NAME).run();
return { emptySelection };
return;
}
const cursorPosition = editor.state.selection.from;
@@ -94,16 +97,14 @@ export function toggleSpoilerFormatting(editor: Editor): SpoilerToggleState {
);
if (rangeSpoilerState === "no-markable-content") {
chain.setTextSelection(cursorPosition).run();
return { emptySelection };
return;
}
const nextSpoilered = rangeSpoilerState !== "fully-spoiled";
if (nextSpoilered) {
if (rangeSpoilerState !== "fully-spoiled") {
chain.setMark(SPOILER_MARK_NAME).setTextSelection(cursorPosition).run();
} else {
chain.unsetMark(SPOILER_MARK_NAME).setTextSelection(cursorPosition).run();
}
return { emptySelection, nextSpoilered };
}
/**
@@ -202,6 +203,11 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
editor?.chain().focus().toggleBlockquote().run();
}, [editor]);
const toggleSpoiler = React.useCallback(() => {
if (!editor) return;
toggleSpoilerFormatting(editor);
}, [editor]);
if (!editor || !activeStates) return null;
const items = [
@@ -264,12 +270,18 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
action: toggleBlockquote,
active: activeStates.blockquote,
},
{
icon: HatGlasses,
label: "Spoiler",
action: toggleSpoiler,
active: activeStates.spoiler,
},
] as const;
return (
<div className="flex items-center gap-0.5">
{items.map((item) => (
<Tooltip key={item.label}>
<Tooltip key={item.label} disableHoverableContent>
<TooltipTrigger asChild>
<button
type="button"
@@ -18,6 +18,7 @@ import {
stripImetaMediaLines,
} from "@/features/messages/lib/imetaMediaMarkdown";
import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing";
import {
type MediaUploadController,
useMediaUpload,
@@ -815,41 +816,24 @@ function MessageComposerImpl({
[media.removeAttachment],
);
const handleComposerSpoilerToggle = React.useCallback(
({
emptySelection,
nextSpoilered,
}: {
emptySelection: boolean;
nextSpoilered?: boolean;
}) => {
if (!emptySelection) return;
const { handleAttachmentEditSave, handleAttachmentRevert } =
useAttachmentEditing({
revertAttachment: media.revertAttachment,
setSpoileredAttachmentUrls,
uploadEditedAttachment: media.uploadEditedAttachment,
});
const mediaUrls = media.pendingImetaRef.current
.filter(
(attachment) =>
attachment.type.startsWith("image/") ||
attachment.type.startsWith("video/"),
)
.map((attachment) => attachment.url);
if (mediaUrls.length === 0) return;
setSpoileredAttachmentUrls((current) => {
const shouldSpoiler =
nextSpoilered ?? mediaUrls.some((url) => !current.has(url));
const next = new Set(current);
for (const url of mediaUrls) {
if (shouldSpoiler) {
next.add(url);
} else {
next.delete(url);
}
}
return next;
});
},
[media.pendingImetaRef],
);
const handleToggleAttachmentSpoiler = React.useCallback((url: string) => {
setSpoileredAttachmentUrls((current) => {
const next = new Set(current);
if (next.has(url)) {
next.delete(url);
} else {
next.add(url);
}
return next;
});
}, []);
return (
<>
@@ -934,7 +918,11 @@ function MessageComposerImpl({
onCancelUpload={media.cancelUpload}
uploadingCount={media.uploadingCount}
uploadingPreviews={media.uploadingPreviews}
onEditSave={handleAttachmentEditSave}
onRemove={handleRemoveAttachment}
onRevert={handleAttachmentRevert}
originalUrlByUrl={media.originalUrlByUrl}
onToggleSpoiler={handleToggleAttachmentSpoiler}
spoileredUrls={spoileredAttachmentUrls}
/>
</div>
@@ -966,9 +954,7 @@ function MessageComposerImpl({
onLinkButton={linkEditor.openFromToolbar}
onOpenMentionPicker={openMentionPicker}
onPaperclip={handlePaperclipClick}
onSpoilerToggle={handleComposerSpoilerToggle}
sendDisabled={sendDisabled}
spoilerActive={spoileredAttachmentUrls.size > 0}
/>
</form>
</div>
@@ -1,25 +1,12 @@
import * as React from "react";
import type { Editor } from "@tiptap/react";
import { AnimatePresence, motion } from "motion/react";
import {
ALargeSmall,
ArrowUp,
AtSign,
HatGlasses,
Paperclip,
X,
} from "lucide-react";
import { ALargeSmall, ArrowUp, AtSign, Paperclip, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { ComposerEmojiPicker } from "./ComposerEmojiPicker";
import {
FormattingToolbar,
isSpoilerFormattingActive,
type SpoilerToggleState,
toggleSpoilerFormatting,
} from "./FormattingToolbar";
import { FormattingToolbar } from "./FormattingToolbar";
import { SelectionFormattingTray } from "./SelectionFormattingTray";
/** Spring for enter/exit of button groups — all fire simultaneously. */
@@ -46,9 +33,7 @@ export const MessageComposerToolbar = React.memo(
onLinkButton,
onOpenMentionPicker,
onPaperclip,
onSpoilerToggle,
sendDisabled,
spoilerActive,
}: {
composerDisabled: boolean;
editor: Editor | null;
@@ -65,38 +50,8 @@ export const MessageComposerToolbar = React.memo(
onLinkButton: () => void;
onOpenMentionPicker: () => void;
onPaperclip: () => void;
onSpoilerToggle?: (state: SpoilerToggleState) => void;
sendDisabled: boolean;
spoilerActive?: boolean;
}) {
const [spoilerFormattingActive, setSpoilerFormattingActive] =
React.useState(() =>
editor ? isSpoilerFormattingActive(editor) : false,
);
React.useEffect(() => {
if (!editor) {
setSpoilerFormattingActive(false);
return;
}
const update = () => {
setSpoilerFormattingActive(isSpoilerFormattingActive(editor));
};
update();
editor.on("transaction", update);
return () => {
editor.off("transaction", update);
};
}, [editor]);
const isSpoilerActive = spoilerFormattingActive || Boolean(spoilerActive);
const handleSpoilerClick = React.useCallback(() => {
if (!editor) return;
onSpoilerToggle?.(toggleSpoilerFormatting(editor));
}, [editor, onSpoilerToggle]);
return (
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
<SelectionFormattingTray
@@ -133,7 +88,7 @@ export const MessageComposerToolbar = React.memo(
exit={{ x: 8, opacity: 0 }}
transition={presenceSpring}
>
<Tooltip>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Button
aria-label="Toggle formatting"
@@ -158,7 +113,7 @@ export const MessageComposerToolbar = React.memo(
exit={{ opacity: 0, scale: 0.95 }}
transition={{ ...presenceSpring, delay: 0.15 }}
>
<Tooltip>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Button
aria-label="Close formatting"
@@ -203,7 +158,8 @@ export const MessageComposerToolbar = React.memo(
exit={{ opacity: 0, x: -12 }}
transition={presenceSpring}
>
<Tooltip>
{/* disableHoverableContent keeps tooltips from lingering over the editor. */}
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Button
aria-label="Mention someone"
@@ -220,7 +176,7 @@ export const MessageComposerToolbar = React.memo(
</TooltipTrigger>
<TooltipContent>Mention someone</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Button
aria-label="Attach image"
@@ -244,34 +200,13 @@ export const MessageComposerToolbar = React.memo(
onTriggerMouseDown={onCaptureSelection}
open={isEmojiPickerOpen}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Spoiler"
aria-pressed={isSpoilerActive}
className={cn(
isSpoilerActive &&
"bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground",
)}
disabled={composerDisabled || !editor || isUploading}
onClick={handleSpoilerClick}
onMouseDown={onCaptureSelection}
size="icon"
type="button"
variant={isSpoilerActive ? "default" : "ghost"}
>
<HatGlasses />
</Button>
</TooltipTrigger>
<TooltipContent>Spoiler</TooltipContent>
</Tooltip>
<motion.div
initial={{ x: -8, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -8, opacity: 0 }}
transition={presenceSpring}
>
<Tooltip>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Button
aria-label="Toggle formatting"
+16
View File
@@ -0,0 +1,16 @@
import { invokeTauri } from "./tauri";
/**
* Fetch relay media bytes over IPC (Rust reqwest, WARP-tunneled).
*
* Used by the composer image editor: wrapping the bytes in a same-origin
* `blob:` URL gives the canvas pixel access without CORS, so the media
* proxy needs no special headers. The Rust side enforces the same URL
* validation and size cap as the download commands.
*/
export async function fetchMediaBytes(
url: string,
): Promise<Uint8Array<ArrayBuffer>> {
const bytes = await invokeTauri<number[]>("fetch_media_bytes", { url });
return new Uint8Array(bytes);
}
+7
View File
@@ -8602,6 +8602,13 @@ export function maybeInstallE2eTauriMocks() {
return await resolveMockUploadDescriptors(activeConfig);
case "upload_media_bytes":
return (await resolveMockUploadDescriptors(activeConfig))[0];
case "fetch_media_bytes": {
// The real command fetches relay media through Rust reqwest. In E2E
// the browser fetch suffices — specs serve the URL via page.route.
const response = await fetch((payload as { url: string }).url);
if (!response.ok) throw new Error(`fetch failed: ${response.status}`);
return Array.from(new Uint8Array(await response.arrayBuffer()));
}
case "download_image":
case "download_file":
// The save dialog can't run headlessly; report a successful save so the
@@ -0,0 +1,189 @@
import { expect, type Page, test } from "@playwright/test";
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 ORIGINAL_DESCRIPTOR = {
url: ORIGINAL_URL,
sha256: ORIGINAL_SHA,
size: 1234,
type: "image/svg+xml",
uploaded: Math.floor(Date.now() / 1000),
dim: "320x200",
filename: "draw-original.svg",
};
const EDITED_DESCRIPTOR = {
url: EDITED_URL,
sha256: EDITED_SHA,
size: 2345,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "320x200",
filename: "draw-original.png",
};
/**
* Serve deterministic same-size SVGs for both attachment URLs. These back
* the display <img> loads and the mock bridge's `fetch_media_bytes`
* handler (the editor exports via IPC bytes + blob: URL, so no CORS
* headers are needed). The CORS header is required only because the mock
* bridge's in-page `fetch()` of this cross-origin URL is CORS-mode
* production fetches the bytes in Rust instead.
*/
async function installImageRoutes(page: Page) {
await page.route("https://example.com/e2e/draw-*.svg*", (route) => {
const fill = route.request().url().includes("edited")
? "#b3574a"
: "#4aa3df";
route.fulfill({
body: `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200" viewBox="0 0 320 200"><rect width="100%" height="100%" fill="${fill}"/></svg>`,
contentType: "image/svg+xml",
headers: { "access-control-allow-origin": "*" },
});
});
}
async function drawStrokeOnCanvas(page: Page) {
const canvas = page.getByTestId("composer-image-editor-canvas");
await expect(canvas).toBeVisible();
const box = await canvas.boundingBox();
if (!box) throw new Error("Expected drawing canvas to have a layout box");
const centerY = box.y + box.height / 2;
await page.mouse.move(box.x + box.width * 0.25, centerY);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.75, centerY, { steps: 8 });
await page.mouse.up();
}
test.beforeEach(async ({ page }) => {
await installImageRoutes(page);
await installMockBridge(page, {
uploadDescriptors: [ORIGINAL_DESCRIPTOR],
});
});
test("draw on an uploaded image, save replaces it, revert restores in place", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Attach the original image via the mocked paperclip flow.
await page.getByRole("button", { name: "Attach image" }).click();
const composer = page.getByTestId("message-composer");
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
// Open the composer lightbox.
await composer.getByAltText("Attachment aaaa").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src="${ORIGINAL_URL}"]`)).toBeVisible();
// No revert affordance before any edit.
await expect(page.getByTestId("composer-attachment-revert")).toHaveCount(0);
// Enter canvas mode; Escape leaves canvas mode but keeps the dialog open.
await page.getByTestId("composer-attachment-edit").click();
await expect(page.getByTestId("composer-image-editor-canvas")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("composer-image-editor-canvas")).toHaveCount(0);
await expect(dialog).toBeVisible();
// Re-enter canvas mode and draw a stroke.
await page.getByTestId("composer-attachment-edit").click();
const saveButton = page.getByTestId("composer-image-editor-save");
await expect(saveButton).toBeDisabled();
await drawStrokeOnCanvas(page);
await expect(saveButton).toBeEnabled();
// The next mocked upload returns the annotated descriptor.
await page.evaluate((edited) => {
window.__BUZZ_E2E__ = {
...window.__BUZZ_E2E__,
mock: {
...window.__BUZZ_E2E__?.mock,
uploadDescriptors: [edited],
},
};
}, EDITED_DESCRIPTOR);
await saveButton.click();
// Saving closes the lightbox; the composer thumbnail now shows the
// annotated image.
await expect(dialog).toHaveCount(0);
await expect(composer.getByAltText("Attachment bbbb")).toBeVisible();
// The annotated PNG went through the real upload command.
const uploadCommandCount = await page.evaluate(
() =>
(
window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }
).__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "upload_media_bytes",
).length ?? 0,
);
expect(uploadCommandCount).toBe(1);
// Reopen the lightbox on the annotated attachment to revert.
await composer.getByAltText("Attachment bbbb").click();
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src="${EDITED_URL}"]`)).toBeVisible();
// Revert swaps back to the original without closing the dialog.
await page.getByTestId("composer-attachment-revert").click();
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src="${ORIGINAL_URL}"]`)).toBeVisible();
await expect(page.getByTestId("composer-attachment-revert")).toHaveCount(0);
// Closing the dialog shows the (restored) original thumbnail.
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
});
test("spoiler marking survives drawing on the attachment", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByRole("button", { name: "Attach image" }).click();
const composer = page.getByTestId("message-composer");
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
// 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 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 page.getByTestId("composer-attachment-edit").click();
await drawStrokeOnCanvas(page);
await page.evaluate((edited) => {
window.__BUZZ_E2E__ = {
...window.__BUZZ_E2E__,
mock: {
...window.__BUZZ_E2E__?.mock,
uploadDescriptors: [edited],
},
};
}, EDITED_DESCRIPTOR);
await page.getByTestId("composer-image-editor-save").click();
// Saving closes the lightbox.
await expect(page.getByRole("dialog")).toHaveCount(0);
// The annotated replacement is still marked as a spoiler.
await expect(composer.getByAltText("Attachment bbbb")).toBeVisible();
await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible();
});
@@ -0,0 +1,82 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
/**
* Composer tooltips set disableHoverableContent, so they must dismiss the
* instant the cursor leaves the trigger including when it slides onto the
* tooltip popup itself (Radix's default hoverable-content behavior would
* keep it open, camping it over the message editor).
*/
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
/** Hover the trigger, then slide the cursor onto the tooltip popup and
* assert the tooltip dismisses instead of persisting. */
async function expectTooltipDismissesOnLeave(
page: import("@playwright/test").Page,
trigger: import("@playwright/test").Locator,
tooltipName: string,
) {
await trigger.hover();
const tip = page.getByRole("tooltip", { name: tooltipName });
await expect(tip).toBeVisible();
// Slide off the trigger onto the tooltip popup.
const box = await tip.boundingBox();
if (!box) throw new Error("no tooltip box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
steps: 5,
});
await expect(tip).toBeHidden();
}
test("composer toolbar tooltip dismisses when cursor leaves the trigger", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expectTooltipDismissesOnLeave(
page,
page.getByTestId("message-insert-mention"),
"Mention someone",
);
});
test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Open the formatting sub-toolbar (Bold / Italic / lists / Quote …).
await page.getByRole("button", { name: "Toggle formatting" }).first().click();
const bold = page.getByRole("button", { name: "Bold" });
await expect(bold).toBeVisible();
// Tooltip text is "<label> (<shortcut>)" for items that carry a shortcut.
await expectTooltipDismissesOnLeave(page, bold, "Bold (⌘B)");
});
test("emoji picker tooltip dismisses when cursor leaves the trigger", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// The emoji button wraps its tooltip around a nested PopoverTrigger, so
// cover it separately from the plain toolbar buttons.
await expectTooltipDismissesOnLeave(
page,
page.getByTestId("composer-emoji-button"),
"Insert emoji",
);
});
+22 -6
View File
@@ -71,6 +71,7 @@ test("no-selection spoiler applies to every composer paragraph", async ({
await page.keyboard.press("ControlOrMeta+V");
await expect(input.locator("p")).toHaveCount(paragraphs.length);
await page.getByRole("button", { name: "Toggle formatting" }).click();
await page.getByRole("button", { name: "Spoiler", exact: true }).click();
await expect
@@ -100,7 +101,10 @@ test("image attachments can be marked and sent as hidden spoilers", async ({
const composer = page.getByTestId("message-composer");
await expect(composer.getByAltText("Attachment cccc")).toBeVisible();
await page.getByRole("button", { name: "Spoiler", exact: true }).click();
// Media spoilers are toggled per-attachment from the lightbox.
await composer.getByAltText("Attachment cccc").click();
await page.getByTestId("composer-attachment-spoiler").click();
await page.keyboard.press("Escape");
await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible();
await page.getByTestId("send-message").click();
@@ -119,7 +123,7 @@ test("image attachments can be marked and sent as hidden spoilers", async ({
await expect(page.getByRole("dialog", { name: "image" })).toHaveCount(0);
});
test("spoiler button is disabled while attachment upload is pending", async ({
test("text spoiler stays usable while attachment upload is pending", async ({
page,
}) => {
await installSpoilerBridge(page, { uploadDelayMs: 1_000 });
@@ -127,19 +131,31 @@ test("spoiler button is disabled while attachment upload is pending", async ({
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.click();
await page.keyboard.type("pending secret");
// Kick off the (delayed) upload first — the attach button lives in the
// passive toolbar, which is replaced while formatting is expanded.
await page.getByRole("button", { name: "Attach image" }).click();
await page.getByRole("button", { name: "Toggle formatting" }).click();
const spoilerButton = page.getByRole("button", {
name: "Spoiler",
exact: true,
});
// Text spoilers are independent of media uploads, so the button stays
// enabled and works while the upload is still in flight.
await expect(spoilerButton).toBeEnabled();
await spoilerButton.click();
await expect(input.locator(".buzz-spoiler[data-spoiler]")).toContainText(
"pending secret",
);
await page.getByRole("button", { name: "Attach image" }).click();
await expect(spoilerButton).toBeDisabled({ timeout: 500 });
await expect(
page.getByTestId("message-composer").getByAltText("Attachment cccc"),
).toBeVisible();
await expect(spoilerButton).toBeEnabled();
});
test("hidden spoiler links reveal without opening on the first click", async ({