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

528 lines
16 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 ChannelPinActionCreators from '@app/actions/ChannelPinsActionCreators';
import * as MessageActionCreators from '@app/actions/MessageActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as ReactionActionCreators from '@app/actions/ReactionActionCreators';
import * as ReadStateActionCreators from '@app/actions/ReadStateActionCreators';
import * as SavedMessageActionCreators from '@app/actions/SavedMessageActionCreators';
import * as TextCopyActionCreators from '@app/actions/TextCopyActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {useMaybeMessageViewContext} from '@app/components/channel/MessageViewContext';
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
import {ForwardModal} from '@app/components/modals/ForwardModal';
import {CloudUpload} from '@app/lib/CloudUpload';
import {ComponentDispatch} from '@app/lib/ComponentDispatch';
import type {ChannelRecord} from '@app/records/ChannelRecord';
import type {MessageRecord} from '@app/records/MessageRecord';
import AuthenticationStore from '@app/stores/AuthenticationStore';
import ChannelStore from '@app/stores/ChannelStore';
import GuildMemberStore from '@app/stores/GuildMemberStore';
import GuildStore from '@app/stores/GuildStore';
import GuildVerificationStore from '@app/stores/GuildVerificationStore';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import PermissionStore from '@app/stores/PermissionStore';
import RelationshipStore from '@app/stores/RelationshipStore';
import SavedMessagesStore from '@app/stores/SavedMessagesStore';
import type {UnicodeEmoji} from '@app/types/EmojiTypes';
import {isSystemDmChannel} from '@app/utils/ChannelUtils';
import {buildMessageJumpLink} from '@app/utils/MessageLinkUtils';
import {type ReactionEmoji, toReactionEmoji} from '@app/utils/ReactionUtils';
import TtsUtils from '@app/utils/TtsUtils';
import {
isMessageTypeDeletable,
MessageFlags,
MessageStates,
MessageTypes,
Permissions,
} from '@fluxer/constants/src/ChannelConstants';
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
export function isEmbedsSuppressed(message: MessageRecord): boolean {
return (message.flags & MessageFlags.SUPPRESS_EMBEDS) !== 0;
}
export function isClientSystemMessage(message: MessageRecord): boolean {
return message.isClientSystemMessage();
}
export function canReportMessage(message: MessageRecord): boolean {
if (message.state !== MessageStates.SENT) {
return false;
}
if (message.isCurrentUserAuthor()) {
return false;
}
if (message.author.system) {
return false;
}
return message.type === MessageTypes.DEFAULT || message.type === MessageTypes.REPLY;
}
export function canDeleteAttachmentUtil(message: MessageRecord | undefined): boolean {
if (!message?.isCurrentUserAuthor()) return false;
const channel = ChannelStore.getChannel(message.channelId);
const guild = channel?.guildId ? GuildStore.getGuild(channel.guildId) : null;
const sendMessageDisabled = guild ? (guild.disabledOperations & GuildOperations.SEND_MESSAGE) !== 0 : false;
return !sendMessageDisabled;
}
export function requestOpenReactionPicker(messageId: string): void {
ComponentDispatch.dispatch('EMOJI_PICKER_OPEN', {messageId});
}
export function triggerAddReaction(message: MessageRecord): boolean {
if (isClientSystemMessage(message)) {
return false;
}
const messageElement = document.querySelector<HTMLElement>(`[data-message-id="${message.id}"]`);
if (!messageElement) {
ComponentDispatch.dispatch('EMOJI_PICKER_OPEN', {messageId: message.id});
return false;
}
const addReactionButton = messageElement.querySelector<HTMLButtonElement>(
'[data-action="message-add-reaction-button"]',
);
if (!addReactionButton) {
ComponentDispatch.dispatch('EMOJI_PICKER_OPEN', {messageId: message.id});
return false;
}
addReactionButton.click();
return true;
}
export interface MessagePermissions {
channel: ChannelRecord;
isDM: boolean;
canSendMessages: boolean;
canAddReactions: boolean;
canEditMessage: boolean;
canDeleteMessage: boolean;
canDeleteAttachment: boolean;
canPinMessage: boolean;
canSuppressEmbeds: boolean;
shouldRenderSuppressEmbeds: boolean;
}
function getMessagePermissionsFromStores(message: MessageRecord): MessagePermissions | null {
const channel = ChannelStore.getChannel(message.channelId);
if (!channel) {
return null;
}
const isDM = !channel.guildId;
const isAuthorBlocked = RelationshipStore.isBlocked(message.author.id);
const interactionsBlocked = isSystemDmChannel(channel);
const isClientSystem = isClientSystemMessage(message);
const passesVerification = isDM || GuildVerificationStore.canAccessGuild(channel.guildId || '');
const guild = channel.guildId ? GuildStore.getGuild(channel.guildId) : null;
const sendMessageDisabled = guild ? (guild.disabledOperations & GuildOperations.SEND_MESSAGE) !== 0 : false;
const reactionsDisabled = guild ? (guild.disabledOperations & GuildOperations.REACTIONS) !== 0 : false;
const messageTypeDeletable = isMessageTypeDeletable(message.type);
const currentUserId = AuthenticationStore.currentUserId;
const isCurrentUserTimedOut =
guild && currentUserId ? GuildMemberStore.isUserTimedOut(guild.id, currentUserId) : false;
const canSendMessages =
!isClientSystem &&
!interactionsBlocked &&
(isDM ||
(!sendMessageDisabled &&
PermissionStore.can(Permissions.SEND_MESSAGES, {channelId: message.channelId}) &&
passesVerification));
const canAddReactions =
!isClientSystem &&
!interactionsBlocked &&
!isAuthorBlocked &&
(isDM ||
(!reactionsDisabled &&
PermissionStore.can(Permissions.ADD_REACTIONS, {channelId: message.channelId}) &&
passesVerification &&
!isCurrentUserTimedOut));
const canEditMessage = !interactionsBlocked && !sendMessageDisabled && message.isCurrentUserAuthor();
const canDeleteMessage =
!interactionsBlocked &&
messageTypeDeletable &&
!sendMessageDisabled &&
(message.isCurrentUserAuthor() ||
(isDM ? false : PermissionStore.can(Permissions.MANAGE_MESSAGES, {channelId: message.channelId})));
const canDeleteAttachment = !interactionsBlocked && !sendMessageDisabled && message.isCurrentUserAuthor();
const canPinMessage =
!interactionsBlocked &&
!sendMessageDisabled &&
(isDM ? true : PermissionStore.can(Permissions.PIN_MESSAGES, {channelId: message.channelId}));
const canSuppressEmbeds =
!interactionsBlocked &&
!sendMessageDisabled &&
message.isUserMessage() &&
(message.isCurrentUserAuthor() || (!isDM && canDeleteMessage));
const shouldRenderSuppressEmbeds =
message.isUserMessage() && canSuppressEmbeds && (isEmbedsSuppressed(message) || message.embeds.length > 0);
return {
channel,
isDM,
canSendMessages,
canAddReactions,
canEditMessage,
canDeleteMessage,
canDeleteAttachment,
canPinMessage,
canSuppressEmbeds,
shouldRenderSuppressEmbeds,
};
}
export function useMessagePermissions(message: MessageRecord): MessagePermissions | null {
const context = useMaybeMessageViewContext();
if (context?.previewPermissions) {
return {
channel: context.channel,
...context.previewPermissions,
};
}
return getMessagePermissionsFromStores(message);
}
interface MessageActionHandlersOptions {
i18n: I18n;
onClose?: () => void;
}
export interface MessageActionHandlers {
handleEmojiSelect: (emoji: UnicodeEmoji | ReactionEmoji) => void;
handleCopyMessageId: () => void;
handleCopyMessage: () => void;
handleCopyMessageLink: () => void;
handleSaveMessage: (isSaved: boolean) => (event?: React.MouseEvent | React.KeyboardEvent) => void;
handleToggleSuppressEmbeds: () => void;
handleReply: (event?: React.MouseEvent | React.KeyboardEvent) => void;
handlePinMessage: (event?: React.MouseEvent | React.KeyboardEvent) => void;
handleEditMessage: () => void;
handleRetryMessage: () => void;
handleFailedMessageDelete: () => void;
handleForward: () => void;
handleRemoveAllReactions: () => void;
handleMarkAsUnread: () => void;
}
export function createMessageActionHandlers(
message: MessageRecord,
options: MessageActionHandlersOptions,
): MessageActionHandlers {
const {i18n, onClose} = options;
const handleEmojiSelect = (emoji: UnicodeEmoji | ReactionEmoji) => {
if (isClientSystemMessage(message)) {
return;
}
ReactionActionCreators.addReaction(i18n, message.channelId, message.id, toReactionEmoji(emoji));
};
const handleCopyMessageId = () => {
requestCopyMessageId(message, i18n);
onClose?.();
};
const handleCopyMessage = () => {
if (message.content) {
TextCopyActionCreators.copy(i18n, message.content);
onClose?.();
}
};
const handleCopyMessageLink = () => {
requestCopyMessageLink(message, i18n);
onClose?.();
};
const handleSaveMessage = (isSaved: boolean) => (event?: React.MouseEvent | React.KeyboardEvent) => {
if (isClientSystemMessage(message)) {
onClose?.();
return;
}
if (isSaved) {
SavedMessageActionCreators.remove(i18n, message.id);
} else {
SavedMessageActionCreators.create(i18n, message.channelId, message.id).then(() => {
if (!event?.shiftKey) {
ComponentDispatch.dispatch('SAVED_MESSAGES_OPEN');
}
});
}
onClose?.();
};
const handleToggleSuppressEmbeds = () => {
requestToggleSuppressEmbeds(message, i18n);
onClose?.();
};
const handleReply = (event?: React.MouseEvent | React.KeyboardEvent) => {
const channel = ChannelStore.getChannel(message.channelId)!;
requestMessageReply(message, {
mention: !event?.shiftKey && !message.isCurrentUserAuthor() && channel.guildId != null,
});
onClose?.();
};
const handlePinMessage = (event?: React.MouseEvent | React.KeyboardEvent) => {
onClose?.();
requestMessagePin(message, i18n, {shiftKey: Boolean(event?.shiftKey)});
};
const handleEditMessage = () => {
startMessageEdit(message);
onClose?.();
};
const handleRetryMessage = () => {
if (!message.nonce) {
return;
}
const messageUpload = CloudUpload.getMessageUpload(message.nonce);
const hasAttachments = messageUpload !== null;
const optimisticMessage: Message = {
...message.toJSON(),
state: MessageStates.SENDING,
edited_timestamp: undefined,
attachments: message.attachments ? [...message.attachments] : undefined,
reactions: [],
};
MessageActionCreators.retryLocal(message.channelId, message.id);
MessageActionCreators.createOptimistic(message.channelId, optimisticMessage);
MessageActionCreators.send(message.channelId, {
content: message.content,
nonce: message.nonce,
hasAttachments,
allowedMentions: message._allowedMentions,
messageReference: message.messageReference,
flags: message.flags,
favoriteMemeId: message._favoriteMemeId,
stickers: [...(message.stickers ?? [])],
});
onClose?.();
};
const handleFailedMessageDelete = () => {
MessageActionCreators.deleteLocal(message.channelId, message.id);
onClose?.();
};
const handleForward = () => {
onClose?.();
requestMessageForward(message);
};
const handleRemoveAllReactions = () => {
ReactionActionCreators.removeAllReactions(i18n, message.channelId, message.id);
onClose?.();
};
const handleMarkAsUnread = () => {
requestMarkMessageUnread(message);
onClose?.();
};
return {
handleEmojiSelect,
handleCopyMessageId,
handleCopyMessage,
handleCopyMessageLink,
handleSaveMessage,
handleToggleSuppressEmbeds,
handleReply,
handlePinMessage,
handleEditMessage,
handleRetryMessage,
handleFailedMessageDelete,
handleForward,
handleRemoveAllReactions,
handleMarkAsUnread,
};
}
export function startMessageEdit(message: MessageRecord): void {
if (message.messageSnapshots) {
return;
}
if (MobileLayoutStore.isEnabled()) {
MessageActionCreators.startEditMobile(message.channelId, message.id);
} else {
MessageActionCreators.startEdit(message.channelId, message.id, message.content);
}
}
export function requestDeleteMessage(message: MessageRecord, i18n: I18n, bypassConfirm = false): void {
if (bypassConfirm) {
MessageActionCreators.remove(message.channelId, message.id);
return;
}
MessageActionCreators.showDeleteConfirmation(i18n, {message});
}
export function requestMessagePin(message: MessageRecord, i18n: I18n, options: {shiftKey?: boolean} = {}): void {
if (message.pinned) {
if (options.shiftKey) {
ChannelPinActionCreators.unpin(message.channelId, message.id);
return;
}
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={i18n._(msg`Unpin Message`)}
description={i18n._(msg`Do you want to send this pin back to the future?`)}
message={message}
primaryText={i18n._(msg`Unpin it`)}
onPrimary={() => ChannelPinActionCreators.unpin(message.channelId, message.id)}
/>
)),
);
return;
}
if (options.shiftKey) {
ChannelPinActionCreators.pin(message.channelId, message.id);
return;
}
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={i18n._(msg`Pin it. Pin it good.`)}
description={i18n._(msg`Pin this message to the channel for all to see. Unless ... you're chicken.`)}
message={message}
primaryText={i18n._(msg`Pin it real good`)}
primaryVariant="primary"
onPrimary={() => ChannelPinActionCreators.pin(message.channelId, message.id)}
/>
)),
);
}
export function requestMessageReply(message: MessageRecord, options?: {mention?: boolean}): void {
if (isClientSystemMessage(message)) {
return;
}
const channel = ChannelStore.getChannel(message.channelId);
if (!channel) return;
const shouldMention = options?.mention ?? (!message.isCurrentUserAuthor() && Boolean(channel.guildId));
MessageActionCreators.startReply(message.channelId, message.id, shouldMention);
}
export function requestMessageForward(message: MessageRecord): void {
if (isClientSystemMessage(message)) {
return;
}
ModalActionCreators.push(modal(() => <ForwardModal message={message} />));
}
export function requestCopyMessageText(message: MessageRecord, i18n: I18n): void {
if (!message.content) return;
void TextCopyActionCreators.copy(i18n, message.content);
}
export function requestMarkMessageUnread(message: MessageRecord): void {
ReadStateActionCreators.markAsUnread(message.channelId, message.id);
}
export function requestCopyMessageLink(message: MessageRecord, i18n: I18n): void {
if (isClientSystemMessage(message)) {
return;
}
const channel = ChannelStore.getChannel(message.channelId);
if (!channel) return;
const jumpLink = buildMessageJumpLink({
guildId: channel.guildId,
channelId: message.channelId,
messageId: message.id,
});
void TextCopyActionCreators.copy(i18n, jumpLink);
}
export function requestCopyMessageId(message: MessageRecord, i18n: I18n): void {
void TextCopyActionCreators.copy(i18n, message.id);
}
export function requestToggleBookmark(message: MessageRecord, i18n: I18n): void {
if (isClientSystemMessage(message)) {
return;
}
if (SavedMessagesStore.isSaved(message.id)) {
SavedMessageActionCreators.remove(i18n, message.id);
return;
}
void SavedMessageActionCreators.create(i18n, message.channelId, message.id);
}
export function requestToggleSuppressEmbeds(message: MessageRecord, i18n: I18n): void {
if (isEmbedsSuppressed(message)) {
MessageActionCreators.edit(
message.channelId,
message.id,
undefined,
message.flags & ~MessageFlags.SUPPRESS_EMBEDS,
).then(() => {
ToastActionCreators.createToast({
type: 'success',
children: i18n._(msg`Embeds unsuppressed`),
});
});
return;
}
MessageActionCreators.edit(
message.channelId,
message.id,
undefined,
message.flags | MessageFlags.SUPPRESS_EMBEDS,
).then(() => {
ToastActionCreators.createToast({
type: 'success',
children: i18n._(msg`Embeds suppressed`),
});
});
}
export function requestSpeakMessage(message: MessageRecord): void {
TtsUtils.speakMessage(message.content);
}