diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 3b5f35d990..98e63a8d4c 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -38,8 +38,10 @@ export default defineConfig({ "**/observer-feed-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", diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index c9ce4494ea..760a133c22 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -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, 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. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 93424cd0ff..039e9d4eea 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -513,6 +513,7 @@ pub fn run() { upload_media_bytes, download_image, download_file, + fetch_media_bytes, list_relay_members, get_my_relay_membership, add_relay_member, diff --git a/desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx b/desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx index abdf0f38e1..3a245bdfce 100644 --- a/desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx +++ b/desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx @@ -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, | "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} /> diff --git a/desktop/src/features/messages/lib/useAttachmentEditing.ts b/desktop/src/features/messages/lib/useAttachmentEditing.ts new file mode 100644 index 0000000000..084ef39c4c --- /dev/null +++ b/desktop/src/features/messages/lib/useAttachmentEditing.ts @@ -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>>; + 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 }; +} diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index 994e0fa0ce..06be653e6a 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -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 + >(() => 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(); + 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(); + 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 => { + 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, diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index e40ede5730..7e1fc2c8f5 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -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; 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; + onToggleSpoiler?: (url: string) => void; spoileredUrls?: ReadonlySet; }; +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; + 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 ( + +
+ + +
+ {isVideo ? ( +
+ {videoPosterUrl ? ( + {`Video + ) : ( +
+ )} +
+
+ +
+
+ ) : ( + {`Attachment + )} + {isSpoilered ? ( +
+ +
+ ) : null} +
+ + + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onEscapeKeyDown={handleEscapeKeyDown} + > + + Attachment {hash} preview + + + Full-size attachment preview. Press Escape or click outside to + close. + + {mode === "view" ? ( + + ) : null} + {mode === "edit" && !isVideo ? ( + + ) : isVideo ? ( + // biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available + + + + + + + + Remove attachment + +
+ + ); +}); + /** * 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" > -
+
{label}
- + - - Remove attachment - - + ); })} {isUploading && @@ -210,7 +491,7 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
{onCancelUpload && preview.id >= 0 ? ( - + - - {isVideo ? ( - // biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available - -
- ); -} diff --git a/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx b/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx index a5a87eda05..da23c396e8 100644 --- a/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx +++ b/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx @@ -27,7 +27,7 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({ }: ComposerEmojiPickerProps) { return ( - + + ))} + + + + + + + Undo (⌘Z) + + + + + + Redo (⇧⌘Z) + + + + + + + + {saveError ? ( +

+ {saveError} +

+ ) : null} + + ); +} diff --git a/desktop/src/features/messages/ui/FormattingToolbar.tsx b/desktop/src/features/messages/ui/FormattingToolbar.tsx index 63d41145e6..a605b3dcd9 100644 --- a/desktop/src/features/messages/ui/FormattingToolbar.tsx +++ b/desktop/src/features/messages/ui/FormattingToolbar.tsx @@ -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 (
{items.map((item) => ( - +
@@ -956,9 +944,7 @@ function MessageComposerImpl({ onLinkButton={linkEditor.openFromToolbar} onOpenMentionPicker={openMentionPicker} onPaperclip={handlePaperclipClick} - onSpoilerToggle={handleComposerSpoilerToggle} sendDisabled={sendDisabled} - spoilerActive={spoileredAttachmentUrls.size > 0} /> diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index 6da440c11a..1c8087e992 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -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 (
- + - - Spoiler - - +