From 509562e6daa2f8c9a8dbda5f696fb3bc1b670a14 Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 27 Aug 2026 13:26:18 +0200 Subject: [PATCH] feat(app): delete attachments from the media viewer (#2039) --- .../embeds/attachments/AttachmentGridItem.tsx | 3 ++- .../components/embeds/media/EmbedGifv.tsx | 7 +++++- .../components/embeds/media/EmbedImage.tsx | 8 +++++++ .../components/embeds/media/EmbedVideo.tsx | 11 +++++++-- .../components/modals/MediaModal.tsx | 2 ++ .../components/modals/MediaViewerModal.tsx | 22 +++++++++++++++++- .../modals/media_modal/MediaControls.tsx | 23 +++++++++++++++++-- .../components/modals/media_modal/shared.ts | 3 ++- .../messaging/hooks/useDeleteAttachment.tsx | 18 ++++++++++++--- .../ui/commands/MediaViewerCommands.ts | 2 ++ .../src/features/ui/state/MediaViewer.ts | 4 ++++ 11 files changed, 92 insertions(+), 11 deletions(-) diff --git a/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx b/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx index 6bea5469b..ff39dc44a 100644 --- a/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx +++ b/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx @@ -208,9 +208,10 @@ export const AttachmentGridItem: FC = observer( messageId: message?.id, message, sourceChannel: messageViewContext?.channel, + allowAttachmentDelete: !isPreview && snapshotIndex === undefined, }); }, - [attachment, message, messageViewContext?.channel, mediaAttachments, shouldBlur], + [attachment, message, messageViewContext?.channel, mediaAttachments, shouldBlur, isPreview, snapshotIndex], ); const openInBrowser = useOpenInBrowserOnMiddleClick(attachment.url ?? attachment.proxy_url ?? '', !shouldBlur); const handleFavoriteClick = useCallback( diff --git a/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx b/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx index 0cf119184..e60677ad6 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx +++ b/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx @@ -181,6 +181,7 @@ const useImagePreview = ({ message, sourceChannel, providerName, + allowAttachmentDelete = false, }: { proxyUrl: string; embedUrl: string; @@ -195,6 +196,7 @@ const useImagePreview = ({ message?: Message; sourceChannel?: Channel | null; providerName?: string; + allowAttachmentDelete?: boolean; }): {viewerItem: MediaViewerItem; openPreview: (event: React.MouseEvent | React.KeyboardEvent) => void} => { const viewerItem = useMemo( () => ({ @@ -223,9 +225,10 @@ const useImagePreview = ({ messageId, message, sourceChannel, + allowAttachmentDelete, }); }, - [viewerItem, channelId, messageId, message, sourceChannel], + [viewerItem, channelId, messageId, message, sourceChannel, allowAttachmentDelete], ); return {viewerItem, openPreview}; }; @@ -461,6 +464,7 @@ export const EmbedGifv: FC< message, sourceChannel: messageViewContext?.channel, providerName, + allowAttachmentDelete: !isPreview && snapshotIndex === undefined, }); const handleDeleteClick = useDeleteAttachment(message, attachmentId); const handleDownloadClick = useCallback( @@ -795,6 +799,7 @@ export const EmbedGif: FC< contentHash, message, sourceChannel: messageViewContext?.channel, + allowAttachmentDelete: !isPreview && snapshotIndex === undefined, }); const {scheduleViewerWarm, cancelViewerWarm} = useMediaViewerHoverWarm(viewerItem, { allowAnimated: gifAutoPlay, diff --git a/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.tsx b/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.tsx index ccaa6fbe0..4bcc28fb0 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.tsx +++ b/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.tsx @@ -104,6 +104,7 @@ interface ImagePreviewHandlerProps { message?: Message; animated?: boolean; mediaAttachments?: ReadonlyArray; + allowAttachmentDelete?: boolean; onViewerWarmEnter?: () => void; onViewerWarmLeave?: () => void; children: React.ReactNode; @@ -143,6 +144,7 @@ const ImagePreviewHandler: FC = observer( message, animated, mediaAttachments = NO_MEDIA_ATTACHMENTS, + allowAttachmentDelete = false, onViewerWarmEnter, onViewerWarmLeave, children, @@ -176,6 +178,7 @@ const ImagePreviewHandler: FC = observer( messageId, message, sourceChannel: messageViewContext?.channel, + allowAttachmentDelete, }); } else { MediaViewerCommands.openMediaViewer( @@ -187,6 +190,7 @@ const ImagePreviewHandler: FC = observer( naturalHeight, type: 'image' as const, contentHash, + attachmentId, embedIndex, expiresAt: undefined, expired: undefined, @@ -199,6 +203,7 @@ const ImagePreviewHandler: FC = observer( messageId, message, sourceChannel: messageViewContext?.channel, + allowAttachmentDelete, }, ); } @@ -217,6 +222,7 @@ const ImagePreviewHandler: FC = observer( message, messageViewContext?.channel, mediaAttachments, + allowAttachmentDelete, ], ); const openInBrowser = useOpenInBrowserOnMiddleClick(originalSrc || src); @@ -381,6 +387,7 @@ export const EmbedImage: FC = observer( [originalSrc, src], ); const handleDeleteClick = useDeleteAttachment(message, attachmentId); + const allowAttachmentDelete = !isPreview && snapshotIndex === undefined; const [mediaSheetOpen, setMediaSheetOpen] = useState(false); const handleContextMenu = useCallback( (e: React.MouseEvent) => { @@ -524,6 +531,7 @@ export const EmbedImage: FC = observer( attachmentId={attachmentId} message={message} mediaAttachments={mediaAttachments} + allowAttachmentDelete={allowAttachmentDelete} animated={animated} onViewerWarmEnter={scheduleViewerWarm} onViewerWarmLeave={cancelViewerWarm} diff --git a/fluxer_app/src/features/channel/components/embeds/media/EmbedVideo.tsx b/fluxer_app/src/features/channel/components/embeds/media/EmbedVideo.tsx index 16b41a3c2..de6b213a7 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/EmbedVideo.tsx +++ b/fluxer_app/src/features/channel/components/embeds/media/EmbedVideo.tsx @@ -272,6 +272,7 @@ const EmbedVideo: FC = observer( [src], ); const handleDeleteClick = useDeleteAttachment(message, attachmentId); + const allowAttachmentDelete = !isPreview && snapshotIndex === undefined; const handleContextMenu = useCallback( (e: React.MouseEvent) => { if (!message) return; @@ -396,6 +397,7 @@ const EmbedVideo: FC = observer( messageId, message, sourceChannel: messageViewContext?.channel, + allowAttachmentDelete, }); } else { MediaViewerCommands.openMediaViewer( @@ -407,12 +409,13 @@ const EmbedVideo: FC = observer( naturalHeight: viewerVideoDimensions.height, type: 'video' as const, contentHash, + attachmentId, embedIndex, duration, }, ], 0, - {channelId, messageId, message, sourceChannel: messageViewContext?.channel}, + {channelId, messageId, message, sourceChannel: messageViewContext?.channel, allowAttachmentDelete}, ); } }, [ @@ -420,6 +423,7 @@ const EmbedVideo: FC = observer( messageId, message, messageViewContext?.channel, + allowAttachmentDelete, mediaAttachments, attachmentId, effectiveSrc, @@ -451,6 +455,7 @@ const EmbedVideo: FC = observer( messageId, message, sourceChannel: messageViewContext?.channel, + allowAttachmentDelete, }); } else { MediaViewerCommands.openMediaViewer( @@ -462,13 +467,14 @@ const EmbedVideo: FC = observer( naturalHeight: viewerVideoDimensions.height, type: 'video' as const, contentHash, + attachmentId, embedIndex, duration, initialTime: currentTime, }, ], 0, - {channelId, messageId, message, sourceChannel: messageViewContext?.channel}, + {channelId, messageId, message, sourceChannel: messageViewContext?.channel, allowAttachmentDelete}, ); } setIsPlayingInline(false); @@ -477,6 +483,7 @@ const EmbedVideo: FC = observer( messageId, message, messageViewContext?.channel, + allowAttachmentDelete, mediaAttachments, attachmentId, effectiveSrc, diff --git a/fluxer_app/src/features/messaging/components/modals/MediaModal.tsx b/fluxer_app/src/features/messaging/components/modals/MediaModal.tsx index 36288b5a1..a5fabc990 100644 --- a/fluxer_app/src/features/messaging/components/modals/MediaModal.tsx +++ b/fluxer_app/src/features/messaging/components/modals/MediaModal.tsx @@ -95,6 +95,7 @@ export const MediaModal: FC = observer( onOpenInBrowser, onCopyLink, onCopyMedia, + onDeleteAttachment, onReply, onForward, children, @@ -636,6 +637,7 @@ export const MediaModal: FC = observer( onOpenInBrowser={onOpenInBrowser} onCopyLink={onCopyLink} onCopyMedia={onCopyMedia} + onDeleteAttachment={onDeleteAttachment} onReset={handleResetMedia} onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} diff --git a/fluxer_app/src/features/messaging/components/modals/MediaViewerModal.tsx b/fluxer_app/src/features/messaging/components/modals/MediaViewerModal.tsx index 785a1af51..182681912 100644 --- a/fluxer_app/src/features/messaging/components/modals/MediaViewerModal.tsx +++ b/fluxer_app/src/features/messaging/components/modals/MediaViewerModal.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import Accessibility from '@app/features/accessibility/state/Accessibility'; import {useBottomSheetBackHandler} from '@app/features/app/hooks/useBottomSheetBackHandler'; import {deriveDefaultNameFromMessage} from '@app/features/channel/components/embeds/EmbedUtils'; import {useMessageActionMenuData} from '@app/features/channel/components/MessageActionMenu'; @@ -14,6 +15,7 @@ import type {ForwardModalSuccess} from '@app/features/messaging/components/modal import {MediaModal} from '@app/features/messaging/components/modals/MediaModal'; import styles from '@app/features/messaging/components/modals/MediaViewerModal.module.css'; import {getMediaViewerPortalRoot} from '@app/features/messaging/components/modals/MediaViewerPortal'; +import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment'; import {useMediaFavorite} from '@app/features/messaging/hooks/useMediaFavorite'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; import {formatAttachmentDate} from '@app/features/messaging/utils/AttachmentExpiryUtils'; @@ -273,7 +275,8 @@ const MobileMediaOptionsSheet: FC = observer(funct }); const MediaViewerModalComponent: FC = observer(() => { const {i18n} = useLingui(); - const {isOpen, items, currentIndex, channelId, messageId, message, sourceChannel} = MediaViewer; + const {isOpen, items, currentIndex, channelId, messageId, message, sourceChannel, allowAttachmentDelete} = + MediaViewer; const {enabled: isMobile} = MobileLayout; const [isMediaMenuOpen, setIsMediaMenuOpen] = useState(false); const currentItem = items[currentIndex]; @@ -438,6 +441,22 @@ const MediaViewerModalComponent: FC = observer(() => { () => (message ? untracked(() => getMessagePermissions(message, sourceChannel)) : null), [message, sourceChannel], ); + const deletableAttachmentId = + Accessibility.showMediaDeleteButton && + allowAttachmentDelete && + permissions?.canDeleteAttachment && + currentItem?.attachmentId && + message?.attachments.some((attachment) => attachment.id === currentItem.attachmentId) + ? currentItem.attachmentId + : undefined; + const handleAttachmentDeleted = useCallback(() => { + if (MediaViewer.isOpen && MediaViewer.items === items) { + MediaViewerCommands.closeMediaViewer(); + } + }, [items]); + const handleDeleteAttachment = useDeleteAttachment(message, deletableAttachmentId, { + onDeleted: handleAttachmentDeleted, + }); const forwardMediaSelection = useMemo(() => { if (!currentItem) return undefined; if (currentItem.attachmentId) { @@ -672,6 +691,7 @@ const MediaViewerModalComponent: FC = observer(() => { onOpenInBrowser={handleOpenInBrowser} onCopyLink={handleCopyLink} onCopyMedia={handleCopyMedia} + onDeleteAttachment={deletableAttachmentId ? handleDeleteAttachment : undefined} onReply={permissions?.canSendMessages ? handleReply : undefined} onForward={canForwardCurrentMedia ? handleForward : undefined} enablePanZoom={currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv'} diff --git a/fluxer_app/src/features/messaging/components/modals/media_modal/MediaControls.tsx b/fluxer_app/src/features/messaging/components/modals/media_modal/MediaControls.tsx index dd7f0eea8..bdfa5d081 100644 --- a/fluxer_app/src/features/messaging/components/modals/media_modal/MediaControls.tsx +++ b/fluxer_app/src/features/messaging/components/modals/media_modal/MediaControls.tsx @@ -3,6 +3,7 @@ import { ADD_TO_FAVORITES_DESCRIPTOR, COPY_LINK_DESCRIPTOR, + DELETE_ATTACHMENT_DESCRIPTOR, REMOVE_FROM_FAVORITES_DESCRIPTOR, ZOOM_IN_DESCRIPTOR, ZOOM_OUT_DESCRIPTOR, @@ -36,16 +37,17 @@ import { MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon, StarIcon, + TrashIcon, XIcon, } from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; -import {type FC, forwardRef, type ReactNode, type Ref} from 'react'; +import {type FC, forwardRef, type MouseEventHandler, type ReactNode, type Ref} from 'react'; interface ControlButtonProps { icon: ReactNode; label: string; - onClick: () => void; + onClick: MouseEventHandler; variant?: 'default' | 'primary' | 'danger'; active?: boolean; disabled?: boolean; @@ -119,6 +121,7 @@ interface MediaOverlayActionsProps { onOpenInBrowser?: () => void; onCopyLink?: () => void; onCopyMedia?: () => void; + onDeleteAttachment?: MouseEventHandler; onReset?: () => void; onZoomIn?: () => void; onZoomOut?: () => void; @@ -144,6 +147,7 @@ export const MediaOverlayActions: FC = observer( onOpenInBrowser, onCopyLink, onCopyMedia, + onDeleteAttachment, onReset, onZoomIn, onZoomOut, @@ -250,6 +254,21 @@ export const MediaOverlayActions: FC = observer( data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button.download" /> )} + {onDeleteAttachment && ( + + } + label={i18n._(DELETE_ATTACHMENT_DESCRIPTOR)} + onClick={onDeleteAttachment} + variant="danger" + data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button.delete-attachment" + /> + )}