feat(app): delete attachments from the media viewer (#2039)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-27 13:26:18 +02:00
1 parent 88d85919f1
commit 509562e6da
11 files changed
+92 -11

No files matched your search

@@ -208,9 +208,10 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
messageId: message?.id, messageId: message?.id,
message, message,
sourceChannel: messageViewContext?.channel, 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 openInBrowser = useOpenInBrowserOnMiddleClick(attachment.url ?? attachment.proxy_url ?? '', !shouldBlur);
const handleFavoriteClick = useCallback( const handleFavoriteClick = useCallback(
@@ -181,6 +181,7 @@ const useImagePreview = ({
message, message,
sourceChannel, sourceChannel,
providerName, providerName,
allowAttachmentDelete = false,
}: { }: {
proxyUrl: string; proxyUrl: string;
embedUrl: string; embedUrl: string;
@@ -195,6 +196,7 @@ const useImagePreview = ({
message?: Message; message?: Message;
sourceChannel?: Channel | null; sourceChannel?: Channel | null;
providerName?: string; providerName?: string;
allowAttachmentDelete?: boolean;
}): {viewerItem: MediaViewerItem; openPreview: (event: React.MouseEvent | React.KeyboardEvent) => void} => { }): {viewerItem: MediaViewerItem; openPreview: (event: React.MouseEvent | React.KeyboardEvent) => void} => {
const viewerItem = useMemo<MediaViewerItem>( const viewerItem = useMemo<MediaViewerItem>(
() => ({ () => ({
@@ -223,9 +225,10 @@ const useImagePreview = ({
messageId, messageId,
message, message,
sourceChannel, sourceChannel,
allowAttachmentDelete,
}); });
}, },
[viewerItem, channelId, messageId, message, sourceChannel], [viewerItem, channelId, messageId, message, sourceChannel, allowAttachmentDelete],
); );
return {viewerItem, openPreview}; return {viewerItem, openPreview};
}; };
@@ -461,6 +464,7 @@ export const EmbedGifv: FC<
message, message,
sourceChannel: messageViewContext?.channel, sourceChannel: messageViewContext?.channel,
providerName, providerName,
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
}); });
const handleDeleteClick = useDeleteAttachment(message, attachmentId); const handleDeleteClick = useDeleteAttachment(message, attachmentId);
const handleDownloadClick = useCallback( const handleDownloadClick = useCallback(
@@ -795,6 +799,7 @@ export const EmbedGif: FC<
contentHash, contentHash,
message, message,
sourceChannel: messageViewContext?.channel, sourceChannel: messageViewContext?.channel,
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
}); });
const {scheduleViewerWarm, cancelViewerWarm} = useMediaViewerHoverWarm(viewerItem, { const {scheduleViewerWarm, cancelViewerWarm} = useMediaViewerHoverWarm(viewerItem, {
allowAnimated: gifAutoPlay, allowAnimated: gifAutoPlay,
@@ -104,6 +104,7 @@ interface ImagePreviewHandlerProps {
message?: Message; message?: Message;
animated?: boolean; animated?: boolean;
mediaAttachments?: ReadonlyArray<MessageAttachment>; mediaAttachments?: ReadonlyArray<MessageAttachment>;
allowAttachmentDelete?: boolean;
onViewerWarmEnter?: () => void; onViewerWarmEnter?: () => void;
onViewerWarmLeave?: () => void; onViewerWarmLeave?: () => void;
children: React.ReactNode; children: React.ReactNode;
@@ -143,6 +144,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
message, message,
animated, animated,
mediaAttachments = NO_MEDIA_ATTACHMENTS, mediaAttachments = NO_MEDIA_ATTACHMENTS,
allowAttachmentDelete = false,
onViewerWarmEnter, onViewerWarmEnter,
onViewerWarmLeave, onViewerWarmLeave,
children, children,
@@ -176,6 +178,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
messageId, messageId,
message, message,
sourceChannel: messageViewContext?.channel, sourceChannel: messageViewContext?.channel,
allowAttachmentDelete,
}); });
} else { } else {
MediaViewerCommands.openMediaViewer( MediaViewerCommands.openMediaViewer(
@@ -187,6 +190,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
naturalHeight, naturalHeight,
type: 'image' as const, type: 'image' as const,
contentHash, contentHash,
attachmentId,
embedIndex, embedIndex,
expiresAt: undefined, expiresAt: undefined,
expired: undefined, expired: undefined,
@@ -199,6 +203,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
messageId, messageId,
message, message,
sourceChannel: messageViewContext?.channel, sourceChannel: messageViewContext?.channel,
allowAttachmentDelete,
}, },
); );
} }
@@ -217,6 +222,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
message, message,
messageViewContext?.channel, messageViewContext?.channel,
mediaAttachments, mediaAttachments,
allowAttachmentDelete,
], ],
); );
const openInBrowser = useOpenInBrowserOnMiddleClick(originalSrc || src); const openInBrowser = useOpenInBrowserOnMiddleClick(originalSrc || src);
@@ -381,6 +387,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
[originalSrc, src], [originalSrc, src],
); );
const handleDeleteClick = useDeleteAttachment(message, attachmentId); const handleDeleteClick = useDeleteAttachment(message, attachmentId);
const allowAttachmentDelete = !isPreview && snapshotIndex === undefined;
const [mediaSheetOpen, setMediaSheetOpen] = useState(false); const [mediaSheetOpen, setMediaSheetOpen] = useState(false);
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@@ -524,6 +531,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
attachmentId={attachmentId} attachmentId={attachmentId}
message={message} message={message}
mediaAttachments={mediaAttachments} mediaAttachments={mediaAttachments}
allowAttachmentDelete={allowAttachmentDelete}
animated={animated} animated={animated}
onViewerWarmEnter={scheduleViewerWarm} onViewerWarmEnter={scheduleViewerWarm}
onViewerWarmLeave={cancelViewerWarm} onViewerWarmLeave={cancelViewerWarm}
@@ -272,6 +272,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
[src], [src],
); );
const handleDeleteClick = useDeleteAttachment(message, attachmentId); const handleDeleteClick = useDeleteAttachment(message, attachmentId);
const allowAttachmentDelete = !isPreview && snapshotIndex === undefined;
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (!message) return; if (!message) return;
@@ -396,6 +397,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
messageId, messageId,
message, message,
sourceChannel: messageViewContext?.channel, sourceChannel: messageViewContext?.channel,
allowAttachmentDelete,
}); });
} else { } else {
MediaViewerCommands.openMediaViewer( MediaViewerCommands.openMediaViewer(
@@ -407,12 +409,13 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
naturalHeight: viewerVideoDimensions.height, naturalHeight: viewerVideoDimensions.height,
type: 'video' as const, type: 'video' as const,
contentHash, contentHash,
attachmentId,
embedIndex, embedIndex,
duration, duration,
}, },
], ],
0, 0,
{channelId, messageId, message, sourceChannel: messageViewContext?.channel}, {channelId, messageId, message, sourceChannel: messageViewContext?.channel, allowAttachmentDelete},
); );
} }
}, [ }, [
@@ -420,6 +423,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
messageId, messageId,
message, message,
messageViewContext?.channel, messageViewContext?.channel,
allowAttachmentDelete,
mediaAttachments, mediaAttachments,
attachmentId, attachmentId,
effectiveSrc, effectiveSrc,
@@ -451,6 +455,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
messageId, messageId,
message, message,
sourceChannel: messageViewContext?.channel, sourceChannel: messageViewContext?.channel,
allowAttachmentDelete,
}); });
} else { } else {
MediaViewerCommands.openMediaViewer( MediaViewerCommands.openMediaViewer(
@@ -462,13 +467,14 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
naturalHeight: viewerVideoDimensions.height, naturalHeight: viewerVideoDimensions.height,
type: 'video' as const, type: 'video' as const,
contentHash, contentHash,
attachmentId,
embedIndex, embedIndex,
duration, duration,
initialTime: currentTime, initialTime: currentTime,
}, },
], ],
0, 0,
{channelId, messageId, message, sourceChannel: messageViewContext?.channel}, {channelId, messageId, message, sourceChannel: messageViewContext?.channel, allowAttachmentDelete},
); );
} }
setIsPlayingInline(false); setIsPlayingInline(false);
@@ -477,6 +483,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
messageId, messageId,
message, message,
messageViewContext?.channel, messageViewContext?.channel,
allowAttachmentDelete,
mediaAttachments, mediaAttachments,
attachmentId, attachmentId,
effectiveSrc, effectiveSrc,
@@ -95,6 +95,7 @@ export const MediaModal: FC<MediaModalProps> = observer(
onOpenInBrowser, onOpenInBrowser,
onCopyLink, onCopyLink,
onCopyMedia, onCopyMedia,
onDeleteAttachment,
onReply, onReply,
onForward, onForward,
children, children,
@@ -636,6 +637,7 @@ export const MediaModal: FC<MediaModalProps> = observer(
onOpenInBrowser={onOpenInBrowser} onOpenInBrowser={onOpenInBrowser}
onCopyLink={onCopyLink} onCopyLink={onCopyLink}
onCopyMedia={onCopyMedia} onCopyMedia={onCopyMedia}
onDeleteAttachment={onDeleteAttachment}
onReset={handleResetMedia} onReset={handleResetMedia}
onZoomIn={handleZoomIn} onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut} onZoomOut={handleZoomOut}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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 {useBottomSheetBackHandler} from '@app/features/app/hooks/useBottomSheetBackHandler';
import {deriveDefaultNameFromMessage} from '@app/features/channel/components/embeds/EmbedUtils'; import {deriveDefaultNameFromMessage} from '@app/features/channel/components/embeds/EmbedUtils';
import {useMessageActionMenuData} from '@app/features/channel/components/MessageActionMenu'; 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 {MediaModal} from '@app/features/messaging/components/modals/MediaModal';
import styles from '@app/features/messaging/components/modals/MediaViewerModal.module.css'; import styles from '@app/features/messaging/components/modals/MediaViewerModal.module.css';
import {getMediaViewerPortalRoot} from '@app/features/messaging/components/modals/MediaViewerPortal'; 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 {useMediaFavorite} from '@app/features/messaging/hooks/useMediaFavorite';
import type {Message} from '@app/features/messaging/models/MessagingMessage'; import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {formatAttachmentDate} from '@app/features/messaging/utils/AttachmentExpiryUtils'; import {formatAttachmentDate} from '@app/features/messaging/utils/AttachmentExpiryUtils';
@@ -273,7 +275,8 @@ const MobileMediaOptionsSheet: FC<MobileMediaOptionsSheetProps> = observer(funct
}); });
const MediaViewerModalComponent: FC = observer(() => { const MediaViewerModalComponent: FC = observer(() => {
const {i18n} = useLingui(); 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 {enabled: isMobile} = MobileLayout;
const [isMediaMenuOpen, setIsMediaMenuOpen] = useState(false); const [isMediaMenuOpen, setIsMediaMenuOpen] = useState(false);
const currentItem = items[currentIndex]; const currentItem = items[currentIndex];
@@ -438,6 +441,22 @@ const MediaViewerModalComponent: FC = observer(() => {
() => (message ? untracked(() => getMessagePermissions(message, sourceChannel)) : null), () => (message ? untracked(() => getMessagePermissions(message, sourceChannel)) : null),
[message, sourceChannel], [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<MessageCommands.ForwardMediaSelection | undefined>(() => { const forwardMediaSelection = useMemo<MessageCommands.ForwardMediaSelection | undefined>(() => {
if (!currentItem) return undefined; if (!currentItem) return undefined;
if (currentItem.attachmentId) { if (currentItem.attachmentId) {
@@ -672,6 +691,7 @@ const MediaViewerModalComponent: FC = observer(() => {
onOpenInBrowser={handleOpenInBrowser} onOpenInBrowser={handleOpenInBrowser}
onCopyLink={handleCopyLink} onCopyLink={handleCopyLink}
onCopyMedia={handleCopyMedia} onCopyMedia={handleCopyMedia}
onDeleteAttachment={deletableAttachmentId ? handleDeleteAttachment : undefined}
onReply={permissions?.canSendMessages ? handleReply : undefined} onReply={permissions?.canSendMessages ? handleReply : undefined}
onForward={canForwardCurrentMedia ? handleForward : undefined} onForward={canForwardCurrentMedia ? handleForward : undefined}
enablePanZoom={currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv'} enablePanZoom={currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv'}
@@ -3,6 +3,7 @@
import { import {
ADD_TO_FAVORITES_DESCRIPTOR, ADD_TO_FAVORITES_DESCRIPTOR,
COPY_LINK_DESCRIPTOR, COPY_LINK_DESCRIPTOR,
DELETE_ATTACHMENT_DESCRIPTOR,
REMOVE_FROM_FAVORITES_DESCRIPTOR, REMOVE_FROM_FAVORITES_DESCRIPTOR,
ZOOM_IN_DESCRIPTOR, ZOOM_IN_DESCRIPTOR,
ZOOM_OUT_DESCRIPTOR, ZOOM_OUT_DESCRIPTOR,
@@ -36,16 +37,17 @@ import {
MagnifyingGlassMinusIcon, MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon, MagnifyingGlassPlusIcon,
StarIcon, StarIcon,
TrashIcon,
XIcon, XIcon,
} from '@phosphor-icons/react'; } from '@phosphor-icons/react';
import {clsx} from 'clsx'; import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite'; 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 { interface ControlButtonProps {
icon: ReactNode; icon: ReactNode;
label: string; label: string;
onClick: () => void; onClick: MouseEventHandler<HTMLButtonElement>;
variant?: 'default' | 'primary' | 'danger'; variant?: 'default' | 'primary' | 'danger';
active?: boolean; active?: boolean;
disabled?: boolean; disabled?: boolean;
@@ -119,6 +121,7 @@ interface MediaOverlayActionsProps {
onOpenInBrowser?: () => void; onOpenInBrowser?: () => void;
onCopyLink?: () => void; onCopyLink?: () => void;
onCopyMedia?: () => void; onCopyMedia?: () => void;
onDeleteAttachment?: MouseEventHandler<HTMLButtonElement>;
onReset?: () => void; onReset?: () => void;
onZoomIn?: () => void; onZoomIn?: () => void;
onZoomOut?: () => void; onZoomOut?: () => void;
@@ -144,6 +147,7 @@ export const MediaOverlayActions: FC<MediaOverlayActionsProps> = observer(
onOpenInBrowser, onOpenInBrowser,
onCopyLink, onCopyLink,
onCopyMedia, onCopyMedia,
onDeleteAttachment,
onReset, onReset,
onZoomIn, onZoomIn,
onZoomOut, onZoomOut,
@@ -250,6 +254,21 @@ export const MediaOverlayActions: FC<MediaOverlayActionsProps> = observer(
data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button.download" data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button.download"
/> />
)} )}
{onDeleteAttachment && (
<OverlayTooltipButton
icon={
<TrashIcon
size={20}
weight="bold"
data-flx="messaging.media-modal.media-overlay-actions.delete-attachment-icon"
/>
}
label={i18n._(DELETE_ATTACHMENT_DESCRIPTOR)}
onClick={onDeleteAttachment}
variant="danger"
data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button.delete-attachment"
/>
)}
<div <div
className={styles.overlayActionGap} className={styles.overlayActionGap}
aria-hidden="true" aria-hidden="true"
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
import type {ReactNode} from 'react'; import type {MouseEventHandler, ReactNode} from 'react';
export type ZoomState = 'fit' | 'zoomed'; export type ZoomState = 'fit' | 'zoomed';
@@ -23,6 +23,7 @@ export interface MediaModalProps {
onOpenInBrowser?: () => void; onOpenInBrowser?: () => void;
onCopyLink?: () => void; onCopyLink?: () => void;
onCopyMedia?: () => void; onCopyMedia?: () => void;
onDeleteAttachment?: MouseEventHandler<HTMLButtonElement>;
onReply?: () => void; onReply?: () => void;
onForward?: () => void; onForward?: () => void;
onInfo?: () => void; onInfo?: () => void;
@@ -9,14 +9,26 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
import {Trans, useLingui} from '@lingui/react/macro'; import {Trans, useLingui} from '@lingui/react/macro';
import {useCallback} from 'react'; import {useCallback} from 'react';
export function useDeleteAttachment(message: Message | null | undefined, attachmentId: string | null | undefined) { interface DeleteAttachmentOptions {
onDeleted?: () => void;
}
export function useDeleteAttachment(
message: Message | null | undefined,
attachmentId: string | null | undefined,
options?: DeleteAttachmentOptions,
) {
const {i18n} = useLingui(); const {i18n} = useLingui();
const onDeleted = options?.onDeleted;
return useCallback( return useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (!message || !attachmentId) return; if (!message || !attachmentId) return;
const deleteAttachment = () => MessageCommands.deleteAttachment(message.channelId, message.id, attachmentId); const deleteAttachment = async () => {
await MessageCommands.deleteAttachment(message.channelId, message.id, attachmentId);
onDeleted?.();
};
if (e.shiftKey) { if (e.shiftKey) {
void deleteAttachment(); void deleteAttachment();
return; return;
@@ -39,6 +51,6 @@ export function useDeleteAttachment(message: Message | null | undefined, attachm
)), )),
); );
}, },
[message, attachmentId, i18n], [message, attachmentId, i18n, onDeleted],
); );
} }
@@ -9,6 +9,7 @@ interface MediaViewerOpenOptions {
messageId?: string; messageId?: string;
message?: Message; message?: Message;
sourceChannel?: Channel | null; sourceChannel?: Channel | null;
allowAttachmentDelete?: boolean;
} }
type MediaViewerIntent = type MediaViewerIntent =
@@ -26,6 +27,7 @@ function dispatchMediaViewerIntent(intent: MediaViewerIntent): void {
intent.options?.messageId, intent.options?.messageId,
intent.options?.message, intent.options?.message,
intent.options?.sourceChannel, intent.options?.sourceChannel,
intent.options?.allowAttachmentDelete,
); );
return; return;
case 'close': case 'close':
@@ -36,6 +36,7 @@ class MediaViewer {
messageId?: string = undefined; messageId?: string = undefined;
message?: Message = undefined; message?: Message = undefined;
sourceChannel?: Channel = undefined; sourceChannel?: Channel = undefined;
allowAttachmentDelete: boolean = false;
constructor() { constructor() {
makeAutoObservable( makeAutoObservable(
@@ -56,6 +57,7 @@ class MediaViewer {
messageId?: string, messageId?: string,
message?: Message, message?: Message,
sourceChannel?: Channel | null, sourceChannel?: Channel | null,
allowAttachmentDelete: boolean = false,
): void { ): void {
this.isOpen = true; this.isOpen = true;
this.items = copyMediaViewerItems(items); this.items = copyMediaViewerItems(items);
@@ -64,6 +66,7 @@ class MediaViewer {
this.messageId = messageId; this.messageId = messageId;
this.message = message; this.message = message;
this.sourceChannel = sourceChannel ?? undefined; this.sourceChannel = sourceChannel ?? undefined;
this.allowAttachmentDelete = allowAttachmentDelete;
} }
close(): void { close(): void {
@@ -74,6 +77,7 @@ class MediaViewer {
this.messageId = undefined; this.messageId = undefined;
this.message = undefined; this.message = undefined;
this.sourceChannel = undefined; this.sourceChannel = undefined;
this.allowAttachmentDelete = false;
} }
navigate(index: number): void { navigate(index: number): void {