Files
fluxer/fluxer_app/src/features/messaging/state/MessageQueue.tsx
T

1395 lines
49 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import i18n from '@app/app/I18n';
import {FeatureTemporarilyDisabledModal} from '@app/features/app/components/alerts/FeatureTemporarilyDisabledModal';
import {Endpoints} from '@app/features/app/constants/Endpoints';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import {createSystemMessage} from '@app/features/devtools/utils/CommandUtils';
import * as DraftCommands from '@app/features/messaging/commands/DraftCommands';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {AttachmentUploadConnectivityModal} from '@app/features/messaging/components/alerts/AttachmentUploadConnectivityModal';
import {FileSizeTooLargeModal} from '@app/features/messaging/components/alerts/FileSizeTooLargeModal';
import {MessageSendFailedModal} from '@app/features/messaging/components/alerts/MessageSendFailedModal';
import {MessageSendTooQuickModal} from '@app/features/messaging/components/alerts/MessageSendTooQuickModal';
import {
type MessageLocalSendRateLimitState,
resolveMessageLocalSendRateLimitDecision,
resolveMessageQueueRequestOutcomeDecision,
resolveMessageQueueSendExecutionDecision,
} from '@app/features/messaging/state/MessageQueueStateMachine';
import {planTextareaAttachmentCancellation} from '@app/features/messaging/state/TextareaAttachmentUploadCancellation';
import {
type ChunkedUploadPart,
type ChunkedUploadPlan,
uploadFileInChunks,
} from '@app/features/messaging/upload/ChunkedAttachmentUploader';
import {type CloudAttachment, CloudUpload} from '@app/features/messaging/upload/CloudUpload';
import {exceedsMultipartFallbackRequestSize} from '@app/features/messaging/utils/AttachmentUploadFallbackUtils';
import {prepareAttachmentsForNonce} from '@app/features/messaging/utils/MessageAttachmentUtils';
import {
type ApiAttachmentMetadata,
buildMessageCreateRequest,
type MessageCreateRequest,
} from '@app/features/messaging/utils/MessageRequestUtils';
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
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 * as SlowmodeCommands from '@app/features/slowmode/commands/SlowmodeCommands';
import {SlowmodeRateLimitedModal} from '@app/features/slowmode/components/alerts/SlowmodeRateLimitedModal';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {formatUserSettingsPath} from '@app/features/user/components/settings_utils/SettingsConstants';
import PrivacyPreferences from '@app/features/user/state/PrivacyPreferences';
import {Queue} from '@app/lib/list/ListQueue';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {
AllowedMentions,
Message,
MessageReference,
MessageStickerItem,
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {reaction} from 'mobx';
const YOUR_MESSAGE_COULD_NOT_BE_DELIVERED_THIS_IS_DESCRIPTOR = msg({
message:
"Your message could not be delivered. This is usually because you don't share a community with the recipient or the recipient is only accepting direct messages from friends. You may also need to adjust your own direct message privacy settings in {directMessagePrivacySettingsPath}.",
comment: 'Label in the message queue state.',
});
const YOUR_MESSAGE_COULD_NOT_BE_DELIVERED_YOU_NEED_DESCRIPTOR = msg({
message: 'Your message could not be delivered. You need to claim your account to send direct messages.',
comment: 'Description text in the message queue state.',
});
const YOUR_MESSAGE_COULD_NOT_BE_DELIVERED_YOU_NEED_2_DESCRIPTOR = msg({
message: 'Your message could not be delivered. You need to claim your account to send messages.',
comment: 'Description text in the message queue state.',
});
const YOUR_MESSAGE_COULD_NOT_BE_DELIVERED_BECAUSE_IT_DESCRIPTOR = msg({
message:
'Your message could not be delivered because it was flagged by our safety systems. If you believe this is a mistake, please contact support.',
comment: 'Label in the message queue state.',
});
const YOUR_MESSAGE_COULD_NOT_BE_DELIVERED_BECAUSE_IT_2_DESCRIPTOR = msg({
message:
'Your message could not be delivered because it contains mature emoji or stickers that are not allowed in this context.',
comment: 'Label in the message queue state.',
});
const logger = new Logger('MessageQueue');
const DEFAULT_MAX_SIZE = 5;
const DEV_MESSAGE_DELAY = 3000;
const LOCAL_SEND_RATE_LIMIT_MAX_SENDS = 5;
const LOCAL_SEND_RATE_LIMIT_WINDOW_MS = 2000;
const LOCAL_SEND_RATE_LIMIT_BLOCK_MS = 3000;
const LOCAL_SEND_RATE_LIMIT_TRACKED_CHANNELS_MAX = 128;
const LOCAL_SEND_RESERVATION_TTL_MS = 30 * 1000;
const LOCAL_SEND_RESERVATIONS_MAX = 128;
const LOCAL_SEND_RATE_LIMIT_MODAL_KEY_PREFIX = 'message-local-send-rate-limit';
const TEXTAREA_ATTACHMENT_UPLOAD_CACHE_TTL_MS = 5 * 60 * 1000;
const MESSAGE_SEND_RATE_LIMIT_MAX_AUTOMATIC_RETRIES = 2;
const MESSAGE_SEND_RATE_LIMIT_MAX_AUTOMATIC_DELAY_MS = 30 * 1000;
interface BaseMessagePayload {
channelId: string;
}
interface SendMessagePayload extends BaseMessagePayload {
type: 'send';
nonce: string;
rateLimitRetryCount?: number;
content: string;
hasAttachments?: boolean;
preparedAttachments?: Array<ApiAttachmentMetadata>;
preparedFiles?: Array<File>;
allowedMentions?: AllowedMentions;
messageReference?: MessageReference;
flags?: number;
favoriteMemeId?: string;
stickers?: Array<MessageStickerItem>;
tts?: boolean;
}
export type MessageQueuePayload = SendMessagePayload;
type MessageQueueCompletion<TResult> = {
retry: RetryError | null;
result?: TResult;
error?: unknown;
};
export interface RetryError {
retryAfter?: number;
}
export interface ApiErrorBody {
code?: number | string;
message?: string;
}
interface PresignedAttachmentUploadSinglepartResponse {
upload_mode: 'singlepart';
id: string | number;
filename: string;
upload_filename: string;
upload_url: string;
file_size: number;
content_type: string;
}
interface PresignedAttachmentUploadMultipartResponse {
upload_mode: 'multipart';
id: string | number;
filename: string;
upload_filename: string;
file_size: number;
content_type: string;
upload_id: string;
part_size: number;
parts: Array<{part_number: number; upload_url: string}>;
}
type PresignedAttachmentUploadResponseAttachment =
| PresignedAttachmentUploadSinglepartResponse
| PresignedAttachmentUploadMultipartResponse;
interface PresignedAttachmentUploadRequestFile {
id: string;
filename: string;
file_size: number;
content_type: string;
}
interface PresignedAttachmentUploadRequestBody {
attachments: Array<PresignedAttachmentUploadRequestFile>;
}
interface PresignedAttachmentUploadResponseBody {
attachments: Array<PresignedAttachmentUploadResponseAttachment>;
}
interface CompleteMultipartAttachmentUploadRequestBody {
uploads: Array<{upload_filename: string; upload_id: string}>;
}
interface CompleteMultipartAttachmentUploadResponseBody {
uploads: Array<{upload_filename: string}>;
}
export interface PreparedSendAttachments {
attachments?: Array<ApiAttachmentMetadata>;
files?: Array<File>;
}
interface TextareaAttachmentUploadResult {
uploadFilename: string;
fileSize: number;
contentType: string;
}
interface TextareaAttachmentUpload {
channelId: string;
attachmentId: number;
startedAt: number;
requestAbortController: AbortController;
abortController: AbortController;
promise: Promise<TextareaAttachmentUploadResult>;
settled: boolean;
}
function createAbortError(): DOMException {
return new DOMException('Upload aborted', 'AbortError');
}
const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
return typeof error.body === 'object' && error.body !== null ? (error.body as ApiErrorBody) : undefined;
};
interface MessageRateLimitRetry {
retryAfterMs: number | null;
automaticRetryDelayMs: number | null;
}
function resolveMessageRateLimitRetry(error: HttpError): MessageRateLimitRetry {
const retryAfterMs = resolveRetryAfterMs(error);
if (retryAfterMs === null) {
return {retryAfterMs: null, automaticRetryDelayMs: null};
}
let automaticRetryDelayMs: number | null = null;
if (retryAfterMs <= MESSAGE_SEND_RATE_LIMIT_MAX_AUTOMATIC_DELAY_MS) {
automaticRetryDelayMs = retryAfterMs;
}
return {retryAfterMs, automaticRetryDelayMs};
}
function retryAfterMsToWholeSeconds(retryAfterMs: number | null): number | null {
if (retryAfterMs === null) return null;
return Math.ceil(retryAfterMs / 1000);
}
const isAbortError = (error: unknown): boolean => {
return error instanceof DOMException && error.name === 'AbortError';
};
const isTimeoutError = (error: unknown): boolean => {
return error instanceof DOMException && error.name === 'TimeoutError';
};
const isNetworkRequestError = (error: unknown): boolean => {
return error instanceof Error && error.message === 'Network error during request';
};
function isRateLimitError(error: HttpError): boolean {
if (error.status !== 429) return false;
return !isSlowmodeError(error);
}
function isSlowmodeError(error: HttpError): boolean {
if (error.status !== 400 && error.status !== 429) return false;
const body = getApiErrorBody(error);
if (body === undefined) return false;
return body.code === APIErrorCodes.SLOWMODE_RATE_LIMITED;
}
function isFeatureDisabledError(error: HttpError): boolean {
return error?.status === 403 && getApiErrorBody(error)?.code === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED;
}
function isExplicitContentError(error: HttpError): boolean {
return getApiErrorBody(error)?.code === APIErrorCodes.EXPLICIT_CONTENT_CANNOT_BE_SENT;
}
function isFileTooLargeError(error: HttpError): boolean {
return getApiErrorBody(error)?.code === APIErrorCodes.FILE_SIZE_TOO_LARGE;
}
function isDMRestrictedError(error: HttpError): boolean {
return getApiErrorBody(error)?.code === APIErrorCodes.CANNOT_SEND_MESSAGES_TO_USER;
}
function getUnclaimedAccountErrorCode(error: HttpError): string | undefined {
const code = getApiErrorBody(error)?.code;
if (
code === APIErrorCodes.UNCLAIMED_ACCOUNT_CANNOT_SEND_MESSAGES ||
code === APIErrorCodes.UNCLAIMED_ACCOUNT_CANNOT_SEND_DIRECT_MESSAGES
) {
return code;
}
return undefined;
}
function isPresignedUploadEndpointUnreachable(error: unknown): boolean {
if (isTimeoutError(error)) {
return true;
}
if (error instanceof HttpError) {
return typeof error.status === 'number' && error.status >= 500;
}
return isNetworkRequestError(error);
}
class PresignedUploadFallbackUnavailableError extends Error {
constructor() {
super('Presigned attachment upload request failed and multipart fallback is unavailable');
this.name = 'PresignedUploadFallbackUnavailableError';
}
}
export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Message> | undefined> {
private readonly maxSize: number;
private readonly abortControllers = new Map<string, AbortController>();
private readonly textareaAttachmentUploads = new Map<number, TextareaAttachmentUpload>();
private readonly textareaAttachmentUploadDisposers = new Map<string, () => void>();
private readonly localSendLimiters = new Map<string, MessageLocalSendRateLimitState>();
private readonly localSendReservations = new Map<string, number>();
constructor(maxSize = DEFAULT_MAX_SIZE) {
super({logger, defaultRetryAfter: 100});
this.maxSize = maxSize;
reaction(
() => PrivacyPreferences.getPreuploadMessageAttachments(),
(enabled) => {
if (!enabled) {
this.cancelAllTextareaAttachmentUploads();
}
},
);
}
isFull(): boolean {
return this.queueLength >= this.maxSize;
}
override enqueue(
message: MessageQueuePayload,
success: (result?: RestResponse<Message>, error?: unknown) => void,
): void {
if (!this.isFull()) {
super.enqueue(message, success);
return;
}
const error = new Error(`Message queue capacity of ${this.maxSize} entries was reached`);
logger.error('Rejected message queue entry because capacity was reached', error);
this.handleSendError(message.channelId, message.nonce, error, i18n, message.hasAttachments);
try {
success(undefined, error);
} catch (callbackError) {
logger.error('Message queue rejection callback failed', callbackError);
}
}
reserveLocalSend(channelId: string, nonce: string): boolean {
const reservationKey = this.getLocalSendReservationKey(channelId, nonce);
const now = Date.now();
this.pruneExpiredLocalSendReservations(now);
const existingExpiry = this.localSendReservations.get(reservationKey);
if (existingExpiry !== undefined) {
if (existingExpiry > now) return true;
this.localSendReservations.delete(reservationKey);
}
if (!this.consumeLocalSendAllowance(channelId)) {
return false;
}
while (this.localSendReservations.size >= LOCAL_SEND_RESERVATIONS_MAX) {
const oldestReservationKey = this.localSendReservations.keys().next().value;
if (oldestReservationKey === undefined) break;
this.localSendReservations.delete(oldestReservationKey);
}
this.localSendReservations.set(reservationKey, now + LOCAL_SEND_RESERVATION_TTL_MS);
return true;
}
consumeLocalSendReservation(channelId: string, nonce: string): boolean {
const reservationKey = this.getLocalSendReservationKey(channelId, nonce);
const expiresAt = this.localSendReservations.get(reservationKey);
if (expiresAt !== undefined) {
this.localSendReservations.delete(reservationKey);
if (expiresAt > Date.now()) return true;
}
return this.consumeLocalSendAllowance(channelId);
}
rejectLocalRateLimitedSend(channelId: string, nonce: string, hasAttachments?: boolean): void {
MessageCommands.sendError(channelId, nonce);
if (hasAttachments) {
this.restoreFailedMessage(channelId, nonce);
}
}
drain(
message: MessageQueuePayload,
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
): Promise<unknown> | undefined {
return this.handleSend(message, completed);
}
private consumeLocalSendAllowance(channelId: string): boolean {
const now = Date.now();
const previous = this.localSendLimiters.get(channelId);
let windowStartedAt: number | null = null;
let sentCount = 0;
let blockedUntil: number | null = null;
if (previous !== undefined) {
windowStartedAt = previous.windowStartedAt;
sentCount = previous.sentCount;
blockedUntil = previous.blockedUntil;
}
const decision = resolveMessageLocalSendRateLimitDecision({
windowStartedAt,
sentCount,
blockedUntil,
now,
maxSends: LOCAL_SEND_RATE_LIMIT_MAX_SENDS,
windowMs: LOCAL_SEND_RATE_LIMIT_WINDOW_MS,
blockMs: LOCAL_SEND_RATE_LIMIT_BLOCK_MS,
});
this.localSendLimiters.delete(channelId);
if (this.localSendLimiters.size >= LOCAL_SEND_RATE_LIMIT_TRACKED_CHANNELS_MAX) {
this.removeExpiredLocalSendLimiters(now);
}
while (this.localSendLimiters.size >= LOCAL_SEND_RATE_LIMIT_TRACKED_CHANNELS_MAX) {
const oldestChannelId = this.localSendLimiters.keys().next().value;
if (oldestChannelId === undefined) break;
this.localSendLimiters.delete(oldestChannelId);
}
this.localSendLimiters.set(channelId, decision.next);
switch (decision.type) {
case 'allow':
return true;
case 'block':
this.showLocalSendRateLimitModal(channelId, decision.retryAfterMs);
return false;
}
}
private showLocalSendRateLimitModal(channelId: string, retryAfterMs: number): void {
const key = this.getLocalSendRateLimitModalKey(channelId);
const retryAfter = this.getLocalSendRateLimitRetryAfterSeconds(retryAfterMs);
ModalCommands.pushWithKey(
modal(() => (
<MessageSendTooQuickModal
retryAfter={retryAfter}
data-flx="messaging.message-queue.local-message-send-too-quick-modal"
/>
)),
key,
);
}
private getLocalSendRateLimitModalKey(channelId: string): string {
return `${LOCAL_SEND_RATE_LIMIT_MODAL_KEY_PREFIX}:${channelId}`;
}
private getLocalSendRateLimitRetryAfterSeconds(retryAfterMs: number): number {
return Math.max(1, Math.ceil(retryAfterMs / 1000));
}
private getLocalSendReservationKey(channelId: string, nonce: string): string {
return `${channelId}:${nonce}`;
}
private removeExpiredLocalSendLimiters(now: number): void {
for (const [channelId, state] of this.localSendLimiters) {
const windowExpired =
state.windowStartedAt === null || now - state.windowStartedAt >= LOCAL_SEND_RATE_LIMIT_WINDOW_MS;
const blockExpired = state.blockedUntil === null || now >= state.blockedUntil;
if (windowExpired && blockExpired) this.localSendLimiters.delete(channelId);
}
}
private pruneExpiredLocalSendReservations(now: number): void {
for (const [reservationKey, expiresAt] of this.localSendReservations) {
if (expiresAt <= now) this.localSendReservations.delete(reservationKey);
}
}
cancelRequest(nonce: string): void {
logger.info('Cancel message send:', nonce);
const messageUpload = CloudUpload.getMessageUpload(nonce);
if (messageUpload) {
this.cancelTextareaAttachmentUploads(messageUpload.attachments.map((attachment) => attachment.id));
}
const controller = this.abortControllers.get(nonce);
controller?.abort();
this.abortControllers.delete(nonce);
}
async sendImmediately(payload: SendMessagePayload): Promise<RestResponse<Message> | undefined> {
while (true) {
const completion = await this.drainSendImmediately(payload);
if (completion.retry === null) {
return completion.result;
}
const retryAfter = completion.retry.retryAfter;
const delay = retryAfter === undefined ? this.defaultRetryAfter : retryAfter;
logger.info(`Pausing immediate send retry for ${delay}ms due to retry request`);
await new Promise<void>((resolve) => window.setTimeout(resolve, delay));
}
}
private drainSendImmediately(payload: SendMessagePayload): Promise<MessageQueueCompletion<RestResponse<Message>>> {
return new Promise((resolve) => {
let hasCompleted = false;
const complete = (retry: RetryError | null, result?: RestResponse<Message>, error?: unknown): void => {
if (hasCompleted) {
logger.warn('Immediate send completion callback invoked more than once; ignoring extra call');
return;
}
hasCompleted = true;
resolve({retry, result, error});
};
try {
void this.handleSend(payload, complete).catch((error) => {
logger.error('Unhandled error while sending immediate message', error);
if (!hasCompleted) {
complete(null, undefined, error);
}
});
} catch (error) {
logger.error('Unhandled error while sending immediate message', error);
if (!hasCompleted) {
complete(null, undefined, error);
}
}
});
}
startTextareaAttachmentUploads(channelId: string, attachments: ReadonlyArray<CloudAttachment>): void {
if (!PrivacyPreferences.getPreuploadMessageAttachments()) {
return;
}
const pendingAttachments = attachments.filter((attachment) => !this.textareaAttachmentUploads.has(attachment.id));
if (pendingAttachments.length === 0) {
return;
}
const files = pendingAttachments.map((attachment) => attachment.file);
if (!this.canUsePresignedAttachmentUploads(files)) {
return;
}
this.ensureTextareaAttachmentUploadPruner(channelId);
const requestAttachments: Array<ApiAttachmentMetadata> = pendingAttachments.map((attachment) => ({
id: String(attachment.id),
filename: attachment.filename,
title: attachment.filename,
description: attachment.description,
flags: attachment.flags,
duration: attachment.duration != null ? Math.ceil(attachment.duration) : undefined,
waveform: attachment.waveform ?? undefined,
}));
const requestAbortController = new AbortController();
const plansPromise = this.requestPresignedAttachmentUploads(
channelId,
requestAttachments,
files,
requestAbortController.signal,
)
.then((plans) => {
const planIndexById = new Map<string, number>();
plans.forEach((entry, index) => planIndexById.set(String(entry.id), index));
return {plans, planIndexById};
})
.catch((error) => {
if (!isAbortError(error)) {
logger.warn('Failed to start background attachment upload; will upload when sending', error);
}
throw error;
});
pendingAttachments.forEach((attachment, index) => {
const file = files[index];
const abortController = new AbortController();
const promise = (async (): Promise<TextareaAttachmentUploadResult> => {
try {
CloudUpload.updateAttachment(channelId, attachment.id, {status: 'uploading', uploadProgress: 0});
const {plans, planIndexById} = await plansPromise;
if (abortController.signal.aborted) {
throw createAbortError();
}
const planIndex = planIndexById.get(String(attachment.id));
const plan = planIndex == null ? undefined : plans[planIndex];
if (!plan) {
throw new Error(`Missing presigned upload metadata for attachment ${attachment.id}`);
}
const result = await this.uploadTextareaAttachmentViaPlan({
channelId,
attachmentId: attachment.id,
file,
plan,
signal: abortController.signal,
});
CloudUpload.updateAttachment(channelId, attachment.id, {status: 'sending', uploadProgress: 100});
return result;
} catch (error) {
CloudUpload.updateAttachment(channelId, attachment.id, {status: 'pending', uploadProgress: 0});
throw error;
}
})();
const entry: TextareaAttachmentUpload = {
channelId,
attachmentId: attachment.id,
startedAt: Date.now(),
requestAbortController,
abortController,
promise,
settled: false,
};
this.textareaAttachmentUploads.set(attachment.id, entry);
void promise
.catch(() => undefined)
.finally(() => {
const current = this.textareaAttachmentUploads.get(attachment.id);
if (current === entry) {
current.settled = true;
}
});
this.scheduleTextareaAttachmentUploadCleanup(attachment.id);
});
}
private scheduleTextareaAttachmentUploadCleanup(attachmentId: number): void {
window.setTimeout(() => {
const entry = this.textareaAttachmentUploads.get(attachmentId);
if (!entry || !entry.settled) {
return;
}
if (Date.now() - entry.startedAt >= TEXTAREA_ATTACHMENT_UPLOAD_CACHE_TTL_MS) {
this.textareaAttachmentUploads.delete(attachmentId);
this.disposeTextareaAttachmentUploadPrunerIfIdle(entry.channelId);
}
}, TEXTAREA_ATTACHMENT_UPLOAD_CACHE_TTL_MS);
}
private ensureTextareaAttachmentUploadPruner(channelId: string): void {
if (this.textareaAttachmentUploadDisposers.has(channelId)) {
return;
}
const dispose = CloudUpload.subscribeToTextarea(channelId, () => {
this.cancelDetachedTextareaAttachmentUploads(channelId);
});
this.textareaAttachmentUploadDisposers.set(channelId, dispose);
}
private cancelDetachedTextareaAttachmentUploads(channelId: string): void {
const attachmentIds: Array<number> = [];
for (const [attachmentId, upload] of this.textareaAttachmentUploads.entries()) {
if (upload.channelId === channelId && !CloudUpload.hasAttachment(attachmentId)) {
attachmentIds.push(attachmentId);
}
}
this.cancelTextareaAttachmentUploads(attachmentIds);
this.disposeTextareaAttachmentUploadPrunerIfIdle(channelId);
}
private disposeTextareaAttachmentUploadPrunerIfIdle(channelId: string): void {
for (const upload of this.textareaAttachmentUploads.values()) {
if (upload.channelId === channelId) {
return;
}
}
const dispose = this.textareaAttachmentUploadDisposers.get(channelId);
if (!dispose) {
return;
}
dispose();
this.textareaAttachmentUploadDisposers.delete(channelId);
}
private cancelTextareaAttachmentUploads(attachmentIds: ReadonlyArray<number>): void {
const plan = planTextareaAttachmentCancellation(this.textareaAttachmentUploads, attachmentIds);
for (const {attachmentId, upload} of plan.cancelled) {
upload.abortController.abort();
this.textareaAttachmentUploads.delete(attachmentId);
}
for (const controller of plan.requestControllersToAbort) {
controller.abort();
}
for (const channelId of plan.channelIds) {
this.disposeTextareaAttachmentUploadPrunerIfIdle(channelId);
}
}
private cancelAllTextareaAttachmentUploads(): void {
this.cancelTextareaAttachmentUploads(Array.from(this.textareaAttachmentUploads.keys()));
}
private deleteTextareaAttachmentUploads(attachmentIds: ReadonlyArray<number>): void {
const channelIds = new Set<string>();
for (const attachmentId of attachmentIds) {
const upload = this.textareaAttachmentUploads.get(attachmentId);
if (upload) {
channelIds.add(upload.channelId);
}
this.textareaAttachmentUploads.delete(attachmentId);
}
for (const channelId of channelIds) {
this.disposeTextareaAttachmentUploadPrunerIfIdle(channelId);
}
}
private async tryPrepareTextareaAttachmentUploads(params: {
channelId: string;
nonce: string;
rawAttachments: Array<ApiAttachmentMetadata>;
files: Array<File>;
}): Promise<PreparedSendAttachments | null | undefined> {
const {channelId, nonce, rawAttachments, files} = params;
const messageUpload = CloudUpload.getMessageUpload(nonce);
if (!messageUpload || messageUpload.attachments.length !== rawAttachments.length) {
return undefined;
}
if (messageUpload.attachments.length !== files.length) {
return undefined;
}
const attachmentIds = messageUpload.attachments.map((attachment) => attachment.id);
if (!PrivacyPreferences.getPreuploadMessageAttachments()) {
this.cancelTextareaAttachmentUploads(attachmentIds);
return undefined;
}
const uploads: Array<TextareaAttachmentUpload> = [];
for (const attachment of messageUpload.attachments) {
const upload = this.textareaAttachmentUploads.get(attachment.id);
if (!upload || upload.channelId !== channelId) {
this.cancelTextareaAttachmentUploads(attachmentIds);
return undefined;
}
uploads.push(upload);
}
try {
CloudUpload.startSendingProgress(nonce);
const results = await Promise.all(uploads.map((upload) => upload.promise));
CloudUpload.updateSendingProgress(nonce, 100);
return {
attachments: rawAttachments.map((attachment, index) => {
const result = results[index];
return {
...attachment,
upload_filename: result.uploadFilename,
file_size: result.fileSize,
content_type: result.contentType,
};
}),
files: undefined,
};
} catch (error) {
if (isAbortError(error)) {
return null;
}
logger.warn('Background attachment upload unavailable; uploading at send time', error);
this.cancelTextareaAttachmentUploads(attachmentIds);
CloudUpload.updateSendingProgress(nonce, 0);
return undefined;
}
}
async prepareAttachmentsForSend(params: {
channelId: string;
nonce: string;
favoriteMemeId?: string;
}): Promise<PreparedSendAttachments | null> {
const {channelId, nonce, favoriteMemeId} = params;
const abortController = new AbortController();
this.abortControllers.set(nonce, abortController);
try {
const {attachments: rawAttachments, files} = await prepareAttachmentsForNonce(nonce, favoriteMemeId);
if (!files?.length || !rawAttachments?.length) {
return {attachments: rawAttachments, files};
}
if (!this.canUsePresignedAttachmentUploads(files)) {
return {attachments: rawAttachments, files};
}
if (rawAttachments.length !== files.length) {
throw new Error(
`Attachment metadata mismatch for presigned uploads: expected ${files.length} entries but got ${rawAttachments.length}`,
);
}
const backgroundUploadResult = await this.tryPrepareTextareaAttachmentUploads({
channelId,
nonce,
rawAttachments,
files,
});
if (backgroundUploadResult !== undefined) {
return backgroundUploadResult;
}
let plans: Array<PresignedAttachmentUploadResponseAttachment>;
try {
plans = await this.requestPresignedAttachmentUploads(channelId, rawAttachments, files, abortController.signal);
} catch (error) {
if (isAbortError(error)) {
return null;
}
if (isPresignedUploadEndpointUnreachable(error)) {
if (exceedsMultipartFallbackRequestSize(files)) {
logger.warn(
'Presigned attachment upload URL request failed because the endpoint was unreachable and multipart fallback cannot be used for oversized requests',
error,
);
this.handleSendError(channelId, nonce, new PresignedUploadFallbackUnavailableError(), i18n, true);
return null;
}
logger.warn(
'Presigned attachment upload URL request failed because the endpoint was unreachable; falling back to multipart message upload',
error,
);
return {attachments: rawAttachments, files};
}
logger.warn('Presigned attachment upload URL request failed; falling back to multipart message upload', error);
return {attachments: rawAttachments, files};
}
const planIndexById = new Map<string, number>();
plans.forEach((entry, index) => planIndexById.set(String(entry.id), index));
for (const attachment of rawAttachments) {
if (!planIndexById.has(String(attachment.id))) {
this.handleSendError(
channelId,
nonce,
new Error(`Missing presigned upload metadata for attachment ${attachment.id}`),
i18n,
true,
);
return null;
}
}
const multipartUploadsToComplete: Array<{upload_filename: string; upload_id: string}> = [];
let finalized: Array<ApiAttachmentMetadata>;
try {
finalized = await this.uploadAttachmentsViaPlans({
nonce,
attachments: rawAttachments,
files,
plans,
planIndexById,
multipartUploadsToComplete,
signal: abortController.signal,
});
} catch (error) {
if (isAbortError(error)) {
this.abortRemainingMultipartUploads(channelId, multipartUploadsToComplete);
return null;
}
if (exceedsMultipartFallbackRequestSize(files)) {
logger.warn(
'Presigned attachment upload failed and multipart fallback cannot be used for oversized requests',
error,
);
this.handleSendError(channelId, nonce, new PresignedUploadFallbackUnavailableError(), i18n, true);
return null;
}
logger.warn('Presigned attachment upload failed; falling back to multipart message upload', error);
CloudUpload.updateSendingProgress(nonce, 0);
this.abortRemainingMultipartUploads(channelId, multipartUploadsToComplete);
return {attachments: rawAttachments, files};
}
if (multipartUploadsToComplete.length > 0) {
try {
await this.completeMultipartAttachmentUploads(channelId, multipartUploadsToComplete, abortController.signal);
} catch (error) {
if (isAbortError(error)) {
return null;
}
logger.error(`Failed to finalize multipart attachment uploads for channel ${channelId}`, error);
this.handleSendError(channelId, nonce, error, i18n, true);
return null;
}
}
return {attachments: finalized, files: undefined};
} catch (error) {
if (isAbortError(error)) {
return null;
}
logger.error(`Failed to prepare attachments for channel ${channelId}:`, error);
this.handleSendError(channelId, nonce, error, i18n, true);
return null;
} finally {
if (this.abortControllers.get(nonce) === abortController && abortController.signal.aborted) {
this.abortControllers.delete(nonce);
}
}
}
private async handleSend(
payload: SendMessagePayload,
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
): Promise<void> {
const {channelId, nonce, hasAttachments} = payload;
await this.applyDevDelay();
const executionDecision = resolveMessageQueueSendExecutionDecision({
forceFailure: DeveloperOptions.forceFailMessageSends,
});
switch (executionDecision.type) {
case 'simulateFailure': {
const forcedError = new Error('Forced message send failure');
logger.error(`Failed to send message to channel ${channelId}:`, forcedError);
this.handleSendError(channelId, nonce, forcedError as HttpError, i18n, payload.hasAttachments);
completed(null, undefined, forcedError);
return;
}
case 'requestNetwork':
break;
}
const requestBody = buildMessageCreateRequest({
content: payload.content,
nonce,
attachments: payload.preparedAttachments,
allowedMentions: payload.allowedMentions,
messageReference: payload.messageReference,
flags: payload.flags,
favoriteMemeId: payload.favoriteMemeId,
stickers: payload.stickers,
tts: payload.tts,
});
logger.debug(`Sending message to channel ${channelId}`);
const outcome = await this.attemptMessageSend(channelId, nonce, requestBody, payload.preparedFiles);
const outcomeDecision = resolveMessageQueueRequestOutcomeDecision({status: outcome.status});
switch (outcomeDecision.type) {
case 'completeSuccess': {
const successOutcome = outcome as {status: 'success'; response: RestResponse<Message>};
logger.debug(`Successfully sent message to channel ${channelId}`);
if (hasAttachments) {
const messageUpload = CloudUpload.getMessageUpload(nonce);
if (messageUpload) {
this.deleteTextareaAttachmentUploads(messageUpload.attachments.map((attachment) => attachment.id));
}
CloudUpload.removeMessageUpload(nonce);
}
completed(null, successOutcome.response);
return;
}
case 'retryRateLimit': {
const rateLimitOutcome = outcome as {status: 'rateLimit'; error: HttpError};
logger.error(`Failed to send message to channel ${channelId}:`, rateLimitOutcome.error);
this.handleSendRateLimit(payload, rateLimitOutcome.error, completed);
return;
}
case 'completeFailure': {
const failureOutcome = outcome as {status: 'failure'; error: unknown};
logger.error(`Failed to send message to channel ${channelId}:`, failureOutcome.error);
this.handleSendError(channelId, nonce, failureOutcome.error, i18n, payload.hasAttachments);
completed(null, undefined, failureOutcome.error);
return;
}
}
}
private async applyDevDelay(): Promise<void> {
if (!DeveloperOptions.slowMessageSend) return;
logger.debug(`Slow message send enabled, delaying by ${DEV_MESSAGE_DELAY}ms`);
await new Promise((resolve) => setTimeout(resolve, DEV_MESSAGE_DELAY));
}
private async sendMessageRequest(
channelId: string,
nonce: string,
requestBody: MessageCreateRequest,
files?: Array<File>,
): Promise<RestResponse<Message>> {
const existing = this.abortControllers.get(nonce);
const abortController = existing ?? new AbortController();
if (!existing) {
this.abortControllers.set(nonce, abortController);
}
try {
if (files?.length) {
logger.debug('Sending message with multipart form data');
return await this.sendMultipartMessage(channelId, requestBody, files, abortController.signal, nonce);
}
return await http.post<Message>(Endpoints.CHANNEL_MESSAGES(channelId), {
body: requestBody,
signal: abortController.signal,
suppressContentBlockedModal: true,
});
} finally {
this.abortControllers.delete(nonce);
}
}
private canUsePresignedAttachmentUploads(files?: Array<File>): files is Array<File> {
return RuntimeConfig.features.presigned_attachment_uploads && Boolean(files?.length);
}
private async requestPresignedAttachmentUploads(
channelId: string,
attachments: Array<ApiAttachmentMetadata>,
files: Array<File>,
signal: AbortSignal,
): Promise<Array<PresignedAttachmentUploadResponseAttachment>> {
const requestBody: PresignedAttachmentUploadRequestBody = {
attachments: attachments.map((attachment, index) => ({
id: attachment.id,
filename: attachment.filename,
file_size: files[index].size,
content_type: files[index].type || 'application/octet-stream',
})),
};
const response = await http.post<PresignedAttachmentUploadResponseBody>(Endpoints.CHANNEL_ATTACHMENTS(channelId), {
body: requestBody,
signal,
});
const plans = response.body?.attachments ?? [];
for (const entry of plans) {
if (!entry || !entry.upload_mode || !entry.upload_filename || !entry.filename) {
throw new Error('Invalid presigned attachment upload response');
}
if (entry.upload_mode === 'singlepart') {
if (!entry.upload_url) {
throw new Error(`Missing upload_url for singlepart attachment ${entry.id}`);
}
} else if (entry.upload_mode === 'multipart') {
if (!entry.upload_id || !Array.isArray(entry.parts) || entry.parts.length === 0) {
throw new Error(`Missing multipart metadata for attachment ${entry.id}`);
}
} else {
throw new Error(`Unknown upload_mode for attachment ${(entry as {id: string}).id}`);
}
}
return plans;
}
private async uploadAttachmentsViaPlans(params: {
nonce: string;
attachments: Array<ApiAttachmentMetadata>;
files: Array<File>;
plans: Array<PresignedAttachmentUploadResponseAttachment>;
planIndexById: Map<string, number>;
multipartUploadsToComplete: Array<{upload_filename: string; upload_id: string}>;
signal: AbortSignal;
}): Promise<Array<ApiAttachmentMetadata>> {
const {nonce, attachments, files, plans, planIndexById, multipartUploadsToComplete, signal} = params;
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
const loadedBytesByIndex = new Array<number>(files.length).fill(0);
let completedUploads = 0;
const reportProgress = (): void => {
if (totalBytes > 0) {
const uploadedBytes = loadedBytesByIndex.reduce((sum, loaded) => sum + loaded, 0);
CloudUpload.updateSendingProgress(nonce, (uploadedBytes / totalBytes) * 100);
return;
}
if (files.length > 0) {
CloudUpload.updateSendingProgress(nonce, (completedUploads / files.length) * 100);
}
};
for (let index = 0; index < attachments.length; index += 1) {
const attachment = attachments[index];
const file = files[index];
const planIndex = planIndexById.get(String(attachment.id));
if (planIndex == null) {
throw new Error(`Missing presigned upload metadata for attachment ${attachment.id}`);
}
const plan = plans[planIndex];
if (plan.upload_mode === 'singlepart') {
await http.put(plan.upload_url, {
body: file,
headers: {
'Content-Type': plan.content_type,
},
signal,
onProgress: (event) => {
const loaded = Math.min(file.size, event.loaded);
if (loaded > loadedBytesByIndex[index]) {
loadedBytesByIndex[index] = loaded;
reportProgress();
}
},
});
loadedBytesByIndex[index] = file.size;
} else {
multipartUploadsToComplete.push({
upload_filename: plan.upload_filename,
upload_id: plan.upload_id,
});
const parts: Array<ChunkedUploadPart> = plan.parts.map((entry) => ({
partNumber: entry.part_number,
uploadUrl: entry.upload_url,
}));
const uploadPlan: ChunkedUploadPlan = {file, contentType: plan.content_type, partSize: plan.part_size, parts};
await uploadFileInChunks(uploadPlan, {
signal,
onProgress: (uploaded) => {
if (uploaded > loadedBytesByIndex[index]) {
loadedBytesByIndex[index] = uploaded;
reportProgress();
}
},
});
loadedBytesByIndex[index] = file.size;
}
completedUploads += 1;
reportProgress();
}
return attachments.map((attachment) => {
const planIndex = planIndexById.get(String(attachment.id));
if (planIndex == null) {
throw new Error(`Missing presigned upload metadata for attachment ${attachment.id}`);
}
const plan = plans[planIndex];
return {
...attachment,
upload_filename: plan.upload_filename,
file_size: plan.file_size,
content_type: plan.content_type,
};
});
}
private async uploadTextareaAttachmentViaPlan(params: {
channelId: string;
attachmentId: number;
file: File;
plan: PresignedAttachmentUploadResponseAttachment;
signal: AbortSignal;
}): Promise<TextareaAttachmentUploadResult> {
const {channelId, attachmentId, file, plan, signal} = params;
const reportProgress = (uploadedBytes: number): void => {
if (file.size <= 0) {
CloudUpload.updateAttachment(channelId, attachmentId, {status: 'uploading', uploadProgress: 0});
return;
}
const uploadProgress = Math.round((Math.min(file.size, uploadedBytes) / file.size) * 100);
CloudUpload.updateAttachment(channelId, attachmentId, {status: 'uploading', uploadProgress});
};
if (plan.upload_mode === 'singlepart') {
await http.put(plan.upload_url, {
body: file,
headers: {
'Content-Type': plan.content_type,
},
signal,
onProgress: (event) => {
reportProgress(event.loaded);
},
});
} else {
const parts: Array<ChunkedUploadPart> = plan.parts.map((entry) => ({
partNumber: entry.part_number,
uploadUrl: entry.upload_url,
}));
await uploadFileInChunks(
{file, contentType: plan.content_type, partSize: plan.part_size, parts},
{
signal,
onProgress: (uploadedBytes) => {
reportProgress(uploadedBytes);
},
},
);
await this.completeMultipartAttachmentUploads(
channelId,
[{upload_filename: plan.upload_filename, upload_id: plan.upload_id}],
signal,
);
}
return {
uploadFilename: plan.upload_filename,
fileSize: plan.file_size,
contentType: plan.content_type,
};
}
private async completeMultipartAttachmentUploads(
channelId: string,
uploads: Array<{upload_filename: string; upload_id: string}>,
signal: AbortSignal,
): Promise<void> {
const body: CompleteMultipartAttachmentUploadRequestBody = {uploads};
await http.post<CompleteMultipartAttachmentUploadResponseBody>(Endpoints.CHANNEL_ATTACHMENTS_COMPLETE(channelId), {
body,
signal,
});
}
private abortRemainingMultipartUploads(
channelId: string,
uploads: Array<{upload_filename: string; upload_id: string}>,
): void {
if (uploads.length === 0) return;
logger.debug(
`Leaving ${uploads.length} multipart ${uploads.length === 1 ? 'upload' : 'uploads'} for server to GC in channel ${channelId}`,
);
}
private async sendMultipartMessage(
channelId: string,
requestBody: MessageCreateRequest,
files: Array<File>,
signal: AbortSignal,
nonce?: string,
): Promise<RestResponse<Message>> {
const formData = new FormData();
formData['append']('payload_json', JSON.stringify(requestBody));
files.forEach((file, index) => {
formData['append'](`files[${index}]`, file);
});
return http.post<Message>(Endpoints.CHANNEL_MESSAGES(channelId), {
body: formData,
signal,
suppressContentBlockedModal: true,
onProgress: nonce
? (event) => {
if (event.lengthComputable && event.total > 0) {
const progress = (event.loaded / event.total) * 100;
CloudUpload.updateSendingProgress(nonce, progress);
}
}
: undefined,
});
}
private async attemptMessageSend(
channelId: string,
nonce: string,
requestBody: MessageCreateRequest,
files?: Array<File>,
): Promise<
| {status: 'success'; response: RestResponse<Message>}
| {status: 'rateLimit'; error: HttpError}
| {status: 'failure'; error: unknown}
> {
try {
const response = await this.sendMessageRequest(channelId, nonce, requestBody, files);
return {status: 'success', response};
} catch (error) {
return this.buildSendOutcome(error);
}
}
private buildSendOutcome(
error: unknown,
): {status: 'rateLimit'; error: HttpError} | {status: 'failure'; error: unknown} {
const responseErr = error instanceof HttpError ? error : null;
if (responseErr && isRateLimitError(responseErr)) {
return {status: 'rateLimit', error: responseErr};
}
return {status: 'failure', error};
}
private handleSendRateLimit(
payload: SendMessagePayload,
error: HttpError,
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
): void {
const retry = resolveMessageRateLimitRetry(error);
const retryCount = payload.rateLimitRetryCount === undefined ? 0 : payload.rateLimitRetryCount;
if (retry.automaticRetryDelayMs !== null && retryCount < MESSAGE_SEND_RATE_LIMIT_MAX_AUTOMATIC_RETRIES) {
payload.rateLimitRetryCount = retryCount + 1;
completed({retryAfter: retry.automaticRetryDelayMs}, undefined, error);
return;
}
MessageCommands.sendError(payload.channelId, payload.nonce);
if (payload.hasAttachments) {
this.restoreFailedMessage(payload.channelId, payload.nonce);
}
completed(null, undefined, error);
this.handleRateLimitError(retryAfterMsToWholeSeconds(retry.retryAfterMs));
}
private handleSendError(
channelId: string,
nonce: string,
error: unknown,
i18n: I18n,
hasAttachments?: boolean,
): void {
MessageCommands.sendError(channelId, nonce);
if (hasAttachments) {
this.restoreFailedMessage(channelId, nonce);
}
if (!(error instanceof HttpError)) {
this.showErrorModal(error, channelId, hasAttachments);
return;
}
if (isDMRestrictedError(error)) {
const directMessagePrivacySettingsPath = formatUserSettingsPath(i18n, 'privacy_safety', 'communication');
const systemMessage = createSystemMessage(
channelId,
i18n._(YOUR_MESSAGE_COULD_NOT_BE_DELIVERED_THIS_IS_DESCRIPTOR, {directMessagePrivacySettingsPath}),
);
MessageCommands.createOptimistic(channelId, systemMessage.toJSON());
return;
}
const unclaimedErrorCode = getUnclaimedAccountErrorCode(error);
if (unclaimedErrorCode) {
const systemMessage = createSystemMessage(
channelId,
unclaimedErrorCode === APIErrorCodes.UNCLAIMED_ACCOUNT_CANNOT_SEND_DIRECT_MESSAGES
? i18n._(YOUR_MESSAGE_COULD_NOT_BE_DELIVERED_YOU_NEED_DESCRIPTOR)
: i18n._(YOUR_MESSAGE_COULD_NOT_BE_DELIVERED_YOU_NEED_2_DESCRIPTOR),
);
MessageCommands.createOptimistic(channelId, systemMessage.toJSON());
return;
}
if (getApiErrorBody(error)?.code === APIErrorCodes.CONTENT_BLOCKED) {
const systemMessage = createSystemMessage(
channelId,
i18n._(YOUR_MESSAGE_COULD_NOT_BE_DELIVERED_BECAUSE_IT_DESCRIPTOR),
);
MessageCommands.createOptimistic(channelId, systemMessage.toJSON());
return;
}
if (getApiErrorBody(error)?.code === APIErrorCodes.NSFW_EMOJI_STICKER_BLOCKED) {
const systemMessage = createSystemMessage(
channelId,
i18n._(YOUR_MESSAGE_COULD_NOT_BE_DELIVERED_BECAUSE_IT_2_DESCRIPTOR),
);
MessageCommands.createOptimistic(channelId, systemMessage.toJSON());
return;
}
this.showErrorModal(error, channelId, hasAttachments);
}
private restoreFailedMessage(channelId: string, nonce: string): void {
const messageUpload = CloudUpload.getMessageUpload(nonce);
CloudUpload.restoreAttachmentsToTextarea(nonce);
const contentToRestore = messageUpload?.content ?? '';
DraftCommands.createDraft(channelId, contentToRestore);
if (messageUpload?.messageReference) {
MessageCommands.startReply(
channelId,
messageUpload.messageReference.message_id,
messageUpload.allowedMentions?.replied_user ?? true,
);
}
MessageCommands.deleteOptimistic(channelId, nonce);
}
private showErrorModal(error: unknown, channelId?: string, hasAttachments?: boolean): void {
if (error instanceof PresignedUploadFallbackUnavailableError) {
ModalCommands.push(
modal(() => (
<AttachmentUploadConnectivityModal data-flx="messaging.message-queue.attachment-upload-connectivity-modal" />
)),
);
} else if (error instanceof HttpError && isSlowmodeError(error)) {
const retry = resolveMessageRateLimitRetry(error);
const retryAfterMs = SlowmodeCommands.clampSlowmodeRetryAfterMs(retry.retryAfterMs);
if (retryAfterMs <= 0) {
ModalCommands.push(
modal(() => (
<MessageSendFailedModal
hasAttachments={hasAttachments}
data-flx="messaging.message-queue.message-send-failed-modal--invalid-slowmode-retry"
/>
)),
);
return;
}
const retryAfter = Math.ceil(retryAfterMs / 1000);
if (channelId) {
SlowmodeCommands.updateSlowmodeRemaining(channelId, retryAfterMs);
}
ModalCommands.push(
modal(() => (
<SlowmodeRateLimitedModal
retryAfter={retryAfter}
data-flx="messaging.message-queue.slowmode-rate-limited-modal"
/>
)),
);
} else if (error instanceof HttpError && isFeatureDisabledError(error)) {
ModalCommands.push(
modal(() => (
<FeatureTemporarilyDisabledModal data-flx="messaging.message-queue.feature-temporarily-disabled-modal" />
)),
);
} else if (error instanceof HttpError && isExplicitContentError(error)) {
ModalCommands.push(
modal(() => <MatureContentRejectedModal data-flx="messaging.message-queue.mature-content-rejected-modal" />),
);
} else if (error instanceof HttpError && isFileTooLargeError(error)) {
ModalCommands.push(
modal(() => <FileSizeTooLargeModal data-flx="messaging.message-queue.file-size-too-large-modal" />),
);
} else if (!isAbortError(error)) {
ModalCommands.push(
modal(() => (
<MessageSendFailedModal
hasAttachments={hasAttachments}
data-flx="messaging.message-queue.message-send-failed-modal"
/>
)),
);
}
}
private handleRateLimitError(retryAfter: number | null, onRetry?: () => void): void {
ModalCommands.push(
modal(() => {
if (retryAfter === null) {
return (
<MessageSendTooQuickModal
onRetry={onRetry}
data-flx="messaging.message-queue.message-send-too-quick-modal"
/>
);
}
return (
<MessageSendTooQuickModal
retryAfter={retryAfter}
onRetry={onRetry}
data-flx="messaging.message-queue.message-send-too-quick-modal"
/>
);
}),
);
}
}
export default new MessageQueue();