fluxer/fluxer_app/src/components/channel/ChannelAttachmentArea.tsx
2026-02-17 12:22:36 +00:00

622 lines
18 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as MediaViewerActionCreators from '@app/actions/MediaViewerActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import styles from '@app/components/channel/ChannelAttachmentArea.module.css';
import EmbedVideo from '@app/components/channel/embeds/media/EmbedVideo';
import {computeHorizontalDropPosition} from '@app/components/layout/dnd/DndDropPosition';
import {type AttachmentDragItem, type AttachmentDropResult, DND_TYPES} from '@app/components/layout/types/DndTypes';
import {AttachmentEditModal} from '@app/components/modals/AttachmentEditModal';
import * as Modal from '@app/components/modals/Modal';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {Scroller} from '@app/components/uikit/Scroller';
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import {useTextareaAttachments} from '@app/hooks/useCloudUpload';
import {type CloudAttachment, CloudUpload} from '@app/lib/CloudUpload';
import {ComponentDispatch} from '@app/lib/ComponentDispatch';
import MessageStore from '@app/stores/MessageStore';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import {isEmbeddableImageFile} from '@app/utils/EmbeddableImageTypes';
import {formatFileSize} from '@app/utils/FileUtils';
import {MessageAttachmentFlags} from '@fluxer/constants/src/ChannelConstants';
import {useLingui} from '@lingui/react/macro';
import {
EyeIcon,
EyeSlashIcon,
FileAudioIcon,
FileCodeIcon,
FileIcon,
FilePdfIcon,
FileTextIcon,
FileZipIcon,
type Icon,
PencilIcon,
TrashIcon,
} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
import type {ConnectableElement} from 'react-dnd';
import {useDrag, useDrop} from 'react-dnd';
import {getEmptyImage} from 'react-dnd-html5-backend';
const getFileExtension = (filename: string): string => {
const ext = filename.split('.').pop()?.toLowerCase() || '';
return ext.length > 0 && ext.length <= 4 ? ext : '';
};
const getFileIcon = (file: File): Icon => {
const mimeType = file.type.toLowerCase();
const extension = file.name.split('.').pop()?.toLowerCase() || '';
if (mimeType.startsWith('audio/')) {
return FileAudioIcon;
}
if (mimeType === 'application/pdf') {
return FilePdfIcon;
}
if (mimeType.startsWith('text/') || ['txt', 'md', 'markdown', 'rtf'].includes(extension)) {
return FileTextIcon;
}
if (
[
'application/zip',
'application/x-zip-compressed',
'application/x-rar-compressed',
'application/x-7z-compressed',
].includes(mimeType) ||
['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)
) {
return FileZipIcon;
}
if (
mimeType.startsWith('application/') &&
[
'js',
'ts',
'jsx',
'tsx',
'html',
'css',
'json',
'xml',
'py',
'java',
'cpp',
'c',
'cs',
'php',
'rb',
'go',
'rs',
'swift',
].includes(extension)
) {
return FileCodeIcon;
}
return FileIcon;
};
const isAttachmentMedia = (attachment: CloudAttachment): boolean => {
if (attachment.file.type.startsWith('video/')) {
return attachment.previewURL !== null || attachment.thumbnailURL !== null;
}
if (isEmbeddableImageFile(attachment.file)) {
return attachment.previewURL !== null;
}
return false;
};
const VideoPreviewModal = observer(({file, width, height}: {file: File; width: number; height: number}) => {
const {t} = useLingui();
const [blobUrl, setBlobUrl] = useState<string | null>(null);
useEffect(() => {
const url = URL.createObjectURL(file);
setBlobUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!blobUrl) return null;
return (
<Modal.Root className={styles.videoModal}>
<Modal.ScreenReaderLabel text={t`Video`} />
<div className={styles.videoContainer}>
<EmbedVideo src={blobUrl} width={width} height={height} />
</div>
</Modal.Root>
);
});
const SortableAttachmentItem = observer(
({
attachment,
channelId,
isSortingList = false,
onAttachmentDrop,
onDragStateChange,
}: {
attachment: CloudAttachment;
channelId: string;
isSortingList?: boolean;
onAttachmentDrop?: (item: AttachmentDragItem, result: AttachmentDropResult) => void;
onDragStateChange?: (item: AttachmentDragItem | null) => void;
}) => {
const {t} = useLingui();
const itemRef = useRef<HTMLLIElement | null>(null);
const mobileLayout = MobileLayoutStore;
const [spoilerHidden, setSpoilerHidden] = useState(true);
const [dropIndicator, setDropIndicator] = useState<'left' | 'right' | null>(null);
const isSpoiler = (attachment.flags & MessageAttachmentFlags.IS_SPOILER) !== 0;
const dragItemData: AttachmentDragItem = {
type: DND_TYPES.ATTACHMENT,
id: attachment.id,
channelId,
};
const [{isDragging}, dragRef, preview] = useDrag(
() => ({
type: DND_TYPES.ATTACHMENT,
item: () => {
onDragStateChange?.(dragItemData);
return dragItemData;
},
canDrag: !mobileLayout.enabled,
collect: (monitor) => ({isDragging: monitor.isDragging()}),
end: () => {
onDragStateChange?.(null);
setDropIndicator(null);
},
}),
[dragItemData, mobileLayout.enabled, onDragStateChange],
);
const [{isOver}, dropRef] = useDrop(
() => ({
accept: DND_TYPES.ATTACHMENT,
canDrop: (item: AttachmentDragItem) => item.id !== attachment.id,
hover: (item: AttachmentDragItem, monitor) => {
if (item.id === attachment.id) {
setDropIndicator(null);
return;
}
const node = itemRef.current;
if (!node) return;
const hoverBoundingRect = node.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const dropPos = computeHorizontalDropPosition(clientOffset, hoverBoundingRect);
setDropIndicator(dropPos === 'before' ? 'left' : 'right');
},
drop: (item: AttachmentDragItem, monitor): AttachmentDropResult | undefined => {
if (!monitor.canDrop()) {
setDropIndicator(null);
return;
}
const node = itemRef.current;
if (!node) return;
const hoverBoundingRect = node.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const result: AttachmentDropResult = {
targetId: attachment.id,
position: computeHorizontalDropPosition(clientOffset, hoverBoundingRect),
};
onAttachmentDrop?.(item, result);
setDropIndicator(null);
return result;
},
collect: (monitor) => ({
isOver: monitor.isOver({shallow: true}),
}),
}),
[attachment.id, onAttachmentDrop],
);
useEffect(() => {
if (!isOver) setDropIndicator(null);
}, [isOver]);
useEffect(() => {
preview(getEmptyImage(), {captureDraggingState: true});
}, [preview]);
const dragConnectorRef = useCallback(
(node: ConnectableElement | null) => {
dragRef(node);
},
[dragRef],
);
const dropConnectorRef = useCallback(
(node: ConnectableElement | null) => {
dropRef(node);
},
[dropRef],
);
const setRefs = useCallback(
(node: HTMLLIElement | null) => {
itemRef.current = node;
dragConnectorRef(node);
dropConnectorRef(node);
},
[dragConnectorRef, dropConnectorRef],
);
useEffect(() => {
if (isSpoiler) {
setSpoilerHidden(true);
}
}, [isSpoiler]);
const handleClick = () => {
if (isSpoiler && spoilerHidden) {
setSpoilerHidden(false);
return;
}
if (isEmbeddableImageFile(attachment.file)) {
if (!attachment.previewURL) return;
MediaViewerActionCreators.openMediaViewer(
[
{
src: attachment.previewURL,
originalSrc: attachment.previewURL,
naturalWidth: attachment.width,
naturalHeight: attachment.height,
type: 'image' as const,
filename: attachment.file.name,
},
],
0,
);
} else if (attachment.file.type.startsWith('video/')) {
ModalActionCreators.push(
modal(() => <VideoPreviewModal file={attachment.file} width={attachment.width} height={attachment.height} />),
);
}
};
const containerStyle: React.CSSProperties = {
width: '200px',
height: '200px',
position: 'relative',
opacity: isDragging ? 0.5 : 1,
cursor: isDragging ? 'grabbing' : 'default',
};
const isMedia = isAttachmentMedia(attachment);
const isHiddenSpoiler = isSpoiler && spoilerHidden;
const IconComponent = getFileIcon(attachment.file);
return (
<li
ref={setRefs}
style={containerStyle}
className={clsx(
styles.upload,
dropIndicator === 'left' && styles.dropIndicatorLeft,
dropIndicator === 'right' && styles.dropIndicatorRight,
)}
tabIndex={-1}
>
<div className={styles.uploadContainer}>
{isMedia ? (
<div className={styles.mediaContainer}>
<button type="button" className={styles.clickableMedia} onClick={handleClick}>
<div
className={clsx(
styles.spoilerContainer,
isHiddenSpoiler && styles.hidden,
isHiddenSpoiler && styles.hiddenSpoiler,
)}
>
{isHiddenSpoiler && (
<div className={clsx(styles.spoilerWarning, styles.obscureWarning)}>{t`Spoiler`}</div>
)}
<div className={styles.spoilerInnerContainer} aria-hidden={isHiddenSpoiler}>
<div className={styles.spoilerWrapper}>
{isEmbeddableImageFile(attachment.file) ? (
<ImageThumbnail attachment={attachment} spoiler={isHiddenSpoiler} />
) : attachment.file.type.startsWith('video/') ? (
<VideoThumbnail attachment={attachment} spoiler={isHiddenSpoiler} />
) : null}
<div className={styles.tags}>
{isSpoiler && !spoilerHidden && <span className={styles.altTag}>{t`Spoiler`}</span>}
</div>
</div>
</div>
</div>
</button>
</div>
) : (
<div className={styles.icon}>
<button type="button" className={styles.clickableMedia} onClick={handleClick}>
<div
className={clsx(
styles.spoilerContainer,
isHiddenSpoiler && styles.hidden,
isHiddenSpoiler && styles.hiddenSpoiler,
)}
>
{isHiddenSpoiler && (
<div className={clsx(styles.spoilerWarning, styles.obscureWarning)}>{t`Spoiler`}</div>
)}
<div className={styles.spoilerInnerContainer} aria-hidden={isHiddenSpoiler}>
<div className={styles.spoilerWrapper}>
<IconComponent
className={clsx(styles.iconImage, isHiddenSpoiler && styles.spoiler)}
weight="fill"
aria-label={attachment.filename}
/>
<div className={styles.tags}>
{isSpoiler && !spoilerHidden && <span className={styles.altTag}>{t`Spoiler`}</span>}
</div>
</div>
</div>
</div>
</button>
</div>
)}
<div className={styles.filenameContainer}>
<Tooltip text={attachment.filename}>
<div className={styles.filename}>{attachment.filename}</div>
</Tooltip>
<div className={styles.fileDetails}>
<span className={styles.fileSize}>{formatFileSize(attachment.file.size)}</span>
<span className={styles.fileExtension}>{getFileExtension(attachment.filename)}</span>
</div>
</div>
{!isSortingList && (
<div className={styles.actionBarContainer}>
{attachment.status === 'failed' ? (
<div className={styles.actionBar}>
<AttachmentActionBarButton
icon={TrashIcon}
label={t`Remove Attachment`}
danger={true}
onClick={() => CloudUpload.removeAttachment(channelId, attachment.id)}
/>
</div>
) : (
<AttachmentActionBar channelId={channelId} attachment={attachment} />
)}
</div>
)}
</div>
</li>
);
},
);
export const ChannelAttachmentArea = observer(({channelId}: {channelId: string}) => {
const attachments = useTextareaAttachments(channelId);
const prevAttachmentsLength = useRef<number | null>(null);
const wasAtBottomBeforeChange = useRef<boolean>(true);
const [isDragging, setIsDragging] = useState(false);
const handleAttachmentDrop = useCallback(
(item: AttachmentDragItem, result: AttachmentDropResult) => {
const sourceId = item.id;
const targetId = result.targetId;
if (sourceId === targetId) return;
const oldIndex = attachments.findIndex((attachment: CloudAttachment) => attachment.id === sourceId);
const targetIndex = attachments.findIndex((attachment: CloudAttachment) => attachment.id === targetId);
if (oldIndex === -1 || targetIndex === -1) return;
let newIndex = result.position === 'after' ? targetIndex + 1 : targetIndex;
if (oldIndex < targetIndex && result.position === 'after') newIndex--;
const newArray = [...attachments];
const [movedItem] = newArray.splice(oldIndex, 1);
newArray.splice(newIndex, 0, movedItem);
CloudUpload.reorderAttachments(channelId, newArray);
},
[attachments, channelId],
);
const handleDragStateChange = useCallback((item: AttachmentDragItem | null) => {
setIsDragging(item !== null);
}, []);
if (attachments.length !== prevAttachmentsLength.current) {
const scrollerElement = document.querySelector('.scroller-base') as HTMLElement | null;
if (scrollerElement) {
const isNearBottom =
scrollerElement.scrollHeight <= scrollerElement.scrollTop + scrollerElement.offsetHeight + 16;
wasAtBottomBeforeChange.current = isNearBottom;
}
}
useLayoutEffect(() => {
const currentLength = attachments.length;
const previousLength = prevAttachmentsLength.current;
if (previousLength !== null && previousLength !== currentLength) {
if ((previousLength === 0 && currentLength > 0) || (previousLength > 0 && currentLength === 0)) {
if (wasAtBottomBeforeChange.current) {
const messages = MessageStore.getMessages(channelId);
if (messages.hasMoreAfter) {
ComponentDispatch.dispatch('FORCE_JUMP_TO_PRESENT');
}
}
}
ComponentDispatch.dispatch('LAYOUT_RESIZED');
}
prevAttachmentsLength.current = currentLength;
}, [attachments, channelId]);
if (attachments.length === 0) {
return null;
}
return (
<>
<Scroller key="channel-attachment-scroller" orientation="horizontal" fade={false} className={styles.scroller}>
<ul className={styles.channelAttachmentArea}>
{attachments.map((attachment: CloudAttachment) => (
<SortableAttachmentItem
key={attachment.id}
attachment={attachment}
channelId={channelId}
isSortingList={isDragging}
onAttachmentDrop={handleAttachmentDrop}
onDragStateChange={handleDragStateChange}
/>
))}
</ul>
</Scroller>
<div className={styles.divider} />
</>
);
});
const ImageThumbnail = observer(({attachment, spoiler}: {attachment: CloudAttachment; spoiler: boolean}) => {
const [hasError, setHasError] = useState(false);
const src = attachment.previewURL;
if (hasError || !src) return null;
return (
<img
src={src}
className={clsx(styles.media, spoiler && styles.spoiler)}
aria-hidden={true}
alt={attachment.filename}
onError={() => setHasError(true)}
/>
);
});
const VideoThumbnail = observer(({attachment, spoiler}: {attachment: CloudAttachment; spoiler: boolean}) => {
const [hasError, setHasError] = useState(false);
const src = attachment.thumbnailURL || attachment.previewURL;
if (hasError || !src) return null;
return (
<img
src={src}
alt={attachment.filename}
className={clsx(styles.media, spoiler && styles.spoiler)}
onError={() => setHasError(true)}
/>
);
});
const AttachmentActionBarButton = observer(
({
label,
icon: Icon,
onClick,
danger = false,
}: {
label: string;
icon: Icon;
onClick: (event: React.MouseEvent | React.KeyboardEvent) => void;
danger?: boolean;
}) => {
const handleClick = (event: React.MouseEvent | React.KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
onClick(event);
};
return (
<Tooltip text={label}>
<FocusRing offset={-2}>
<button
type="button"
aria-label={label}
onClick={handleClick}
className={clsx(styles.button, danger && styles.danger)}
>
<Icon className={styles.actionBarIcon} />
</button>
</FocusRing>
</Tooltip>
);
},
);
const AttachmentActionBar = observer(({channelId, attachment}: {channelId: string; attachment: CloudAttachment}) => {
const {t} = useLingui();
const isSpoiler = (attachment.flags & MessageAttachmentFlags.IS_SPOILER) !== 0;
const toggleSpoiler = () => {
const nextFlags = isSpoiler
? attachment.flags & ~MessageAttachmentFlags.IS_SPOILER
: attachment.flags | MessageAttachmentFlags.IS_SPOILER;
CloudUpload.updateAttachment(channelId, attachment.id, {
flags: nextFlags,
spoiler: !isSpoiler,
});
};
const editAttachment = () => {
ModalActionCreators.push(modal(() => <AttachmentEditModal channelId={channelId} attachment={attachment} />));
};
const removeAttachment = () => {
CloudUpload.removeAttachment(channelId, attachment.id);
};
return (
<div className={styles.actionBarContainer}>
<div className={styles.actionBar} role="toolbar" aria-label={t`Attachment Actions`}>
<AttachmentActionBarButton
icon={isSpoiler ? EyeSlashIcon : EyeIcon}
label={isSpoiler ? t`Remove Spoiler` : t`Spoiler Attachment`}
onClick={toggleSpoiler}
/>
<AttachmentActionBarButton icon={PencilIcon} label={t`Edit Attachment`} onClick={editAttachment} />
<AttachmentActionBarButton
icon={TrashIcon}
label={t`Remove Attachment`}
danger={true}
onClick={removeAttachment}
/>
</div>
</div>
);
});