mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
292 lines
10 KiB
TypeScript
292 lines
10 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import type {Channel} from '@app/features/channel/models/Channel';
|
|
import Channels from '@app/features/channel/state/Channels';
|
|
import Emoji from '@app/features/emoji/state/Emoji';
|
|
import type {GuildEmoji} from '@app/features/expressions/models/GuildEmoji';
|
|
import GuildGuilds from '@app/features/guild/state/Guilds';
|
|
import {
|
|
$insertComposerClipboardSlice,
|
|
type ComposerClipboardSlice,
|
|
FLUXER_COMPOSER_CLIPBOARD_MIME,
|
|
getComposerClipboardTextPlain,
|
|
parseComposerClipboardSlice,
|
|
} from '@app/features/lexical/composer/ComposerClipboard';
|
|
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
|
|
import {showAttachmentPermissionDeniedModal} from '@app/features/messaging/components/alerts/AttachmentPermissionDeniedModal';
|
|
import {FileSizeTooLargeModal} from '@app/features/messaging/components/alerts/FileSizeTooLargeModal';
|
|
import {TooManyAttachmentsModal} from '@app/features/messaging/components/alerts/TooManyAttachmentsModal';
|
|
import ChatInputSettings from '@app/features/messaging/state/ChatInputSettings';
|
|
import MessageFocus from '@app/features/messaging/state/MessageFocus';
|
|
import type {CloudAttachment} from '@app/features/messaging/upload/CloudUpload';
|
|
import {canAttachFilesInChannel} from '@app/features/messaging/utils/AttachmentPermissionUtils';
|
|
import {getClipboardDataFiles, readClipboardImageFiles} from '@app/features/messaging/utils/ClipboardFilePasteUtils';
|
|
import * as FileUploadUtils from '@app/features/messaging/utils/FileUploadUtils';
|
|
import {detectPastedSegments, type LookupFunctions} from '@app/features/messaging/utils/PasteSegmentUtils';
|
|
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
|
|
import {isDialogPasteTarget} from '@app/features/messaging/utils/TextInputEditUtils';
|
|
import {canFocusTextarea, safeFocus} from '@app/features/platform/utils/InputFocusManager';
|
|
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
|
|
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
|
import {modal} from '@app/features/ui/commands/ModalCommands';
|
|
import ContextMenuState from '@app/features/ui/state/ContextMenu';
|
|
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
|
import Users from '@app/features/user/state/Users';
|
|
import {COMMAND_PRIORITY_HIGH, PASTE_COMMAND} from 'lexical';
|
|
import type React from 'react';
|
|
import {useCallback, useEffect} from 'react';
|
|
|
|
interface UseChannelComposerPasteParams {
|
|
channel: Channel;
|
|
handleRef: React.RefObject<ComposerHandle | null>;
|
|
editableRef: React.RefObject<HTMLDivElement | null>;
|
|
isFocused: boolean;
|
|
maxMessageLength: number;
|
|
maxAttachments: number;
|
|
uploadAttachments: ReadonlyArray<CloudAttachment>;
|
|
textareaInputDisabled: boolean;
|
|
}
|
|
|
|
function createComposerPasteSlice(pastedText: string, segments: ReadonlyArray<MentionSegment>): ComposerClipboardSlice {
|
|
const displayParts: Array<string> = [];
|
|
const projectedSegments: Array<MentionSegment> = [];
|
|
let sourceCursor = 0;
|
|
let displayLength = 0;
|
|
for (const segment of segments) {
|
|
const plainText = pastedText.slice(sourceCursor, segment.start);
|
|
displayParts.push(plainText, segment.displayText);
|
|
displayLength += plainText.length;
|
|
projectedSegments.push({
|
|
...segment,
|
|
start: displayLength,
|
|
end: displayLength + segment.displayText.length,
|
|
});
|
|
displayLength += segment.displayText.length;
|
|
sourceCursor = segment.end;
|
|
}
|
|
displayParts.push(pastedText.slice(sourceCursor));
|
|
return {display: displayParts.join(''), segments: projectedSegments};
|
|
}
|
|
|
|
export function useChannelComposerPaste({
|
|
channel,
|
|
handleRef,
|
|
editableRef,
|
|
isFocused,
|
|
maxMessageLength,
|
|
maxAttachments,
|
|
uploadAttachments,
|
|
textareaInputDisabled,
|
|
}: UseChannelComposerPasteParams): void {
|
|
const handlePasteExceedsLimit = useCallback(
|
|
async (pastedText: string) => {
|
|
if (!canAttachFilesInChannel(channel)) {
|
|
showAttachmentPermissionDeniedModal(channel);
|
|
return;
|
|
}
|
|
const result = await FileUploadUtils.convertTextToFile(
|
|
channel.id,
|
|
pastedText,
|
|
uploadAttachments.length,
|
|
maxAttachments,
|
|
);
|
|
if (!result.success) {
|
|
if (result.error === 'too_many_attachments') {
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<TooManyAttachmentsModal data-flx="channel.use-channel-composer-paste.handle-paste-exceeds-limit.too-many-attachments-modal" />
|
|
)),
|
|
);
|
|
} else if (result.error === 'file_size_too_large') {
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<FileSizeTooLargeModal
|
|
oversizedFileCount={result.oversizedFileCount}
|
|
data-flx="channel.use-channel-composer-paste.handle-paste-exceeds-limit.file-size-too-large-modal"
|
|
/>
|
|
)),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
[channel, uploadAttachments.length, maxAttachments],
|
|
);
|
|
const handlePasteFiles = useCallback(
|
|
async (files: Array<File>) => {
|
|
if (!canAttachFilesInChannel(channel)) {
|
|
showAttachmentPermissionDeniedModal(channel);
|
|
return;
|
|
}
|
|
const result = await FileUploadUtils.handleFileUpload(
|
|
channel.id,
|
|
files,
|
|
uploadAttachments.length,
|
|
maxAttachments,
|
|
);
|
|
if (!result.success) {
|
|
if (result.error === 'too_many_attachments') {
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<TooManyAttachmentsModal data-flx="channel.use-channel-composer-paste.handle-paste-files.too-many-attachments-modal" />
|
|
)),
|
|
);
|
|
} else if (result.error === 'file_size_too_large') {
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<FileSizeTooLargeModal
|
|
oversizedFileCount={result.oversizedFileCount}
|
|
data-flx="channel.use-channel-composer-paste.handle-paste-files.file-size-too-large-modal"
|
|
/>
|
|
)),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
[channel, uploadAttachments.length, maxAttachments],
|
|
);
|
|
const insertPastedText = useCallback(
|
|
(pastedText: string): boolean => {
|
|
const guildId = channel.guildId;
|
|
const lookups: LookupFunctions = {
|
|
userById: (id: string) => {
|
|
const user = Users.getUser(id);
|
|
return user ? {id: user.id, tag: user.tag} : null;
|
|
},
|
|
channelById: (id: string) => {
|
|
const foundChannel = Channels.getChannel(id);
|
|
if (foundChannel === null || foundChannel === undefined || !foundChannel.name) return null;
|
|
return {id: foundChannel.id, name: foundChannel.name};
|
|
},
|
|
roleById: (id: string) => {
|
|
if (!guildId) return null;
|
|
const roles = GuildGuilds.getGuildRoles(guildId);
|
|
const role = roles.find((r) => r.id === id);
|
|
return role ? {id: role.id, name: role.name} : null;
|
|
},
|
|
emojiById: (id: string) => {
|
|
const guilds = GuildGuilds.getGuilds();
|
|
for (const guild of guilds) {
|
|
const emojis = Emoji.getGuildEmoji(guild.id);
|
|
const emoji = emojis.find((e: GuildEmoji) => e.id === id);
|
|
if (emoji) return {id: emoji.id, name: emoji.name, uniqueName: emoji.uniqueName};
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
const segments = detectPastedSegments(pastedText, 0, lookups);
|
|
const richSlice = createComposerPasteSlice(pastedText, segments);
|
|
const plainText = getComposerClipboardTextPlain(richSlice);
|
|
const slice = plainText === null || plainText === undefined ? {display: pastedText, segments: []} : richSlice;
|
|
return $insertComposerClipboardSlice(slice, ChatInputSettings.renderComposerAsPlainText);
|
|
},
|
|
[channel.guildId],
|
|
);
|
|
useEffect(() => {
|
|
const handle = handleRef.current;
|
|
if (handle === null) {
|
|
return;
|
|
}
|
|
const editor = handle.getEditor();
|
|
const asClipboardEvent = (event: unknown): ClipboardEvent | null =>
|
|
typeof ClipboardEvent !== 'undefined' && event instanceof ClipboardEvent ? event : null;
|
|
const handlePaste = (event: ClipboardEvent): boolean => {
|
|
if (textareaInputDisabled) {
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
const clipboardData = event.clipboardData;
|
|
if (clipboardData === null) return false;
|
|
const pastedFiles = getClipboardDataFiles(clipboardData);
|
|
if (pastedFiles.length > 0) {
|
|
event.preventDefault();
|
|
void handlePasteFiles(pastedFiles);
|
|
return true;
|
|
}
|
|
const rawPastedText = clipboardData.getData('text/plain');
|
|
if (!rawPastedText) {
|
|
void readClipboardImageFiles().then((files) => {
|
|
if (files.length > 0) {
|
|
void handlePasteFiles(files);
|
|
}
|
|
});
|
|
return false;
|
|
}
|
|
const serializedComposerSlice = clipboardData.getData(FLUXER_COMPOSER_CLIPBOARD_MIME);
|
|
if (serializedComposerSlice) {
|
|
const composerSlice = parseComposerClipboardSlice(serializedComposerSlice);
|
|
const composerWire = composerSlice == null ? null : getComposerClipboardTextPlain(composerSlice);
|
|
if (composerWire != null) {
|
|
if (composerWire.length > maxMessageLength) {
|
|
event.preventDefault();
|
|
void handlePasteExceedsLimit(composerWire);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
const pastedText = rawPastedText.replace(/\t/g, ' ');
|
|
if (pastedText.length > maxMessageLength) {
|
|
event.preventDefault();
|
|
void handlePasteExceedsLimit(pastedText);
|
|
return true;
|
|
}
|
|
if (!insertPastedText(pastedText)) {
|
|
return false;
|
|
}
|
|
event.preventDefault();
|
|
return true;
|
|
};
|
|
const handleWindowPaste = (event: ClipboardEvent) => {
|
|
const element = editableRef.current;
|
|
if (event.defaultPrevented || textareaInputDisabled || isDialogPasteTarget(event.target)) {
|
|
return;
|
|
}
|
|
if (!canFocusTextarea(element === null ? undefined : element)) {
|
|
return;
|
|
}
|
|
if (isFocused) {
|
|
return;
|
|
}
|
|
if (QuickSwitcher.getIsOpen()) {
|
|
return;
|
|
}
|
|
if (ContextMenuState.contextMenu) {
|
|
return;
|
|
}
|
|
if (KeyboardMode.keyboardModeEnabled && MessageFocus.focusedMessageId) {
|
|
return;
|
|
}
|
|
const clipboardData = event.clipboardData;
|
|
if (clipboardData === null) {
|
|
return;
|
|
}
|
|
if (getClipboardDataFiles(clipboardData).length > 0) {
|
|
return;
|
|
}
|
|
if (!clipboardData.getData('text/plain')) {
|
|
return;
|
|
}
|
|
if (!element) {
|
|
return;
|
|
}
|
|
safeFocus(element, true);
|
|
const currentHandle = handleRef.current;
|
|
if (currentHandle !== null) currentHandle.focus();
|
|
editor.dispatchCommand(PASTE_COMMAND, event);
|
|
};
|
|
window.addEventListener('paste', handleWindowPaste);
|
|
const unregisterPaste = editor.registerCommand(
|
|
PASTE_COMMAND,
|
|
(event) => {
|
|
const clipboardEvent = asClipboardEvent(event);
|
|
return clipboardEvent != null && handlePaste(clipboardEvent);
|
|
},
|
|
COMMAND_PRIORITY_HIGH,
|
|
);
|
|
return () => {
|
|
window.removeEventListener('paste', handleWindowPaste);
|
|
unregisterPaste();
|
|
};
|
|
}, [handlePasteExceedsLimit, handlePasteFiles, insertPastedText, isFocused, maxMessageLength, textareaInputDisabled]);
|
|
}
|