mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): submit message edits in background
This commit is contained in:
1 parent
c0048504db
commit
8e17970632
6 files changed
+160
-214
No files matched your search
@@ -200,18 +200,15 @@ export const UserMessage = observer(() => {
|
|||||||
finishEditing();
|
finishEditing();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
MessageCommands.edit(
|
finishEditing();
|
||||||
|
void MessageCommands.edit(
|
||||||
channel.id,
|
channel.id,
|
||||||
message.id,
|
message.id,
|
||||||
'',
|
'',
|
||||||
undefined,
|
undefined,
|
||||||
message._allowedMentions,
|
message._allowedMentions,
|
||||||
buildExistingAttachmentEditReferences(message),
|
buildExistingAttachmentEditReferences(message),
|
||||||
).then((result) => {
|
);
|
||||||
if (result) {
|
|
||||||
finishEditing();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleDelete();
|
handleDelete();
|
||||||
@@ -220,11 +217,8 @@ export const UserMessage = observer(() => {
|
|||||||
if (checkCustomEmojiAvailability(content)) {
|
if (checkCustomEmojiAvailability(content)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions).then((result) => {
|
finishEditing();
|
||||||
if (result) {
|
void MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions);
|
||||||
finishEditing();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
channel.id,
|
channel.id,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
import {resolveMessagePageState} from '@app/features/messaging/commands/MessagePageStateMachine';
|
import {resolveMessagePageState} from '@app/features/messaging/commands/MessagePageStateMachine';
|
||||||
import {MessageDeleteFailedModal} from '@app/features/messaging/components/alerts/MessageDeleteFailedModal';
|
import {MessageDeleteFailedModal} from '@app/features/messaging/components/alerts/MessageDeleteFailedModal';
|
||||||
import {MessageDeleteTooQuickModal} from '@app/features/messaging/components/alerts/MessageDeleteTooQuickModal';
|
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 type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
|
import type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
|
||||||
import type {JumpOptions} from '@app/features/messaging/state/ChannelMessages';
|
import type {JumpOptions} from '@app/features/messaging/state/ChannelMessages';
|
||||||
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
||||||
@@ -34,8 +36,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
type ApiAttachmentMetadata,
|
type ApiAttachmentMetadata,
|
||||||
type ApiMessageEditAttachmentMetadata,
|
type ApiMessageEditAttachmentMetadata,
|
||||||
|
buildMessageEditRequest,
|
||||||
normalizeMessageContent,
|
normalizeMessageContent,
|
||||||
} from '@app/features/messaging/utils/MessageRequestUtils';
|
} 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 IARCommands from '@app/features/moderation/commands/IARCommands';
|
||||||
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
||||||
import Permission from '@app/features/permissions/state/Permission';
|
import Permission from '@app/features/permissions/state/Permission';
|
||||||
@@ -80,6 +84,8 @@ const ALSO_REPORT_TO_SAFETY_TEAM_DESCRIPTOR = msg({
|
|||||||
'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).',
|
'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 logger = new Logger('MessageCommands');
|
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 pendingDeletePromises = new Map<string, Promise<void>>();
|
||||||
const pendingFetchPromises = new Map<string, Promise<Array<WireMessage>>>();
|
const pendingFetchPromises = new Map<string, Promise<Array<WireMessage>>>();
|
||||||
|
|
||||||
@@ -601,7 +607,42 @@ function showDeleteFailureModal(error: unknown, messageId: string): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function edit(
|
function showEditFailureModal(error: unknown): void {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModalCommands.push(
|
||||||
|
modal(() => <MessageEditFailedModal data-flx="messaging.message-commands.message-edit-failed-modal" />),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function edit(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
content?: string,
|
content?: string,
|
||||||
@@ -609,31 +650,22 @@ export function edit(
|
|||||||
allowedMentions?: AllowedMentions,
|
allowedMentions?: AllowedMentions,
|
||||||
attachments?: Array<ApiMessageEditAttachmentMetadata>,
|
attachments?: Array<ApiMessageEditAttachmentMetadata>,
|
||||||
): Promise<WireMessage | null> {
|
): Promise<WireMessage | null> {
|
||||||
return new Promise<WireMessage | null>((resolve) => {
|
logger.debug(`Editing message ${messageId} in channel ${channelId}`);
|
||||||
logger.debug(`Enqueueing edit for message ${messageId} in channel ${channelId}`);
|
try {
|
||||||
MessageQueue.enqueue(
|
const response = await http.patch<WireMessage>(Endpoints.CHANNEL_MESSAGE(channelId, messageId), {
|
||||||
{
|
body: buildMessageEditRequest({content, flags, allowedMentions, attachments}),
|
||||||
type: 'edit',
|
mode: 'auto-retry',
|
||||||
channelId,
|
retries: MESSAGE_EDIT_MAX_RETRIES,
|
||||||
messageId,
|
timeoutMs: MESSAGE_EDIT_TIMEOUT_MS,
|
||||||
content,
|
suppressContentBlockedModal: true,
|
||||||
allowedMentions,
|
});
|
||||||
flags,
|
logger.debug(`Message edited successfully: ${messageId} in channel ${channelId}`);
|
||||||
attachments,
|
return response.body ?? null;
|
||||||
},
|
} catch (error) {
|
||||||
(result, error) => {
|
logger.error(`Message edit failed: ${messageId} in channel ${channelId}`, error);
|
||||||
if (result?.body) {
|
showEditFailureModal(error);
|
||||||
logger.debug(`Message edited successfully: ${messageId} in channel ${channelId}`);
|
return null;
|
||||||
resolve(result.body);
|
}
|
||||||
} else {
|
|
||||||
if (error) {
|
|
||||||
logger.debug(`Message edit failed: ${messageId} in channel ${channelId}`, error);
|
|
||||||
}
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(channelId: string, messageId: string): Promise<void> {
|
export async function remove(channelId: string, messageId: string): Promise<void> {
|
||||||
|
|||||||
@@ -481,18 +481,15 @@ export const useTextareaSubmit = ({
|
|||||||
finishMobileEdit();
|
finishMobileEdit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
MessageCommands.edit(
|
finishMobileEdit();
|
||||||
|
void MessageCommands.edit(
|
||||||
channelId,
|
channelId,
|
||||||
editingMessage.id,
|
editingMessage.id,
|
||||||
'',
|
'',
|
||||||
undefined,
|
undefined,
|
||||||
editingMessage._allowedMentions,
|
editingMessage._allowedMentions,
|
||||||
buildExistingAttachmentEditReferences(editingMessage),
|
buildExistingAttachmentEditReferences(editingMessage),
|
||||||
).then((result) => {
|
);
|
||||||
if (result) {
|
|
||||||
finishMobileEdit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
MessageCommands.showDeleteConfirmation(i18n, {
|
MessageCommands.showDeleteConfirmation(i18n, {
|
||||||
@@ -506,17 +503,14 @@ export const useTextareaSubmit = ({
|
|||||||
if (checkCustomEmojiAvailability(resolvedContent)) {
|
if (checkCustomEmojiAvailability(resolvedContent)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
MessageCommands.edit(
|
finishMobileEdit();
|
||||||
|
void MessageCommands.edit(
|
||||||
channelId,
|
channelId,
|
||||||
editingMessage.id,
|
editingMessage.id,
|
||||||
resolvedContent,
|
resolvedContent,
|
||||||
undefined,
|
undefined,
|
||||||
editingMessage._allowedMentions,
|
editingMessage._allowedMentions,
|
||||||
).then((result) => {
|
);
|
||||||
if (result) {
|
|
||||||
finishMobileEdit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasVisibleMessageContent(resolvedContent) && uploadAttachmentsLength === 0 && !hasPendingSticker) {
|
if (!hasVisibleMessageContent(resolvedContent) && uploadAttachmentsLength === 0 && !hasPendingSticker) {
|
||||||
|
|||||||
@@ -10,15 +10,11 @@ import * as DraftCommands from '@app/features/messaging/commands/DraftCommands';
|
|||||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||||
import {AttachmentUploadConnectivityModal} from '@app/features/messaging/components/alerts/AttachmentUploadConnectivityModal';
|
import {AttachmentUploadConnectivityModal} from '@app/features/messaging/components/alerts/AttachmentUploadConnectivityModal';
|
||||||
import {FileSizeTooLargeModal} from '@app/features/messaging/components/alerts/FileSizeTooLargeModal';
|
import {FileSizeTooLargeModal} from '@app/features/messaging/components/alerts/FileSizeTooLargeModal';
|
||||||
import {MessageEditFailedModal} from '@app/features/messaging/components/alerts/MessageEditFailedModal';
|
|
||||||
import {MessageEditTooQuickModal} from '@app/features/messaging/components/alerts/MessageEditTooQuickModal';
|
|
||||||
import {MessageSendFailedModal} from '@app/features/messaging/components/alerts/MessageSendFailedModal';
|
import {MessageSendFailedModal} from '@app/features/messaging/components/alerts/MessageSendFailedModal';
|
||||||
import {MessageSendTooQuickModal} from '@app/features/messaging/components/alerts/MessageSendTooQuickModal';
|
import {MessageSendTooQuickModal} from '@app/features/messaging/components/alerts/MessageSendTooQuickModal';
|
||||||
import {
|
import {
|
||||||
type MessageLocalSendRateLimitState,
|
type MessageLocalSendRateLimitState,
|
||||||
type MessageQueueRequestOutcomeStatus,
|
|
||||||
resolveMessageLocalSendRateLimitDecision,
|
resolveMessageLocalSendRateLimitDecision,
|
||||||
resolveMessageQueuePayloadRouteDecision,
|
|
||||||
resolveMessageQueueRequestOutcomeDecision,
|
resolveMessageQueueRequestOutcomeDecision,
|
||||||
resolveMessageQueueSendExecutionDecision,
|
resolveMessageQueueSendExecutionDecision,
|
||||||
} from '@app/features/messaging/state/MessageQueueStateMachine';
|
} from '@app/features/messaging/state/MessageQueueStateMachine';
|
||||||
@@ -33,11 +29,8 @@ import {exceedsMultipartFallbackRequestSize} from '@app/features/messaging/utils
|
|||||||
import {prepareAttachmentsForNonce} from '@app/features/messaging/utils/MessageAttachmentUtils';
|
import {prepareAttachmentsForNonce} from '@app/features/messaging/utils/MessageAttachmentUtils';
|
||||||
import {
|
import {
|
||||||
type ApiAttachmentMetadata,
|
type ApiAttachmentMetadata,
|
||||||
type ApiMessageEditAttachmentMetadata,
|
|
||||||
buildMessageCreateRequest,
|
buildMessageCreateRequest,
|
||||||
type MessageCreateRequest,
|
type MessageCreateRequest,
|
||||||
type MessageEditRequest,
|
|
||||||
normalizeMessageEditContent,
|
|
||||||
} from '@app/features/messaging/utils/MessageRequestUtils';
|
} from '@app/features/messaging/utils/MessageRequestUtils';
|
||||||
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
|
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
|
||||||
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
|
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
|
||||||
@@ -120,17 +113,7 @@ interface SendMessagePayload extends BaseMessagePayload {
|
|||||||
tts?: boolean;
|
tts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditMessagePayload extends BaseMessagePayload {
|
export type MessageQueuePayload = SendMessagePayload;
|
||||||
type: 'edit';
|
|
||||||
messageId: string;
|
|
||||||
rateLimitRetryCount?: number;
|
|
||||||
content?: string;
|
|
||||||
allowedMentions?: AllowedMentions;
|
|
||||||
flags?: number;
|
|
||||||
attachments?: Array<ApiMessageEditAttachmentMetadata>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MessageQueuePayload = SendMessagePayload | EditMessagePayload;
|
|
||||||
type MessageQueueCompletion<TResult> = {
|
type MessageQueueCompletion<TResult> = {
|
||||||
retry: RetryError | null;
|
retry: RetryError | null;
|
||||||
result?: TResult;
|
result?: TResult;
|
||||||
@@ -260,11 +243,6 @@ function isRateLimitError(error: HttpError): boolean {
|
|||||||
return !isSlowmodeError(error);
|
return !isSlowmodeError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRequestErrorOutcomeStatus(error: HttpError): MessageQueueRequestOutcomeStatus {
|
|
||||||
if (isRateLimitError(error)) return 'rateLimit';
|
|
||||||
return 'failure';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSlowmodeError(error: HttpError): boolean {
|
function isSlowmodeError(error: HttpError): boolean {
|
||||||
if (error.status !== 400 && error.status !== 429) return false;
|
if (error.status !== 400 && error.status !== 429) return false;
|
||||||
const body = getApiErrorBody(error);
|
const body = getApiErrorBody(error);
|
||||||
@@ -351,13 +329,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
|
|||||||
}
|
}
|
||||||
const error = new Error(`Message queue capacity of ${this.maxSize} entries was reached`);
|
const error = new Error(`Message queue capacity of ${this.maxSize} entries was reached`);
|
||||||
logger.error('Rejected message queue entry because capacity was reached', error);
|
logger.error('Rejected message queue entry because capacity was reached', error);
|
||||||
if (message.type === 'send') {
|
this.handleSendError(message.channelId, message.nonce, error, i18n, message.hasAttachments);
|
||||||
this.handleSendError(message.channelId, message.nonce, error, i18n, message.hasAttachments);
|
|
||||||
} else {
|
|
||||||
ModalCommands.push(
|
|
||||||
modal(() => <MessageEditFailedModal data-flx="messaging.message-queue.message-edit-failed-modal--capacity" />),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
success(undefined, error);
|
success(undefined, error);
|
||||||
} catch (callbackError) {
|
} catch (callbackError) {
|
||||||
@@ -407,19 +379,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
|
|||||||
message: MessageQueuePayload,
|
message: MessageQueuePayload,
|
||||||
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
|
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
|
||||||
): Promise<unknown> | undefined {
|
): Promise<unknown> | undefined {
|
||||||
const route = resolveMessageQueuePayloadRouteDecision({
|
return this.handleSend(message, completed);
|
||||||
payloadType: (message as {type?: string}).type,
|
|
||||||
});
|
|
||||||
switch (route.type) {
|
|
||||||
case 'send':
|
|
||||||
return this.handleSend(message as SendMessagePayload, completed);
|
|
||||||
case 'edit':
|
|
||||||
return this.handleEdit(message as EditMessagePayload, completed);
|
|
||||||
case 'unknown':
|
|
||||||
logger.error('Unknown message type, completing with null');
|
|
||||||
completed(null, undefined, new Error('Unknown message queue payload'));
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private consumeLocalSendAllowance(channelId: string): boolean {
|
private consumeLocalSendAllowance(channelId: string): boolean {
|
||||||
@@ -1429,127 +1389,6 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleEdit(
|
|
||||||
payload: EditMessagePayload,
|
|
||||||
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const {channelId, messageId, content, allowedMentions, flags, attachments} = payload;
|
|
||||||
const abortController = new AbortController();
|
|
||||||
this.abortControllers.set(messageId, abortController);
|
|
||||||
try {
|
|
||||||
logger.debug(`Editing message ${messageId} in channel ${channelId}`);
|
|
||||||
const body = this.buildEditRequestBody(content, allowedMentions, flags, attachments);
|
|
||||||
const response = await http.patch<Message>(Endpoints.CHANNEL_MESSAGE(channelId, messageId), {
|
|
||||||
body,
|
|
||||||
signal: abortController.signal,
|
|
||||||
suppressContentBlockedModal: true,
|
|
||||||
});
|
|
||||||
logger.debug(`Successfully edited message ${messageId} in channel ${channelId}`);
|
|
||||||
completed(null, response);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to edit message ${messageId} in channel ${channelId}:`, error);
|
|
||||||
if (!(error instanceof HttpError)) {
|
|
||||||
ModalCommands.push(
|
|
||||||
modal(() => <MessageEditFailedModal data-flx="messaging.message-queue.message-edit-failed-modal--request" />),
|
|
||||||
);
|
|
||||||
completed(null, undefined, error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const responseErr = error;
|
|
||||||
const outcomeDecision = resolveMessageQueueRequestOutcomeDecision({
|
|
||||||
status: getRequestErrorOutcomeStatus(responseErr),
|
|
||||||
});
|
|
||||||
switch (outcomeDecision.type) {
|
|
||||||
case 'retryRateLimit':
|
|
||||||
this.handleEditRateLimit(payload, responseErr, completed);
|
|
||||||
break;
|
|
||||||
case 'completeFailure':
|
|
||||||
case 'completeSuccess':
|
|
||||||
this.showEditErrorModal(responseErr);
|
|
||||||
completed(null, undefined, responseErr);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.abortControllers.delete(messageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildEditRequestBody(
|
|
||||||
content?: string,
|
|
||||||
allowedMentions?: AllowedMentions,
|
|
||||||
flags?: number,
|
|
||||||
attachments?: Array<ApiMessageEditAttachmentMetadata>,
|
|
||||||
): MessageEditRequest {
|
|
||||||
const body: MessageEditRequest = {};
|
|
||||||
if (content !== undefined) {
|
|
||||||
body.content = normalizeMessageEditContent(content);
|
|
||||||
}
|
|
||||||
if (allowedMentions !== undefined) {
|
|
||||||
body.allowed_mentions = allowedMentions;
|
|
||||||
}
|
|
||||||
if (flags !== undefined) {
|
|
||||||
body.flags = flags;
|
|
||||||
}
|
|
||||||
if (attachments !== undefined) {
|
|
||||||
body.attachments = attachments;
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleEditRateLimit(
|
|
||||||
payload: EditMessagePayload,
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
completed(null, undefined, error);
|
|
||||||
this.handleEditRateLimitError(retryAfterMsToWholeSeconds(retry.retryAfterMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
private showEditErrorModal(error: HttpError): void {
|
|
||||||
if (isFeatureDisabledError(error)) {
|
|
||||||
ModalCommands.push(
|
|
||||||
modal(() => (
|
|
||||||
<FeatureTemporarilyDisabledModal data-flx="messaging.message-queue.feature-temporarily-disabled-modal--2" />
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
} else if (getApiErrorBody(error)?.code === APIErrorCodes.CONTENT_BLOCKED) {
|
|
||||||
void import('@app/features/auth/components/ContentBlockedHandler').then((m) => m.showContentBlockedModal());
|
|
||||||
} else {
|
|
||||||
ModalCommands.push(
|
|
||||||
modal(() => <MessageEditFailedModal data-flx="messaging.message-queue.message-edit-failed-modal" />),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleEditRateLimitError(retryAfter: number | null, onRetry?: () => void): void {
|
|
||||||
ModalCommands.push(
|
|
||||||
modal(() => {
|
|
||||||
if (retryAfter === null) {
|
|
||||||
return (
|
|
||||||
<MessageEditTooQuickModal
|
|
||||||
onRetry={onRetry}
|
|
||||||
data-flx="messaging.message-queue.message-edit-too-quick-modal"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<MessageEditTooQuickModal
|
|
||||||
retryAfter={retryAfter}
|
|
||||||
onRetry={onRetry}
|
|
||||||
data-flx="messaging.message-queue.message-edit-too-quick-modal"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new MessageQueue();
|
export default new MessageQueue();
|
||||||
@@ -57,6 +57,13 @@ export interface MessageEditRequest {
|
|||||||
flags?: number;
|
flags?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageEditPayload {
|
||||||
|
content?: string;
|
||||||
|
attachments?: Array<ApiMessageEditAttachmentMetadata>;
|
||||||
|
allowedMentions?: AllowedMentions;
|
||||||
|
flags?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageCreatePayload {
|
export interface MessageCreatePayload {
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
nonce?: string;
|
nonce?: string;
|
||||||
@@ -121,6 +128,24 @@ export function buildMessageCreateRequest(payload: MessageCreatePayload): Messag
|
|||||||
return requestBody;
|
return requestBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildMessageEditRequest(payload: MessageEditPayload): MessageEditRequest {
|
||||||
|
const {content, attachments, allowedMentions, flags} = payload;
|
||||||
|
const requestBody: MessageEditRequest = {};
|
||||||
|
if (content !== undefined) {
|
||||||
|
requestBody.content = normalizeMessageEditContent(content);
|
||||||
|
}
|
||||||
|
if (attachments !== undefined) {
|
||||||
|
requestBody.attachments = attachments;
|
||||||
|
}
|
||||||
|
if (allowedMentions !== undefined) {
|
||||||
|
requestBody.allowed_mentions = allowedMentions;
|
||||||
|
}
|
||||||
|
if (flags !== undefined) {
|
||||||
|
requestBody.flags = flags;
|
||||||
|
}
|
||||||
|
return requestBody;
|
||||||
|
}
|
||||||
|
|
||||||
const isSilentMessage = (content: string): boolean => {
|
const isSilentMessage = (content: string): boolean => {
|
||||||
return content.startsWith('@silent ');
|
return content.startsWith('@silent ');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,6 +78,63 @@ type AttemptDecision =
|
|||||||
| {next: 'retry-after'; delayMs: number; mode: 'backoff' | 'fixed'}
|
| {next: 'retry-after'; delayMs: number; mode: 'backoff' | 'fixed'}
|
||||||
| {next: 'fail'; error: unknown};
|
| {next: 'fail'; error: unknown};
|
||||||
|
|
||||||
|
interface OnlineWaiter {
|
||||||
|
resolve: () => void;
|
||||||
|
signal: AbortSignal | undefined;
|
||||||
|
onAbort: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlineWaiters = new Set<OnlineWaiter>();
|
||||||
|
let onlineListenerActive = false;
|
||||||
|
|
||||||
|
function createRequestAbortError(): DOMException {
|
||||||
|
return new DOMException('Request aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOnlineListener(): void {
|
||||||
|
if (!onlineListenerActive) return;
|
||||||
|
window.removeEventListener('online', resolveOnlineWaiters);
|
||||||
|
onlineListenerActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseOnlineWaiter(waiter: OnlineWaiter): boolean {
|
||||||
|
if (!onlineWaiters.delete(waiter)) return false;
|
||||||
|
waiter.signal?.removeEventListener('abort', waiter.onAbort);
|
||||||
|
if (onlineWaiters.size === 0) removeOnlineListener();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOnlineWaiters(): void {
|
||||||
|
const pending = Array.from(onlineWaiters);
|
||||||
|
onlineWaiters.clear();
|
||||||
|
removeOnlineListener();
|
||||||
|
for (const waiter of pending) {
|
||||||
|
waiter.signal?.removeEventListener('abort', waiter.onAbort);
|
||||||
|
waiter.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitUntilOnline(signal?: AbortSignal): Promise<void> {
|
||||||
|
if (signal?.aborted) return Promise.reject(createRequestAbortError());
|
||||||
|
if (navigator.onLine) return Promise.resolve();
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const waiter: OnlineWaiter = {
|
||||||
|
resolve,
|
||||||
|
signal,
|
||||||
|
onAbort: () => {
|
||||||
|
if (releaseOnlineWaiter(waiter)) reject(createRequestAbortError());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
onlineWaiters.add(waiter);
|
||||||
|
if (signal) signal.addEventListener('abort', waiter.onAbort, {once: true});
|
||||||
|
if (!onlineListenerActive) {
|
||||||
|
window.addEventListener('online', resolveOnlineWaiters);
|
||||||
|
onlineListenerActive = true;
|
||||||
|
}
|
||||||
|
if (navigator.onLine) queueMicrotask(resolveOnlineWaiters);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export class RestClient {
|
export class RestClient {
|
||||||
private readonly state: RuntimeState = {
|
private readonly state: RuntimeState = {
|
||||||
baseUrl: '/api',
|
baseUrl: '/api',
|
||||||
@@ -216,6 +273,7 @@ async function runRetryLoop<T>(
|
|||||||
attempt: number,
|
attempt: number,
|
||||||
): Promise<RestResponse<T>> {
|
): Promise<RestResponse<T>> {
|
||||||
const plan = composePlan(state, method, path, options, sudoApplied);
|
const plan = composePlan(state, method, path, options, sudoApplied);
|
||||||
|
if (plan.retries > 0) await waitUntilOnline(plan.signal);
|
||||||
const pacingHit = consultPacing(state.pacing, plan.rateLimitKey);
|
const pacingHit = consultPacing(state.pacing, plan.rateLimitKey);
|
||||||
if (pacingHit) {
|
if (pacingHit) {
|
||||||
if (plan.mode === 'auto-retry') {
|
if (plan.mode === 'auto-retry') {
|
||||||
@@ -240,7 +298,11 @@ async function runRetryLoop<T>(
|
|||||||
return finalizeAfterRetriesExhausted<T>(state, plan, outcome);
|
return finalizeAfterRetriesExhausted<T>(state, plan, outcome);
|
||||||
}
|
}
|
||||||
const wait = decision.mode === 'backoff' ? computeBackoffMs(attempt) : decision.delayMs;
|
const wait = decision.mode === 'backoff' ? computeBackoffMs(attempt) : decision.delayMs;
|
||||||
await delay(wait, plan.signal);
|
if (outcome.status === 'transport-error' && !navigator.onLine) {
|
||||||
|
await waitUntilOnline(plan.signal);
|
||||||
|
} else {
|
||||||
|
await delay(wait, plan.signal);
|
||||||
|
}
|
||||||
return runRetryLoop<T>(state, method, path, options, sudoApplied, attempt + 1);
|
return runRetryLoop<T>(state, method, path, options, sudoApplied, attempt + 1);
|
||||||
}
|
}
|
||||||
case 'fail':
|
case 'fail':
|
||||||
|
|||||||
Reference in new issue
Block a user