mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
482 lines
18 KiB
TypeScript
482 lines
18 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {
|
|
canReportMessage,
|
|
createMessageActionHandlers,
|
|
getCopyableMessageText,
|
|
getEffectiveContent,
|
|
isClientSystemMessage,
|
|
isEmbedsSuppressed,
|
|
type MessageActionHandlers,
|
|
type MessagePermissions,
|
|
requestSpeakMessage,
|
|
useMessagePermissions,
|
|
} from '@app/features/channel/components/MessageActionUtils';
|
|
import {useQuickReactionEmojis} from '@app/features/channel/state/QuickReactionStore';
|
|
import {MessageDebugModal} from '@app/features/devtools/components/debug/MessageDebugModal';
|
|
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
|
import {
|
|
ADD_REACTION_DESCRIPTOR,
|
|
BOOKMARK_MESSAGE_DESCRIPTOR,
|
|
COPY_MESSAGE_ID_DESCRIPTOR,
|
|
COPY_MESSAGE_LINK_DESCRIPTOR,
|
|
DELETE_MESSAGE_DESCRIPTOR,
|
|
EDIT_MESSAGE_DESCRIPTOR,
|
|
MARK_AS_UNREAD_DESCRIPTOR,
|
|
PIN_MESSAGE_DESCRIPTOR,
|
|
REMOVE_BOOKMARK_DESCRIPTOR,
|
|
REPLY_DESCRIPTOR,
|
|
REPORT_MESSAGE_DESCRIPTOR,
|
|
SUPPRESS_EMBEDS_DESCRIPTOR,
|
|
TRY_AGAIN_DESCRIPTOR,
|
|
UNPIN_MESSAGE_DESCRIPTOR,
|
|
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
|
import {useMessageReactions as useMessageReactionsSnapshot} from '@app/features/messaging/hooks/useMessageReactionStore';
|
|
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
|
import SavedMessages from '@app/features/messaging/state/SavedMessages';
|
|
import {openReportMessageModal} from '@app/features/moderation/utils/ReportActionUtils';
|
|
import Permission from '@app/features/permissions/state/Permission';
|
|
import {
|
|
AddReactionIcon,
|
|
BookmarkIcon,
|
|
CopyIdIcon,
|
|
CopyLinkIcon,
|
|
CopyMessageTextIcon,
|
|
DebugMessageIcon,
|
|
DeleteIcon,
|
|
EditMessageIcon,
|
|
ForwardIcon,
|
|
MarkAsUnreadIcon,
|
|
PinIcon,
|
|
RemoveAllReactionsIcon,
|
|
ReplyIcon,
|
|
ReportMessageIcon,
|
|
RetryIcon,
|
|
SpeakMessageIcon,
|
|
StopSpeakingIcon,
|
|
SuppressEmbedsIcon,
|
|
ViewReactionsIcon,
|
|
} from '@app/features/ui/action_menu/ContextMenuIcons';
|
|
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
|
import {modal} from '@app/features/ui/commands/ModalCommands';
|
|
import {KeybindHint} from '@app/features/ui/keybind_hint/KeybindHint';
|
|
import type {MenuGroupType, MenuItemType} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
|
|
import UserSettings from '@app/features/user/state/UserSettings';
|
|
import TtsUtils from '@app/features/voice/utils/VoiceTtsUtils';
|
|
import {MessageStates, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
|
import {msg} from '@lingui/core/macro';
|
|
import {useLingui} from '@lingui/react/macro';
|
|
import {useCallback, useEffect, useMemo, useState} from 'react';
|
|
|
|
const MESSAGE_DEBUG_DESCRIPTOR = msg({
|
|
message: 'Message debug',
|
|
comment: 'Title of the developer-mode message debug modal opened from the message context menu.',
|
|
});
|
|
const VIEW_REACTIONS_DESCRIPTOR = msg({
|
|
message: 'View reactions',
|
|
comment: 'Message context menu item that opens the reactions list sheet for the message.',
|
|
});
|
|
const REMOVE_ALL_REACTIONS_DESCRIPTOR = msg({
|
|
message: 'Remove all reactions',
|
|
comment: 'Destructive message context menu item for moderators that clears every reaction on a message.',
|
|
});
|
|
const FORWARD_DESCRIPTOR = msg({
|
|
message: 'Forward',
|
|
comment: 'Message context menu item that opens the forward-to-channel picker.',
|
|
});
|
|
const UNSUPPRESS_EMBEDS_DESCRIPTOR = msg({
|
|
message: 'Unsuppress embeds',
|
|
comment: 'Message context menu item that re-shows previously hidden link previews and embeds on the message.',
|
|
});
|
|
const COPY_MESSAGE_DESCRIPTOR = msg({
|
|
message: 'Copy message',
|
|
comment: 'Message context menu item that copies the message text content to the clipboard.',
|
|
});
|
|
const STOP_SPEAKING_DESCRIPTOR = msg({
|
|
message: 'Stop speaking',
|
|
comment: 'Message context menu item that stops the text-to-speech playback in progress.',
|
|
});
|
|
const SPEAK_MESSAGE_DESCRIPTOR = msg({
|
|
message: 'Speak message',
|
|
comment: 'Message context menu item that reads the message aloud via text-to-speech.',
|
|
});
|
|
const DEBUG_MESSAGE_DESCRIPTOR = msg({
|
|
message: 'Debug message',
|
|
comment: 'Developer-mode message context menu item that opens an internal debug view for the message.',
|
|
});
|
|
|
|
interface MessageActionMenuOptions {
|
|
onOpenEmojiPicker?: () => void;
|
|
onOpenReactionsSheet?: () => void;
|
|
onClose?: () => void;
|
|
onDelete?: (bypassConfirm?: boolean) => void;
|
|
sourceChannel?: MessagePermissions['channel'] | null;
|
|
quickReactionCount?: number;
|
|
submenuReactionCount?: number;
|
|
}
|
|
|
|
export const messageActionMenuItemIds = {
|
|
addReaction: 'add-reaction',
|
|
viewReactions: 'view_reactions',
|
|
removeAllReactions: 'remove_all_reactions',
|
|
reply: 'reply',
|
|
forward: 'forward',
|
|
edit: 'edit',
|
|
pinMessage: 'message_pin',
|
|
bookmarkMessage: 'message_bookmark',
|
|
suppressEmbeds: 'suppress_embeds',
|
|
markUnread: 'message_mark_unread',
|
|
speakMessage: 'message_speak',
|
|
deleteMessage: 'message_delete',
|
|
reportMessage: 'report_message',
|
|
copyMessage: 'copy_message',
|
|
copyMessageLink: 'message_copy_link',
|
|
copyMessageId: 'message_copy_id',
|
|
debugMessage: 'debug_message',
|
|
} as const;
|
|
|
|
export interface MessageActionMenuData {
|
|
handlers: MessageActionHandlers;
|
|
permissions: MessagePermissions | null;
|
|
groups: Array<MenuGroupType>;
|
|
quickReactionEmojis: Array<FlatEmoji>;
|
|
submenuReactionEmojis: Array<FlatEmoji>;
|
|
quickReactionRowVisible: boolean;
|
|
isFailed: boolean;
|
|
isSaved: boolean;
|
|
}
|
|
|
|
export const useMessageActionMenuData = (
|
|
message: Message,
|
|
options: MessageActionMenuOptions = {},
|
|
): MessageActionMenuData => {
|
|
const {i18n} = useLingui();
|
|
const {
|
|
onOpenEmojiPicker,
|
|
onOpenReactionsSheet,
|
|
onClose,
|
|
onDelete,
|
|
sourceChannel,
|
|
quickReactionCount = 5,
|
|
submenuReactionCount = 16,
|
|
} = options;
|
|
const permissions = useMessagePermissions(message, sourceChannel);
|
|
const handlers = useMemo(
|
|
() => createMessageActionHandlers(message, {i18n, onClose, channel: permissions?.channel ?? sourceChannel}),
|
|
[message, permissions?.channel, sourceChannel, onClose, i18n.locale],
|
|
);
|
|
const isSaved = useMemo(() => SavedMessages.isSaved(message.id), [message.id]);
|
|
const channel = permissions?.channel ?? sourceChannel ?? null;
|
|
const shouldBuildQuickReactions = permissions?.canAddReactions === true && message.state === MessageStates.SENT;
|
|
const reactionEmojiLimit = Math.max(quickReactionCount, submenuReactionCount);
|
|
const reactionEmojis = useQuickReactionEmojis(channel, reactionEmojiLimit, shouldBuildQuickReactions);
|
|
const quickReactionEmojis = useMemo(
|
|
() => reactionEmojis.slice(0, quickReactionCount),
|
|
[reactionEmojis, quickReactionCount],
|
|
);
|
|
const submenuReactionEmojis = useMemo(
|
|
() => reactionEmojis.slice(0, submenuReactionCount),
|
|
[reactionEmojis, submenuReactionCount],
|
|
);
|
|
const developerMode = UserSettings.developerMode;
|
|
const effectiveContent = useMemo(() => getEffectiveContent(message), [message]);
|
|
const copyableMessageText = useMemo(() => getCopyableMessageText(message, i18n), [message, i18n.locale]);
|
|
const canManageMessages = useMemo(
|
|
() =>
|
|
permissions != null &&
|
|
!permissions.isDM &&
|
|
Permission.can(Permissions.MANAGE_MESSAGES, {channelId: message.channelId}),
|
|
[permissions, message.channelId],
|
|
);
|
|
const [isSpeaking, setIsSpeaking] = useState(TtsUtils.isSpeaking());
|
|
const [voiceReady, setVoiceReady] = useState(TtsUtils.hasVoices());
|
|
const supportsInteractiveActions = !isClientSystemMessage(message);
|
|
useEffect(() => {
|
|
if (!TtsUtils.isSupported()) {
|
|
return;
|
|
}
|
|
const interval = setInterval(() => {
|
|
setIsSpeaking(TtsUtils.isSpeaking());
|
|
setVoiceReady(TtsUtils.hasVoices());
|
|
}, 100);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
const handleSpeakMessage = useCallback(() => {
|
|
requestSpeakMessage(message);
|
|
}, [message]);
|
|
const handleReportMessage = useCallback(() => {
|
|
if (!canReportMessage(message)) {
|
|
return;
|
|
}
|
|
if (onClose) {
|
|
ModalCommands.runAfterBottomSheetClose(onClose, () => openReportMessageModal(message));
|
|
return;
|
|
}
|
|
openReportMessageModal(message);
|
|
}, [message, onClose]);
|
|
const handleDebugMessage = useCallback(() => {
|
|
const messageDebugModal = modal(() => (
|
|
<MessageDebugModal
|
|
title={i18n._(MESSAGE_DEBUG_DESCRIPTOR)}
|
|
message={message}
|
|
data-flx="channel.message-action-menu.handle-debug-message.message-debug-modal"
|
|
/>
|
|
));
|
|
if (onClose) {
|
|
ModalCommands.pushWithKeyAfterBottomSheetClose(onClose, messageDebugModal, `message-debug-${message.id}`);
|
|
return;
|
|
}
|
|
ModalCommands.pushWithKey(messageDebugModal, `message-debug-${message.id}`);
|
|
}, [message, onClose, i18n]);
|
|
const reactions = useMessageReactionsSnapshot(message.id);
|
|
const groups = useMemo(() => {
|
|
const reactionActions: Array<MenuItemType> = [];
|
|
const interactionActions: Array<MenuItemType> = [];
|
|
const managementActions: Array<MenuItemType> = [];
|
|
const utilityActions: Array<MenuItemType> = [];
|
|
if (message.state === MessageStates.SENT) {
|
|
if (permissions?.canAddReactions && onOpenEmojiPicker) {
|
|
reactionActions.push({
|
|
id: messageActionMenuItemIds.addReaction,
|
|
icon: <AddReactionIcon size={20} data-flx="channel.message-action-menu.groups.add-reaction-icon" />,
|
|
label: i18n._(ADD_REACTION_DESCRIPTOR),
|
|
onClick: onOpenEmojiPicker,
|
|
shortcut: <KeybindHint action="message_react" data-flx="channel.message-action-menu.groups.keybind-hint" />,
|
|
});
|
|
}
|
|
if (reactions.length > 0 && onOpenReactionsSheet) {
|
|
reactionActions.push({
|
|
id: messageActionMenuItemIds.viewReactions,
|
|
icon: <ViewReactionsIcon size={20} data-flx="channel.message-action-menu.groups.view-reactions-icon" />,
|
|
label: i18n._(VIEW_REACTIONS_DESCRIPTOR),
|
|
onClick: onOpenReactionsSheet,
|
|
});
|
|
}
|
|
if (canManageMessages && reactions.length > 0) {
|
|
reactionActions.push({
|
|
id: messageActionMenuItemIds.removeAllReactions,
|
|
icon: (
|
|
<RemoveAllReactionsIcon size={20} data-flx="channel.message-action-menu.groups.remove-all-reactions-icon" />
|
|
),
|
|
label: i18n._(REMOVE_ALL_REACTIONS_DESCRIPTOR),
|
|
onClick: handlers.handleRemoveAllReactions,
|
|
danger: true,
|
|
});
|
|
}
|
|
interactionActions.push({
|
|
id: messageActionMenuItemIds.markUnread,
|
|
icon: <MarkAsUnreadIcon size={20} data-flx="channel.message-action-menu.groups.mark-as-unread-icon" />,
|
|
label: i18n._(MARK_AS_UNREAD_DESCRIPTOR),
|
|
onClick: handlers.handleMarkAsUnread,
|
|
shortcut: (
|
|
<KeybindHint action="message_mark_unread" data-flx="channel.message-action-menu.groups.keybind-hint--2" />
|
|
),
|
|
});
|
|
if (message.isUserMessage() && supportsInteractiveActions && permissions?.canSendMessages) {
|
|
interactionActions.push({
|
|
id: messageActionMenuItemIds.reply,
|
|
icon: <ReplyIcon size={20} data-flx="channel.message-action-menu.groups.reply-icon" />,
|
|
label: i18n._(REPLY_DESCRIPTOR),
|
|
onClick: handlers.handleReply,
|
|
shortcut: (
|
|
<KeybindHint action="message_reply" data-flx="channel.message-action-menu.groups.keybind-hint--3" />
|
|
),
|
|
});
|
|
}
|
|
if (message.isUserMessage() && supportsInteractiveActions && permissions?.canForwardMessage) {
|
|
interactionActions.push({
|
|
id: messageActionMenuItemIds.forward,
|
|
icon: <ForwardIcon size={20} data-flx="channel.message-action-menu.groups.forward-icon" />,
|
|
label: i18n._(FORWARD_DESCRIPTOR),
|
|
onClick: handlers.handleForward,
|
|
shortcut: (
|
|
<KeybindHint action="message_forward" data-flx="channel.message-action-menu.groups.keybind-hint--4" />
|
|
),
|
|
});
|
|
}
|
|
if (message.isCurrentUserAuthor() && message.isUserMessage() && !message.messageSnapshots) {
|
|
interactionActions.push({
|
|
id: messageActionMenuItemIds.edit,
|
|
icon: <EditMessageIcon size={20} data-flx="channel.message-action-menu.groups.edit-message-icon" />,
|
|
label: i18n._(EDIT_MESSAGE_DESCRIPTOR),
|
|
onClick: handlers.handleEditMessage,
|
|
shortcut: <KeybindHint action="message_edit" data-flx="channel.message-action-menu.groups.keybind-hint--5" />,
|
|
});
|
|
}
|
|
if (message.isUserMessage() && permissions?.canPinMessage) {
|
|
managementActions.push({
|
|
id: messageActionMenuItemIds.pinMessage,
|
|
icon: <PinIcon size={20} data-flx="channel.message-action-menu.groups.pin-icon" />,
|
|
label: message.pinned ? i18n._(UNPIN_MESSAGE_DESCRIPTOR) : i18n._(PIN_MESSAGE_DESCRIPTOR),
|
|
onClick: handlers.handlePinMessage,
|
|
shortcut: <KeybindHint action="message_pin" data-flx="channel.message-action-menu.groups.keybind-hint--6" />,
|
|
});
|
|
}
|
|
if (message.isUserMessage() && supportsInteractiveActions) {
|
|
managementActions.push({
|
|
id: messageActionMenuItemIds.bookmarkMessage,
|
|
icon: <BookmarkIcon size={20} filled={isSaved} data-flx="channel.message-action-menu.groups.bookmark-icon" />,
|
|
label: isSaved ? i18n._(REMOVE_BOOKMARK_DESCRIPTOR) : i18n._(BOOKMARK_MESSAGE_DESCRIPTOR),
|
|
onClick: handlers.handleSaveMessage(isSaved),
|
|
shortcut: (
|
|
<KeybindHint action="message_bookmark" data-flx="channel.message-action-menu.groups.keybind-hint--7" />
|
|
),
|
|
});
|
|
}
|
|
if (permissions?.shouldRenderSuppressEmbeds) {
|
|
managementActions.push({
|
|
id: messageActionMenuItemIds.suppressEmbeds,
|
|
icon: <SuppressEmbedsIcon size={20} data-flx="channel.message-action-menu.groups.suppress-embeds-icon" />,
|
|
label: isEmbedsSuppressed(message)
|
|
? i18n._(UNSUPPRESS_EMBEDS_DESCRIPTOR)
|
|
: i18n._(SUPPRESS_EMBEDS_DESCRIPTOR),
|
|
onClick: handlers.handleToggleSuppressEmbeds,
|
|
shortcut: (
|
|
<KeybindHint action="message_toggle_embeds" data-flx="channel.message-action-menu.groups.keybind-hint--8" />
|
|
),
|
|
});
|
|
}
|
|
if (permissions?.canDeleteMessage && onDelete) {
|
|
managementActions.push({
|
|
id: messageActionMenuItemIds.deleteMessage,
|
|
icon: <DeleteIcon size={20} data-flx="channel.message-action-menu.groups.delete-icon" />,
|
|
label: i18n._(DELETE_MESSAGE_DESCRIPTOR),
|
|
onClick: (event: {shiftKey?: boolean}) => {
|
|
const deleteMessage = () => onDelete(event?.shiftKey === true);
|
|
if (onClose) {
|
|
ModalCommands.runAfterBottomSheetClose(onClose, deleteMessage);
|
|
return;
|
|
}
|
|
deleteMessage();
|
|
},
|
|
danger: true,
|
|
shortcut: (
|
|
<KeybindHint action="message_delete" data-flx="channel.message-action-menu.groups.keybind-hint--9" />
|
|
),
|
|
});
|
|
}
|
|
if (supportsInteractiveActions) {
|
|
utilityActions.push({
|
|
id: messageActionMenuItemIds.copyMessageLink,
|
|
icon: <CopyLinkIcon size={20} data-flx="channel.message-action-menu.groups.copy-link-icon" />,
|
|
label: i18n._(COPY_MESSAGE_LINK_DESCRIPTOR),
|
|
onClick: handlers.handleCopyMessageLink,
|
|
shortcut: (
|
|
<KeybindHint action="message_copy_link" data-flx="channel.message-action-menu.groups.keybind-hint--10" />
|
|
),
|
|
});
|
|
}
|
|
if (copyableMessageText) {
|
|
utilityActions.push({
|
|
id: messageActionMenuItemIds.copyMessage,
|
|
icon: <CopyMessageTextIcon size={20} data-flx="channel.message-action-menu.groups.copy-message-text-icon" />,
|
|
label: i18n._(COPY_MESSAGE_DESCRIPTOR),
|
|
onClick: handlers.handleCopyMessage,
|
|
shortcut: (
|
|
<KeybindHint action="message_copy_text" data-flx="channel.message-action-menu.groups.keybind-hint--11" />
|
|
),
|
|
});
|
|
}
|
|
if (TtsUtils.isSupported() && voiceReady && effectiveContent.trim()) {
|
|
utilityActions.push({
|
|
id: messageActionMenuItemIds.speakMessage,
|
|
icon: isSpeaking ? (
|
|
<StopSpeakingIcon size={20} data-flx="channel.message-action-menu.groups.stop-speaking-icon" />
|
|
) : (
|
|
<SpeakMessageIcon size={20} data-flx="channel.message-action-menu.groups.speak-message-icon" />
|
|
),
|
|
label: isSpeaking ? i18n._(STOP_SPEAKING_DESCRIPTOR) : i18n._(SPEAK_MESSAGE_DESCRIPTOR),
|
|
onClick: handleSpeakMessage,
|
|
closeOnSelect: false,
|
|
shortcut: (
|
|
<KeybindHint action="message_speak" data-flx="channel.message-action-menu.groups.keybind-hint--12" />
|
|
),
|
|
});
|
|
}
|
|
utilityActions.push({
|
|
id: messageActionMenuItemIds.copyMessageId,
|
|
icon: <CopyIdIcon size={20} data-flx="channel.message-action-menu.groups.copy-id-icon" />,
|
|
label: i18n._(COPY_MESSAGE_ID_DESCRIPTOR),
|
|
onClick: handlers.handleCopyMessageId,
|
|
shortcut: (
|
|
<KeybindHint action="message_copy_id" data-flx="channel.message-action-menu.groups.keybind-hint--13" />
|
|
),
|
|
});
|
|
if (developerMode) {
|
|
utilityActions.push({
|
|
id: messageActionMenuItemIds.debugMessage,
|
|
icon: <DebugMessageIcon size={20} data-flx="channel.message-action-menu.groups.debug-message-icon" />,
|
|
label: i18n._(DEBUG_MESSAGE_DESCRIPTOR),
|
|
onClick: handleDebugMessage,
|
|
});
|
|
}
|
|
} else if (message.state === MessageStates.FAILED) {
|
|
interactionActions.push({
|
|
icon: <RetryIcon size={20} data-flx="channel.message-action-menu.groups.retry-icon" />,
|
|
label: i18n._(TRY_AGAIN_DESCRIPTOR),
|
|
onClick: handlers.handleRetryMessage,
|
|
});
|
|
managementActions.push({
|
|
id: messageActionMenuItemIds.deleteMessage,
|
|
icon: <DeleteIcon size={20} data-flx="channel.message-action-menu.groups.delete-icon--2" />,
|
|
label: i18n._(DELETE_MESSAGE_DESCRIPTOR),
|
|
onClick: handlers.handleFailedMessageDelete,
|
|
danger: true,
|
|
});
|
|
}
|
|
const groups: Array<MenuGroupType> = [
|
|
{items: reactionActions},
|
|
{items: interactionActions},
|
|
{items: managementActions},
|
|
{items: utilityActions},
|
|
];
|
|
if (canReportMessage(message)) {
|
|
groups.push({
|
|
items: [
|
|
{
|
|
id: messageActionMenuItemIds.reportMessage,
|
|
icon: <ReportMessageIcon size={20} data-flx="channel.message-action-menu.groups.report-message-icon" />,
|
|
label: i18n._(REPORT_MESSAGE_DESCRIPTOR),
|
|
onClick: handleReportMessage,
|
|
danger: true,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
return groups;
|
|
}, [
|
|
message,
|
|
handlers,
|
|
isSaved,
|
|
onClose,
|
|
onDelete,
|
|
onOpenEmojiPicker,
|
|
onOpenReactionsSheet,
|
|
reactions.length,
|
|
permissions,
|
|
developerMode,
|
|
canManageMessages,
|
|
supportsInteractiveActions,
|
|
isSpeaking,
|
|
voiceReady,
|
|
effectiveContent,
|
|
copyableMessageText,
|
|
handleSpeakMessage,
|
|
handleReportMessage,
|
|
handleDebugMessage,
|
|
i18n.locale,
|
|
]);
|
|
const quickReactionRowVisible =
|
|
permissions?.canAddReactions === true && message.state === MessageStates.SENT && quickReactionEmojis.length > 0;
|
|
return {
|
|
handlers,
|
|
permissions,
|
|
groups,
|
|
quickReactionEmojis,
|
|
submenuReactionEmojis,
|
|
quickReactionRowVisible,
|
|
isFailed: message.state === MessageStates.FAILED,
|
|
isSaved,
|
|
};
|
|
};
|