mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
1136 lines
41 KiB
TypeScript
1136 lines
41 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
|
import {showDmActionErrorModal} from '@app/features/app/components/alerts/DmActionErrorModal';
|
|
import {FeatureTemporarilyDisabledModal} from '@app/features/app/components/alerts/FeatureTemporarilyDisabledModal';
|
|
import {showGenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModalCommands';
|
|
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
|
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
|
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
|
import Authentication from '@app/features/auth/state/Authentication';
|
|
import Channels from '@app/features/channel/state/Channels';
|
|
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
|
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
|
import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree';
|
|
import {DELETE_MESSAGE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
|
import GuildMembers from '@app/features/member/state/GuildMembers';
|
|
import {
|
|
type MessageFetchCacheHit,
|
|
resolveMessageFetchExecutionDecision,
|
|
resolveMessageFetchPreflightDecision,
|
|
resolveMessageFetchWindowCached,
|
|
} from '@app/features/messaging/commands/MessageFetchStateMachine';
|
|
import {resolveMessagePageState} from '@app/features/messaging/commands/MessagePageStateMachine';
|
|
import {MessageCrosspostLimitModal} from '@app/features/messaging/components/alerts/MessageCrosspostLimitModal';
|
|
import {MessageDeleteFailedModal} from '@app/features/messaging/components/alerts/MessageDeleteFailedModal';
|
|
import {MessageDeleteTooQuickModal} from '@app/features/messaging/components/alerts/MessageDeleteTooQuickModal';
|
|
import {MessageEditFailedModal} from '@app/features/messaging/components/alerts/MessageEditFailedModal';
|
|
import {MessageEditTooQuickModal} from '@app/features/messaging/components/alerts/MessageEditTooQuickModal';
|
|
import {PublishedMessageEditLimitModal} from '@app/features/messaging/components/alerts/PublishedMessageEditLimitModal';
|
|
import type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
|
|
import type {JumpOptions} from '@app/features/messaging/state/ChannelMessages';
|
|
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
|
import MessageEditMobile from '@app/features/messaging/state/MessageEditMobile';
|
|
import MessageQueue from '@app/features/messaging/state/MessageQueue';
|
|
import MessageReferences from '@app/features/messaging/state/MessageReferences';
|
|
import MessageReply from '@app/features/messaging/state/MessageReply';
|
|
import Messages from '@app/features/messaging/state/MessagingMessages';
|
|
import {
|
|
collectMessageModelGuildMemberUserIds,
|
|
collectWireMessageGuildMemberUserIds,
|
|
} from '@app/features/messaging/utils/MessageMemberLoadUtils';
|
|
import {
|
|
type ApiAttachmentMetadata,
|
|
type ApiMessageEditAttachmentMetadata,
|
|
buildMessageEditRequest,
|
|
normalizeMessageContent,
|
|
} from '@app/features/messaging/utils/MessageRequestUtils';
|
|
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
|
|
import * as IARCommands from '@app/features/moderation/commands/IARCommands';
|
|
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
|
import Permission from '@app/features/permissions/state/Permission';
|
|
import {http} from '@app/features/platform/transport/RestTransport';
|
|
import {HttpError} from '@app/features/platform/types/EndpointError';
|
|
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
|
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
|
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
|
import {failureCode, failureMessage} from '@app/features/platform/utils/ResponseInspection';
|
|
import * as ReadStateCommands from '@app/features/read_state/commands/ReadStateCommands';
|
|
import ReadStates from '@app/features/read_state/state/ReadStates';
|
|
import * as SlowmodeCommands from '@app/features/slowmode/commands/SlowmodeCommands';
|
|
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
|
import {modal} from '@app/features/ui/commands/ModalCommands';
|
|
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
|
import {Switch} from '@app/features/ui/components/form/FormSwitch';
|
|
import {blockIfAccountLimited, handleAccountLimitedError} from '@app/features/user/utils/AccountLimitUtils';
|
|
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
|
import {MessageFlags, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
|
import type {JumpType} from '@fluxer/constants/src/JumpConstants';
|
|
import {MAX_MESSAGES_PER_CHANNEL} from '@fluxer/constants/src/LimitConstants';
|
|
import type {MessageId} from '@fluxer/schema/src/branded/WireIds';
|
|
import type {
|
|
AllowedMentions,
|
|
MessageReference,
|
|
MessageStickerItem,
|
|
Message as WireMessage,
|
|
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
|
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
|
|
import type {I18n} from '@lingui/core';
|
|
import {msg} from '@lingui/core/macro';
|
|
|
|
const ARE_YOU_SURE_YOU_WANT_TO_DELETE_THIS_DESCRIPTOR = msg({
|
|
message: "Delete this message? Can't be undone.",
|
|
comment: 'Error message in the messaging commands. Keep the tone plain and specific.',
|
|
});
|
|
const DELETE_DESCRIPTOR = msg({
|
|
message: 'Delete',
|
|
comment: 'Button or menu action label in the messaging commands. Keep it concise. Keep the tone plain and specific.',
|
|
});
|
|
const ALSO_REPORT_TO_SAFETY_TEAM_DESCRIPTOR = msg({
|
|
message: 'Also report this message to the {productName} Safety Team',
|
|
comment:
|
|
'Toggle-switch label in the moderator delete-message confirmation dialog. When enabled, the message is reported (category: other) before being deleted. {productName} is the product name (e.g., Fluxer).',
|
|
});
|
|
const DELETE_PUBLISHED_MESSAGE_BODY_DESCRIPTOR = msg({
|
|
message:
|
|
"Delete this message? Can't be undone. This message is also removed from every community that follows this channel.",
|
|
comment:
|
|
'Body of the delete confirmation for a message that was published from an announcement channel to the channels that follow it.',
|
|
});
|
|
const EDIT_PUBLISHED_MESSAGE_TITLE_DESCRIPTOR = msg({
|
|
message: 'Edit published message?',
|
|
comment: 'Title of the confirmation shown before saving an edit to a message published from an announcement channel.',
|
|
});
|
|
const EDIT_PUBLISHED_MESSAGE_BODY_DESCRIPTOR = msg({
|
|
message:
|
|
'Your changes reach every community that follows this channel, which can take a minute. Each published message allows 3 quick edits, then 1 every 20 minutes.',
|
|
comment: 'Body of the confirmation shown before saving an edit to a message published from an announcement channel.',
|
|
});
|
|
const SAVE_EDIT_DESCRIPTOR = msg({
|
|
message: 'Save',
|
|
comment: 'Confirm button on the alert shown before saving an edit to a published announcement message.',
|
|
});
|
|
const MESSAGE_PUBLISHED_DESCRIPTOR = msg({
|
|
message: 'Message published',
|
|
comment: 'Toast shown after a message in an announcement channel was published to the channels that follow it.',
|
|
});
|
|
const MESSAGE_ALREADY_PUBLISHED_DESCRIPTOR = msg({
|
|
message: 'This message has already been published',
|
|
comment: 'Toast shown when someone tries to publish an announcement message that was already published.',
|
|
});
|
|
const COULD_NOT_PUBLISH_MESSAGE_DESCRIPTOR = msg({
|
|
message: "Couldn't publish this message",
|
|
comment: 'Title of the error alert shown when publishing an announcement message fails.',
|
|
});
|
|
const TRY_PUBLISHING_AGAIN_DESCRIPTOR = msg({
|
|
message: 'Something went wrong while publishing. Try again in a moment.',
|
|
comment: 'Fallback body of the error alert shown when publishing an announcement message fails.',
|
|
});
|
|
const logger = new Logger('MessageCommands');
|
|
const MESSAGE_EDIT_MAX_RETRIES = 5;
|
|
const MESSAGE_EDIT_TIMEOUT_MS = 30_000;
|
|
const pendingDeletePromises = new Map<string, Promise<void>>();
|
|
const pendingFetchPromises = new Map<string, Promise<Array<WireMessage>>>();
|
|
|
|
export interface ForwardMediaSelection {
|
|
attachmentIds?: ReadonlyArray<string>;
|
|
embedIndices?: ReadonlyArray<number>;
|
|
}
|
|
|
|
interface ForwardMessageReference {
|
|
message_id: string;
|
|
channel_id: string;
|
|
guild_id?: string | null;
|
|
attachment_ids?: ReadonlyArray<string>;
|
|
embed_indices?: ReadonlyArray<number>;
|
|
}
|
|
|
|
export interface JumpToMessageOptions {
|
|
channelId: string;
|
|
messageId: string;
|
|
flash?: boolean;
|
|
offset?: number;
|
|
returnToMessageId?: string | null;
|
|
returnChannelId?: string | null;
|
|
returnGuildId?: string | null;
|
|
jumpType?: JumpType;
|
|
}
|
|
|
|
interface FetchMessagesOptions {
|
|
throwOnError?: boolean;
|
|
staleRefetch?: boolean;
|
|
}
|
|
|
|
interface MessagePageState {
|
|
isBefore: boolean;
|
|
isAfter: boolean;
|
|
hasMoreBefore: boolean;
|
|
hasMoreAfter: boolean;
|
|
}
|
|
|
|
function shouldBlockMessageFetch(channelId: string): boolean {
|
|
const channel = Channels.getChannel(channelId);
|
|
if (!channel || channel.isPrivate()) {
|
|
return false;
|
|
}
|
|
return GuildMatureContentAgree.shouldShowGate({channelId: channel.id, guildId: channel.guildId ?? null});
|
|
}
|
|
|
|
function makeFetchKey(
|
|
channelId: string,
|
|
before: string | null,
|
|
after: string | null,
|
|
limit: number,
|
|
jump?: JumpOptions,
|
|
options?: FetchMessagesOptions,
|
|
): string {
|
|
const SEP = '\x1f';
|
|
const throwOnError = options?.throwOnError ? '1' : '0';
|
|
const staleRefetch = options?.staleRefetch ? '1' : '0';
|
|
if (!jump) {
|
|
return `${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}${SEP}${staleRefetch}`;
|
|
}
|
|
return (
|
|
`${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}${SEP}${staleRefetch}${SEP}` +
|
|
`${jump.present ? '1' : '0'}${SEP}${jump.messageId ?? ''}${SEP}${jump.offset ?? 0}${SEP}` +
|
|
`${jump.flash ? '1' : '0'}${SEP}${jump.returnToMessageId ?? ''}${SEP}` +
|
|
`${jump.returnChannelId ?? ''}${SEP}${jump.returnGuildId ?? ''}${SEP}${jump.jumpType ?? ''}`
|
|
);
|
|
}
|
|
|
|
async function requestMissingGuildMembers(channelId: string, messages: Array<WireMessage>): Promise<void> {
|
|
const channel = Channels.getChannel(channelId);
|
|
if (!channel?.guildId) {
|
|
return;
|
|
}
|
|
const guildId = channel.guildId;
|
|
const userIds = collectWireMessageGuildMemberUserIds(messages, Authentication.currentUserId);
|
|
if (userIds.length === 0) {
|
|
return;
|
|
}
|
|
await GuildMembers.ensureMembersLoadedForMessages(guildId, userIds);
|
|
}
|
|
|
|
async function applyDeveloperFetchDelay(): Promise<void> {
|
|
if (!DeveloperOptions.slowMessageLoad) {
|
|
return;
|
|
}
|
|
logger.debug('Slow message load enabled, delaying by 3 seconds');
|
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
}
|
|
|
|
function handleForcedMessageLoadFailure(channelId: string, jump?: JumpOptions): Array<WireMessage> {
|
|
logger.debug('Force fail message loads enabled, simulating failure');
|
|
Messages.handleLoadMessages({channelId, jump});
|
|
Messages.handleLoadMessagesFailure({channelId});
|
|
return [];
|
|
}
|
|
|
|
async function requestChannelMessages(
|
|
channelId: string,
|
|
before: string | null,
|
|
after: string | null,
|
|
limit: number,
|
|
jump?: JumpOptions,
|
|
): Promise<Array<WireMessage>> {
|
|
const around = jump?.messageId;
|
|
const response = await http.get<Array<WireMessage>>(Endpoints.CHANNEL_MESSAGES(channelId), {
|
|
query: {before, after, limit, around: around ?? null},
|
|
retries: 2,
|
|
});
|
|
return response.body ?? [];
|
|
}
|
|
|
|
function calculateMessagePageState(
|
|
channelId: string,
|
|
before: string | null,
|
|
after: string | null,
|
|
limit: number,
|
|
messages: Array<WireMessage>,
|
|
jump?: JumpOptions,
|
|
): MessagePageState {
|
|
const around = jump?.messageId;
|
|
const targetIndex = around == null ? -1 : messages.findIndex((msg: WireMessage) => msg.id === around);
|
|
const knownLatestMessageId =
|
|
around == null
|
|
? null
|
|
: (ReadStates.lastMessageId(channelId) ?? Channels.getChannel(channelId)?.lastMessageId ?? null);
|
|
const newestFetchedMessageId = messages[0]?.id ?? null;
|
|
const pageState = resolveMessagePageState({
|
|
before,
|
|
after,
|
|
limit,
|
|
messageCount: messages.length,
|
|
aroundMessageId: around ?? null,
|
|
aroundTargetIndex: targetIndex,
|
|
newestFetchedMessageId,
|
|
knownLatestMessageId,
|
|
});
|
|
if (around) {
|
|
if (pageState.shouldWarnMissingAroundTarget) {
|
|
logger.warn(`Target message ${around} not found in response!`);
|
|
} else if (pageState.aroundDebug != null) {
|
|
const debug = pageState.aroundDebug;
|
|
logger.debug(
|
|
`Jump to message ${around}: targetIndex=${targetIndex}, messagesNewer=${debug.messagesNewerThanTarget}, messagesOlder=${debug.messagesOlderThanTarget}, expectedNewer=${debug.expectedNewer}, expectedOlder=${debug.expectedOlder}, pageFilled=${debug.pageFilled}, hasMoreBefore=${pageState.hasMoreBefore}, hasMoreAfter=${pageState.hasMoreAfter}, limit=${limit}, knownLatestMessageId=${knownLatestMessageId}, newestFetched=${newestFetchedMessageId}`,
|
|
);
|
|
}
|
|
}
|
|
return {
|
|
isBefore: pageState.isBefore,
|
|
isAfter: pageState.isAfter,
|
|
hasMoreBefore: pageState.hasMoreBefore,
|
|
hasMoreAfter: pageState.hasMoreAfter,
|
|
};
|
|
}
|
|
|
|
function handleMessageFetchSuccess(
|
|
channelId: string,
|
|
messages: Array<WireMessage>,
|
|
pageState: MessagePageState,
|
|
cached: boolean,
|
|
jump?: JumpOptions,
|
|
): void {
|
|
Messages.handleLoadMessagesSuccess({
|
|
channelId,
|
|
messages,
|
|
isBefore: pageState.isBefore,
|
|
isAfter: pageState.isAfter,
|
|
hasMoreBefore: pageState.hasMoreBefore,
|
|
hasMoreAfter: pageState.hasMoreAfter,
|
|
cached,
|
|
jump,
|
|
});
|
|
ReadStates.handleLoadMessages({
|
|
channelId,
|
|
isAfter: pageState.isAfter,
|
|
messages,
|
|
});
|
|
MessageReferences.handleMessagesFetchSuccess(channelId, messages);
|
|
void requestMissingGuildMembers(channelId, messages);
|
|
}
|
|
|
|
export async function ensureMembersForMessages(messages: ReadonlyArray<MessageModel>): Promise<void> {
|
|
const currentUserId = Authentication.currentUserId;
|
|
const byGuild = new Map<string, Set<string>>();
|
|
for (const msg of messages) {
|
|
if (!msg.guildId) continue;
|
|
const userIds = collectMessageModelGuildMemberUserIds([msg], currentUserId);
|
|
if (userIds.length === 0) continue;
|
|
let set = byGuild.get(msg.guildId);
|
|
if (!set) {
|
|
set = new Set();
|
|
byGuild.set(msg.guildId, set);
|
|
}
|
|
for (const userId of userIds) {
|
|
set.add(userId);
|
|
}
|
|
}
|
|
if (byGuild.size === 0) return;
|
|
await Promise.all(
|
|
Array.from(byGuild, ([guildId, ids]) => GuildMembers.ensureMembersLoadedForMessages(guildId, Array.from(ids))),
|
|
);
|
|
}
|
|
|
|
interface SendMessageParams {
|
|
content: string;
|
|
nonce: string;
|
|
hasAttachments?: boolean;
|
|
allowedMentions?: AllowedMentions;
|
|
messageReference?: MessageReference;
|
|
flags?: number;
|
|
favoriteMemeId?: string;
|
|
stickers?: Array<MessageStickerItem>;
|
|
tts?: boolean;
|
|
}
|
|
|
|
export function jumpToLiveEdge(channelId: string, limit = MAX_MESSAGES_PER_CHANNEL): void {
|
|
NavigationCommands.clearMessageIdForChannel(channelId);
|
|
logger.debug(`Jumping to present in channel ${channelId}`);
|
|
ReadStateCommands.clearStickyUnread(channelId);
|
|
const jump: JumpOptions = {
|
|
present: true,
|
|
};
|
|
if (Messages.hasNewestMessages(channelId)) {
|
|
Messages.handleLoadMessagesSuccessCached({channelId, jump, limit});
|
|
} else {
|
|
fetchMessages(channelId, null, null, limit, jump);
|
|
}
|
|
}
|
|
|
|
export function jumpToMessage({
|
|
channelId,
|
|
messageId,
|
|
flash = true,
|
|
offset,
|
|
returnToMessageId,
|
|
returnChannelId,
|
|
returnGuildId,
|
|
jumpType,
|
|
}: JumpToMessageOptions): void {
|
|
logger.debug(`Jumping to message ${messageId} in channel ${channelId}`);
|
|
fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL, {
|
|
messageId: messageId as MessageId,
|
|
flash,
|
|
offset,
|
|
returnToMessageId: returnToMessageId as MessageId | null | undefined,
|
|
returnChannelId,
|
|
returnGuildId,
|
|
jumpType,
|
|
});
|
|
}
|
|
|
|
function getMessageFetchCacheHit(
|
|
channelId: string,
|
|
before: string | null,
|
|
after: string | null,
|
|
jump?: JumpOptions,
|
|
): MessageFetchCacheHit | null {
|
|
const messages = Messages.getMessages(channelId);
|
|
if (!messages.ready || messages.cached) {
|
|
return null;
|
|
}
|
|
if (jump?.messageId && messages.has(jump.messageId, false)) {
|
|
return 'jump';
|
|
}
|
|
if (before && messages.canServeOlderFrom(before)) {
|
|
return 'before';
|
|
}
|
|
if (after && messages.canServeNewerFrom(after)) {
|
|
return 'after';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function applyMessageFetchCacheHit(
|
|
channelId: string,
|
|
cacheHit: MessageFetchCacheHit,
|
|
before: string | null,
|
|
after: string | null,
|
|
limit: number,
|
|
jump?: JumpOptions,
|
|
): void {
|
|
switch (cacheHit) {
|
|
case 'jump':
|
|
Messages.handleLoadMessagesSuccessCached({channelId, jump, limit});
|
|
return;
|
|
case 'before':
|
|
Messages.handleLoadMessagesSuccessCached({channelId, before: before ?? undefined, limit});
|
|
return;
|
|
case 'after':
|
|
Messages.handleLoadMessagesSuccessCached({channelId, after: after ?? undefined, limit});
|
|
return;
|
|
}
|
|
}
|
|
|
|
export async function fetchMessages(
|
|
channelId: string,
|
|
before: string | null,
|
|
after: string | null,
|
|
limit: number,
|
|
jump?: JumpOptions,
|
|
options?: FetchMessagesOptions,
|
|
): Promise<Array<WireMessage>> {
|
|
const key = makeFetchKey(channelId, before, after, limit, jump, options);
|
|
const inFlight = pendingFetchPromises.get(key);
|
|
const preflightDecision = resolveMessageFetchPreflightDecision({
|
|
hasInFlightRequest: inFlight != null,
|
|
shouldBlockForGate: shouldBlockMessageFetch(channelId),
|
|
cacheHit: getMessageFetchCacheHit(channelId, before, after, jump),
|
|
});
|
|
switch (preflightDecision.type) {
|
|
case 'useInFlightRequest':
|
|
logger.debug(`Using in-flight fetchMessages for channel ${channelId} (deduped)`);
|
|
return inFlight as Promise<Array<WireMessage>>;
|
|
case 'blockForGate':
|
|
logger.debug(`Skipping message fetch for gated channel ${channelId}`);
|
|
Messages.handleLoadMessagesBlocked({channelId});
|
|
return [];
|
|
case 'useCache':
|
|
applyMessageFetchCacheHit(channelId, preflightDecision.cacheHit, before, after, limit, jump);
|
|
return [];
|
|
case 'startFetch':
|
|
break;
|
|
}
|
|
const promise = (async () => {
|
|
await applyDeveloperFetchDelay();
|
|
const executionDecision = resolveMessageFetchExecutionDecision({
|
|
forceFailure: DeveloperOptions.forceFailMessageLoads,
|
|
});
|
|
if (executionDecision.type === 'simulateFailure') {
|
|
return handleForcedMessageLoadFailure(channelId, jump);
|
|
}
|
|
Messages.handleLoadMessages({channelId, jump});
|
|
const connectedAtRequest = GatewayConnection.isConnected;
|
|
const epochAtRequest = GatewayConnection.connectionEpoch;
|
|
try {
|
|
const timeStart = Date.now();
|
|
logger.debug(`Fetching messages for channel ${channelId}`);
|
|
const messages = await requestChannelMessages(channelId, before, after, limit, jump);
|
|
const cached = resolveMessageFetchWindowCached({
|
|
connectedAtRequest,
|
|
connectedAtResponse: GatewayConnection.isConnected,
|
|
epochAtRequest,
|
|
epochAtResponse: GatewayConnection.connectionEpoch,
|
|
});
|
|
const pageState = calculateMessagePageState(channelId, before, after, limit, messages, jump);
|
|
logger.info(`Fetched ${messages.length} messages for channel ${channelId}, took ${Date.now() - timeStart}ms`);
|
|
handleMessageFetchSuccess(channelId, messages, pageState, cached, jump);
|
|
return messages;
|
|
} catch (error) {
|
|
logger.error(`Failed to fetch messages for channel ${channelId}:`, error);
|
|
Messages.handleLoadMessagesFailure({channelId});
|
|
if (options?.throwOnError) {
|
|
throw error;
|
|
}
|
|
return [];
|
|
}
|
|
})();
|
|
pendingFetchPromises.set(key, promise);
|
|
promise.finally(() => pendingFetchPromises.delete(key));
|
|
return promise;
|
|
}
|
|
|
|
interface SequentialSendEntry {
|
|
task: () => Promise<RestResponse<WireMessage> | undefined>;
|
|
resolve: (value: RestResponse<WireMessage> | undefined) => void;
|
|
}
|
|
|
|
interface ChannelSendOrderState {
|
|
nextOrder: number;
|
|
nextExpected: number;
|
|
pending: Map<number, SequentialSendEntry>;
|
|
processing: boolean;
|
|
channelId: string;
|
|
}
|
|
|
|
const channelSendOrders = new Map<string, ChannelSendOrderState>();
|
|
|
|
function getOrCreateChannelState(channelId: string): ChannelSendOrderState {
|
|
let state = channelSendOrders.get(channelId);
|
|
if (!state) {
|
|
state = {nextOrder: 0, nextExpected: 0, pending: new Map(), processing: false, channelId};
|
|
channelSendOrders.set(channelId, state);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function orderedSendImmediately(
|
|
channelId: string,
|
|
order: number,
|
|
task: () => Promise<RestResponse<WireMessage> | undefined>,
|
|
): Promise<RestResponse<WireMessage> | undefined> {
|
|
return new Promise<RestResponse<WireMessage> | undefined>((resolve) => {
|
|
const state = getOrCreateChannelState(channelId);
|
|
state.pending.set(order, {task, resolve});
|
|
void processSequentialQueue(state);
|
|
});
|
|
}
|
|
|
|
async function processSequentialQueue(state: ChannelSendOrderState): Promise<void> {
|
|
if (state.processing) return;
|
|
state.processing = true;
|
|
try {
|
|
while (state.pending.has(state.nextExpected)) {
|
|
const entry = state.pending.get(state.nextExpected)!;
|
|
state.pending.delete(state.nextExpected);
|
|
state.nextExpected++;
|
|
const result = await entry.task();
|
|
entry.resolve(result);
|
|
}
|
|
} finally {
|
|
state.processing = false;
|
|
if (state.pending.size === 0 && state.nextExpected === state.nextOrder) {
|
|
channelSendOrders.delete(state.channelId);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function prepareSendAttachments(
|
|
channelId: string,
|
|
params: SendMessageParams,
|
|
): Promise<{attachments?: Array<ApiAttachmentMetadata>; files?: Array<File>} | null> {
|
|
if (!params.hasAttachments) {
|
|
return {};
|
|
}
|
|
logger.debug(`Preparing attachments for channel ${channelId}`);
|
|
const prepared = await MessageQueue.prepareAttachmentsForSend({
|
|
channelId,
|
|
nonce: params.nonce,
|
|
favoriteMemeId: params.favoriteMemeId,
|
|
});
|
|
if (!prepared) {
|
|
return null;
|
|
}
|
|
return {attachments: prepared.attachments, files: prepared.files};
|
|
}
|
|
|
|
function nextChannelOrder(channelId: string): number {
|
|
return getOrCreateChannelState(channelId).nextOrder++;
|
|
}
|
|
|
|
export async function send(channelId: string, params: SendMessageParams): Promise<WireMessage | null> {
|
|
if (!MessageQueue.consumeLocalSendReservation(channelId, params.nonce)) {
|
|
MessageQueue.rejectLocalRateLimitedSend(channelId, params.nonce, params.hasAttachments);
|
|
return null;
|
|
}
|
|
const sendOrder = Accessibility.sequentialFileSend && params.hasAttachments ? nextChannelOrder(channelId) : -1;
|
|
const prepared = await prepareSendAttachments(channelId, params);
|
|
if (!prepared) {
|
|
if (Accessibility.sequentialFileSend) {
|
|
orderedSendImmediately(channelId, sendOrder, () => Promise.resolve(undefined));
|
|
}
|
|
return null;
|
|
}
|
|
const payload = {
|
|
type: 'send' as const,
|
|
channelId,
|
|
nonce: params.nonce,
|
|
content: params.content,
|
|
hasAttachments: params.hasAttachments,
|
|
preparedAttachments: prepared.attachments,
|
|
preparedFiles: prepared.files,
|
|
allowedMentions: params.allowedMentions,
|
|
messageReference: params.messageReference,
|
|
flags: params.flags,
|
|
favoriteMemeId: params.favoriteMemeId,
|
|
stickers: params.stickers,
|
|
tts: params.tts,
|
|
};
|
|
if (params.hasAttachments) {
|
|
logger.debug(`Sending attachment message immediately for channel ${channelId}`);
|
|
const result = Accessibility.sequentialFileSend
|
|
? await orderedSendImmediately(channelId, sendOrder, () => MessageQueue.sendImmediately(payload))
|
|
: await MessageQueue.sendImmediately(payload);
|
|
if (result?.body) {
|
|
logger.debug(`Attachment message sent successfully in channel ${channelId}`);
|
|
Messages.handleIncomingMessage({channelId, message: result.body});
|
|
return result.body;
|
|
}
|
|
return null;
|
|
}
|
|
return new Promise<WireMessage | null>((resolve) => {
|
|
logger.debug(`Enqueueing message for channel ${channelId}`);
|
|
MessageQueue.enqueue(payload, (result, error) => {
|
|
if (result?.body) {
|
|
logger.debug(`Message sent successfully in channel ${channelId}`);
|
|
Messages.handleIncomingMessage({channelId, message: result.body});
|
|
resolve(result.body);
|
|
} else {
|
|
if (error) {
|
|
logger.debug(`Message send failed in channel ${channelId}`, error);
|
|
}
|
|
resolve(null);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export function reserveSend(channelId: string, nonce: string): boolean {
|
|
return MessageQueue.reserveLocalSend(channelId, nonce);
|
|
}
|
|
|
|
function showDeleteFailureModal(error: unknown, messageId: string): void {
|
|
if (error instanceof HttpError) {
|
|
const {status} = error;
|
|
const errorCode = failureCode(error);
|
|
if (status === 429) {
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<MessageDeleteTooQuickModal data-flx="messaging.message-commands.show-delete-failure-modal.message-delete-too-quick-modal" />
|
|
)),
|
|
);
|
|
} else if (status === 403 && errorCode === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) {
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<FeatureTemporarilyDisabledModal data-flx="messaging.message-commands.show-delete-failure-modal.feature-temporarily-disabled-modal" />
|
|
)),
|
|
);
|
|
} else if (status === 404) {
|
|
logger.debug(`Message ${messageId} was already deleted (404 response)`);
|
|
} else {
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<MessageDeleteFailedModal data-flx="messaging.message-commands.show-delete-failure-modal.message-delete-failed-modal" />
|
|
)),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<MessageDeleteFailedModal data-flx="messaging.message-commands.show-delete-failure-modal.message-delete-failed-modal--2" />
|
|
)),
|
|
);
|
|
}
|
|
|
|
function showPublishedEditLimitModal(error: unknown): boolean {
|
|
if (!(error instanceof HttpError) || failureCode(error) !== APIErrorCodes.PUBLISHED_MESSAGE_EDIT_RATE_LIMITED) {
|
|
return false;
|
|
}
|
|
const retryAfterMs = resolveRetryAfterMs(error);
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<PublishedMessageEditLimitModal
|
|
retryAfter={retryAfterMs === null ? undefined : Math.ceil(retryAfterMs / 1000)}
|
|
data-flx="messaging.message-commands.published-message-edit-limit-modal"
|
|
/>
|
|
)),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
function showEditFailureModal(error: unknown): void {
|
|
if (handleAccountLimitedError(error)) {
|
|
return;
|
|
}
|
|
if (showPublishedEditLimitModal(error)) {
|
|
return;
|
|
}
|
|
if (error instanceof HttpError) {
|
|
const errorCode = failureCode(error);
|
|
if (error.status === 429) {
|
|
const retryAfterMs = resolveRetryAfterMs(error);
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<MessageEditTooQuickModal
|
|
retryAfter={retryAfterMs === null ? undefined : Math.ceil(retryAfterMs / 1000)}
|
|
data-flx="messaging.message-commands.message-edit-too-quick-modal"
|
|
/>
|
|
)),
|
|
);
|
|
return;
|
|
}
|
|
if (error.status === 403 && errorCode === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) {
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<FeatureTemporarilyDisabledModal data-flx="messaging.message-commands.message-edit-feature-temporarily-disabled-modal" />
|
|
)),
|
|
);
|
|
return;
|
|
}
|
|
if (errorCode === APIErrorCodes.CONTENT_BLOCKED) {
|
|
void import('@app/features/auth/components/ContentBlockedHandler').then((module) =>
|
|
module.showContentBlockedModal(),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
if (failureCode(error) === APIErrorCodes.NEW_CONVERSATIONS_LIMITED) {
|
|
showDmActionErrorModal(error);
|
|
return;
|
|
}
|
|
ModalCommands.push(
|
|
modal(() => <MessageEditFailedModal data-flx="messaging.message-commands.message-edit-failed-modal" />),
|
|
);
|
|
}
|
|
|
|
export async function edit(
|
|
channelId: string,
|
|
messageId: string,
|
|
content?: string,
|
|
flags?: number,
|
|
allowedMentions?: AllowedMentions,
|
|
attachments?: Array<ApiMessageEditAttachmentMetadata>,
|
|
): Promise<WireMessage | null> {
|
|
logger.debug(`Editing message ${messageId} in channel ${channelId}`);
|
|
const isPublished = Messages.getMessage(channelId, messageId)?.isCrossposted === true;
|
|
try {
|
|
const response = await http.patch<WireMessage>(Endpoints.CHANNEL_MESSAGE(channelId, messageId), {
|
|
body: buildMessageEditRequest({content, flags, allowedMentions, attachments}),
|
|
mode: isPublished ? 'strict' : 'auto-retry',
|
|
retries: MESSAGE_EDIT_MAX_RETRIES,
|
|
timeoutMs: MESSAGE_EDIT_TIMEOUT_MS,
|
|
suppressContentBlockedModal: true,
|
|
});
|
|
logger.debug(`Message edited successfully: ${messageId} in channel ${channelId}`);
|
|
return response.body ?? null;
|
|
} catch (error) {
|
|
logger.error(`Message edit failed: ${messageId} in channel ${channelId}`, error);
|
|
showEditFailureModal(error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function remove(channelId: string, messageId: string): Promise<void> {
|
|
const pendingPromise = pendingDeletePromises.get(messageId);
|
|
if (pendingPromise) {
|
|
logger.debug(`Using in-flight delete request for message ${messageId}`);
|
|
return pendingPromise;
|
|
}
|
|
const deletePromise = (async () => {
|
|
try {
|
|
logger.debug(`Deleting message ${messageId} in channel ${channelId}`);
|
|
await http.delete(Endpoints.CHANNEL_MESSAGE(channelId, messageId));
|
|
logger.debug(`Successfully deleted message ${messageId} in channel ${channelId}`);
|
|
} catch (error) {
|
|
logger.error(`Failed to delete message ${messageId} in channel ${channelId}:`, error);
|
|
showDeleteFailureModal(error, messageId);
|
|
throw error;
|
|
} finally {
|
|
pendingDeletePromises.delete(messageId);
|
|
}
|
|
})();
|
|
pendingDeletePromises.set(messageId, deletePromise);
|
|
return deletePromise;
|
|
}
|
|
|
|
interface ShowDeleteConfirmationOptions {
|
|
message: MessageModel;
|
|
onDelete?: () => void;
|
|
showShiftBypassConfirmationTip?: boolean;
|
|
suppressSafetyTeamReportToggle?: boolean;
|
|
}
|
|
|
|
export function showDeleteConfirmation(
|
|
i18n: I18n,
|
|
{
|
|
message,
|
|
onDelete,
|
|
showShiftBypassConfirmationTip = false,
|
|
suppressSafetyTeamReportToggle = false,
|
|
}: ShowDeleteConfirmationOptions,
|
|
): void {
|
|
const showSafetyTeamReportToggle =
|
|
!suppressSafetyTeamReportToggle &&
|
|
message.author.id !== Authentication.currentUserId &&
|
|
Permission.can(Permissions.MANAGE_MESSAGES, {channelId: message.channelId});
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<ConfirmModal
|
|
title={i18n._(DELETE_MESSAGE_DESCRIPTOR)}
|
|
description={
|
|
message.isCrossposted
|
|
? i18n._(DELETE_PUBLISHED_MESSAGE_BODY_DESCRIPTOR)
|
|
: i18n._(ARE_YOU_SURE_YOU_WANT_TO_DELETE_THIS_DESCRIPTOR)
|
|
}
|
|
message={message}
|
|
primaryText={i18n._(DELETE_DESCRIPTOR)}
|
|
primaryVariant="danger"
|
|
toggleSwitchContent={
|
|
showSafetyTeamReportToggle ? (
|
|
<Switch
|
|
value={false}
|
|
onChange={() => {}}
|
|
label={i18n._(ALSO_REPORT_TO_SAFETY_TEAM_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
|
compact
|
|
data-flx="messaging.message-commands.show-delete-confirmation.switch"
|
|
/>
|
|
) : undefined
|
|
}
|
|
onPrimary={async (alsoReportToSafetyTeam) => {
|
|
if (alsoReportToSafetyTeam) {
|
|
try {
|
|
await IARCommands.reportMessage(message.channelId, message.id, 'other');
|
|
} catch (error) {
|
|
logger.error('Failed to also-report message before deletion:', error);
|
|
}
|
|
}
|
|
remove(message.channelId, message.id);
|
|
onDelete?.();
|
|
}}
|
|
showShiftBypassConfirmationTip={showShiftBypassConfirmationTip}
|
|
data-flx="messaging.message-commands.show-delete-confirmation.confirm-modal"
|
|
/>
|
|
)),
|
|
);
|
|
}
|
|
|
|
export function confirmPublishedMessageEdit(i18n: I18n, message: MessageModel, submit: () => void): void {
|
|
if (!message.isCrossposted) {
|
|
submit();
|
|
return;
|
|
}
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<ConfirmModal
|
|
title={i18n._(EDIT_PUBLISHED_MESSAGE_TITLE_DESCRIPTOR)}
|
|
description={i18n._(EDIT_PUBLISHED_MESSAGE_BODY_DESCRIPTOR)}
|
|
message={message}
|
|
primaryText={i18n._(SAVE_EDIT_DESCRIPTOR)}
|
|
primaryVariant="primary"
|
|
onPrimary={submit}
|
|
data-flx="messaging.message-commands.confirm-published-message-edit.confirm-modal"
|
|
/>
|
|
)),
|
|
);
|
|
}
|
|
|
|
function showCrosspostFailure(i18n: I18n, error: unknown): void {
|
|
if (handleAccountLimitedError(error)) {
|
|
return;
|
|
}
|
|
const errorCode = failureCode(error);
|
|
if (
|
|
error instanceof HttpError &&
|
|
error.status === 429 &&
|
|
errorCode === APIErrorCodes.MESSAGE_CROSSPOST_RATE_LIMITED
|
|
) {
|
|
const retryAfterMs = resolveRetryAfterMs(error);
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<MessageCrosspostLimitModal
|
|
retryAfter={retryAfterMs === null ? undefined : Math.ceil(retryAfterMs / 1000)}
|
|
data-flx="messaging.message-commands.show-crosspost-failure.message-crosspost-limit-modal"
|
|
/>
|
|
)),
|
|
);
|
|
return;
|
|
}
|
|
if (errorCode === APIErrorCodes.MESSAGE_ALREADY_CROSSPOSTED) {
|
|
ToastCommands.error(i18n._(MESSAGE_ALREADY_PUBLISHED_DESCRIPTOR));
|
|
return;
|
|
}
|
|
if (error instanceof HttpError && error.status === 403 && errorCode === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) {
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<FeatureTemporarilyDisabledModal data-flx="messaging.message-commands.show-crosspost-failure.feature-temporarily-disabled-modal" />
|
|
)),
|
|
);
|
|
return;
|
|
}
|
|
if (errorCode === APIErrorCodes.CONTENT_BLOCKED) {
|
|
void import('@app/features/auth/components/ContentBlockedHandler').then((module) =>
|
|
module.showContentBlockedModal(),
|
|
);
|
|
return;
|
|
}
|
|
const apiMessage =
|
|
errorCode === APIErrorCodes.MESSAGE_NOT_CROSSPOSTABLE || errorCode === APIErrorCodes.ANNOUNCEMENT_CHANNEL_REQUIRED
|
|
? failureMessage(error)
|
|
: undefined;
|
|
showGenericErrorModal({
|
|
title: i18n._(COULD_NOT_PUBLISH_MESSAGE_DESCRIPTOR),
|
|
message: apiMessage ?? i18n._(TRY_PUBLISHING_AGAIN_DESCRIPTOR),
|
|
dataFlx: 'messaging.message-commands.show-crosspost-failure.generic-error-modal',
|
|
});
|
|
}
|
|
|
|
export async function crosspost(i18n: I18n, channelId: string, messageId: string): Promise<boolean> {
|
|
logger.debug(`Publishing message ${messageId} in channel ${channelId}`);
|
|
if (blockIfAccountLimited()) return false;
|
|
try {
|
|
await http.post<WireMessage>(Endpoints.CHANNEL_MESSAGE_CROSSPOST(channelId, messageId), {
|
|
mode: 'strict',
|
|
suppressContentBlockedModal: true,
|
|
});
|
|
ToastCommands.createToast({type: 'success', children: i18n._(MESSAGE_PUBLISHED_DESCRIPTOR)});
|
|
return true;
|
|
} catch (error) {
|
|
logger.error(`Failed to publish message ${messageId} in channel ${channelId}:`, error);
|
|
showCrosspostFailure(i18n, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function deleteLocal(channelId: string, messageId: string): void {
|
|
logger.debug(`Deleting message ${messageId} locally in channel ${channelId}`);
|
|
Messages.handleMessageDelete({id: messageId, channelId});
|
|
MessageReply.handleMessageDelete(channelId, messageId);
|
|
}
|
|
|
|
export function revealMessage(channelId: string, messageId: string | null): void {
|
|
logger.debug(`Revealing message ${messageId} in channel ${channelId}`);
|
|
Messages.handleMessageReveal({channelId, messageId});
|
|
}
|
|
|
|
export function startReply(channelId: string, messageId: string, mentioning: boolean): void {
|
|
logger.debug(`Starting reply to message ${messageId} in channel ${channelId}, mentioning=${mentioning}`);
|
|
MessageReply.startReply(channelId, messageId, mentioning);
|
|
ComponentBus.dispatch('FOCUS_TEXTAREA', {channelId});
|
|
window.requestAnimationFrame(() => {
|
|
ComponentBus.dispatch('FOCUS_TEXTAREA', {channelId});
|
|
});
|
|
window.setTimeout(() => {
|
|
ComponentBus.dispatch('FOCUS_TEXTAREA', {channelId});
|
|
}, 300);
|
|
}
|
|
|
|
export function stopReply(channelId: string): void {
|
|
logger.debug(`Stopping reply in channel ${channelId}`);
|
|
MessageReply.stopReply(channelId);
|
|
}
|
|
|
|
export function setReplyMentioning(channelId: string, mentioning: boolean): void {
|
|
logger.debug(`Setting reply mentioning in channel ${channelId}: ${mentioning}`);
|
|
MessageReply.setMentioning(channelId, mentioning);
|
|
}
|
|
|
|
export function startEdit(channelId: string, messageId: string, initialContent: string): void {
|
|
logger.debug(`Starting edit for message ${messageId} in channel ${channelId}`);
|
|
const draftContent = Accessibility.preserveEditDraft ? MessageEdit.getDraftContent(messageId) : null;
|
|
const contentToUse = draftContent ?? initialContent;
|
|
MessageEdit.startEditing(channelId, messageId, contentToUse);
|
|
}
|
|
|
|
export function stopEdit(channelId: string): void {
|
|
logger.debug(`Stopping edit in channel ${channelId}`);
|
|
MessageEdit.stopEditing(channelId);
|
|
}
|
|
|
|
export function startEditMobile(channelId: string, messageId: string): void {
|
|
logger.debug(`Starting mobile edit for message ${messageId} in channel ${channelId}`);
|
|
MessageEditMobile.startEditingMobile(channelId, messageId);
|
|
}
|
|
|
|
export function stopEditMobile(channelId: string): void {
|
|
logger.debug(`Stopping mobile edit in channel ${channelId}`);
|
|
MessageEditMobile.stopEditingMobile(channelId);
|
|
}
|
|
|
|
export function createOptimistic(channelId: string, message: WireMessage): void {
|
|
logger.debug(`Creating optimistic message in channel ${channelId}`);
|
|
Messages.handleIncomingMessage({channelId, message});
|
|
}
|
|
|
|
export function deleteOptimistic(channelId: string, messageId: string): void {
|
|
logger.debug(`Deleting optimistic message ${messageId} in channel ${channelId}`);
|
|
Messages.handleMessageDelete({channelId, id: messageId});
|
|
}
|
|
|
|
export function sendError(channelId: string, nonce: string): void {
|
|
logger.debug(`Message send error for nonce ${nonce} in channel ${channelId}`);
|
|
Messages.handleSendFailed({channelId, nonce});
|
|
}
|
|
|
|
export function retryLocal(channelId: string, messageId: string): void {
|
|
logger.debug(`Retrying optimistic message ${messageId} in channel ${channelId}`);
|
|
Messages.handleSendRetry({channelId, messageId});
|
|
}
|
|
|
|
export function editOptimistic(
|
|
channelId: string,
|
|
messageId: string,
|
|
content: string,
|
|
): {originalContent: string; originalEditedTimestamp: string | null} | null {
|
|
logger.debug(`Applying optimistic edit for message ${messageId} in channel ${channelId}`);
|
|
return Messages.handleOptimisticEdit({channelId, messageId, content});
|
|
}
|
|
|
|
export function editRollback(
|
|
channelId: string,
|
|
messageId: string,
|
|
originalContent: string,
|
|
originalEditedTimestamp: string | null,
|
|
): void {
|
|
logger.debug(`Rolling back edit for message ${messageId} in channel ${channelId}`);
|
|
Messages.handleEditRollback({channelId, messageId, originalContent, originalEditedTimestamp});
|
|
}
|
|
|
|
export async function forward(
|
|
channelIds: Array<string>,
|
|
messageReference: ForwardMessageReference,
|
|
optionalMessage?: string,
|
|
): Promise<boolean> {
|
|
logger.debug(`Forwarding message ${messageReference.message_id} to ${channelIds.length} channels`);
|
|
const normalizedComment = optionalMessage == null ? null : normalizeMessageContent(optionalMessage);
|
|
try {
|
|
for (const channelId of channelIds) {
|
|
const nonce = SnowflakeUtils.fromTimestamp(Date.now());
|
|
const forwardedMessage = await send(channelId, {
|
|
content: '',
|
|
nonce,
|
|
messageReference: {
|
|
message_id: messageReference.message_id,
|
|
channel_id: messageReference.channel_id,
|
|
guild_id: messageReference.guild_id || undefined,
|
|
attachment_ids: messageReference.attachment_ids,
|
|
embed_indices: messageReference.embed_indices,
|
|
type: 1,
|
|
},
|
|
flags: normalizedComment?.flags ?? 0,
|
|
});
|
|
if (!forwardedMessage) {
|
|
logger.warn(`Forward send failed in channel ${channelId}`);
|
|
return false;
|
|
}
|
|
SlowmodeCommands.confirmMessageSend(channelId, forwardedMessage.timestamp);
|
|
if (normalizedComment != null && normalizedComment.content.length > 0) {
|
|
const commentNonce = SnowflakeUtils.fromTimestamp(Date.now() + 1);
|
|
const commentMessage = await send(channelId, {
|
|
content: normalizedComment.content,
|
|
nonce: commentNonce,
|
|
flags: normalizedComment.flags,
|
|
});
|
|
if (!commentMessage) {
|
|
logger.warn(`Forward comment send failed in channel ${channelId}`);
|
|
return false;
|
|
}
|
|
SlowmodeCommands.confirmMessageSend(channelId, commentMessage.timestamp);
|
|
}
|
|
}
|
|
logger.debug('Successfully forwarded message to all channels');
|
|
return true;
|
|
} catch (error) {
|
|
logger.error('Failed to forward message:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function toggledSuppressEmbedsFlags(currentFlags: number): {isSuppressed: boolean; newFlags: number} {
|
|
const isSuppressed = (currentFlags & MessageFlags.SUPPRESS_EMBEDS) === MessageFlags.SUPPRESS_EMBEDS;
|
|
const newFlags = isSuppressed
|
|
? currentFlags & ~MessageFlags.SUPPRESS_EMBEDS
|
|
: currentFlags | MessageFlags.SUPPRESS_EMBEDS;
|
|
return {isSuppressed, newFlags};
|
|
}
|
|
|
|
async function requestMessageFlagsPatch(channelId: string, messageId: string, flags: number): Promise<void> {
|
|
await http.patch<WireMessage>(Endpoints.CHANNEL_MESSAGE(channelId, messageId), {
|
|
body: {flags},
|
|
});
|
|
}
|
|
|
|
export async function toggleSuppressEmbeds(channelId: string, messageId: string, currentFlags: number): Promise<void> {
|
|
try {
|
|
const {isSuppressed, newFlags} = toggledSuppressEmbedsFlags(currentFlags);
|
|
logger.debug(`${isSuppressed ? 'Unsuppressing' : 'Suppressing'} embeds for message ${messageId}`);
|
|
await requestMessageFlagsPatch(channelId, messageId, newFlags);
|
|
logger.debug(`Successfully ${isSuppressed ? 'unsuppressed' : 'suppressed'} embeds for message ${messageId}`);
|
|
} catch (error) {
|
|
logger.error('Failed to toggle suppress embeds:', error);
|
|
if (handleAccountLimitedError(error) || showPublishedEditLimitModal(error)) {
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function requestPersonalNotesPurge(channelId: string): Promise<number> {
|
|
const response = await http.post<{deleted_count: number}>(Endpoints.CHANNEL_MESSAGES_PURGE(channelId), {
|
|
body: {},
|
|
});
|
|
return response.body.deleted_count;
|
|
}
|
|
|
|
export async function purgePersonalNotes(channelId: string): Promise<{deletedCount: number}> {
|
|
try {
|
|
logger.debug(`Purging personal notes channel ${channelId}`);
|
|
const deletedCount = await requestPersonalNotesPurge(channelId);
|
|
logger.info(`Purged ${deletedCount} messages from personal notes ${channelId}`);
|
|
return {deletedCount};
|
|
} catch (error) {
|
|
logger.error(`Failed to purge personal notes channel ${channelId}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function requestAttachmentDelete(channelId: string, messageId: string, attachmentId: string): Promise<void> {
|
|
await http.delete(Endpoints.CHANNEL_MESSAGE_ATTACHMENT(channelId, messageId, attachmentId));
|
|
}
|
|
|
|
export async function deleteAttachment(channelId: string, messageId: string, attachmentId: string): Promise<boolean> {
|
|
try {
|
|
logger.debug(`Deleting attachment ${attachmentId} from message ${messageId}`);
|
|
await requestAttachmentDelete(channelId, messageId, attachmentId);
|
|
logger.debug(`Successfully deleted attachment ${attachmentId} from message ${messageId}`);
|
|
return true;
|
|
} catch (error) {
|
|
logger.error('Failed to delete attachment:', error);
|
|
if (showPublishedEditLimitModal(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|