fix(app): submit message edits in background

This commit is contained in:
Hampus authored and GitHub committed 2026-08-26 23:54:28 +02:00
1 parent c0048504db
commit 8e17970632
6 files changed
+160 -214

No files matched your search

@@ -200,18 +200,15 @@ export const UserMessage = observer(() => {
finishEditing();
return;
}
MessageCommands.edit(
finishEditing();
void MessageCommands.edit(
channel.id,
message.id,
'',
undefined,
message._allowedMentions,
buildExistingAttachmentEditReferences(message),
).then((result) => {
if (result) {
finishEditing();
}
});
);
return;
}
handleDelete();
@@ -220,11 +217,8 @@ export const UserMessage = observer(() => {
if (checkCustomEmojiAvailability(content)) {
return;
}
MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions).then((result) => {
if (result) {
finishEditing();
}
});
finishEditing();
void MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions);
},
[
channel.id,
@@ -19,6 +19,8 @@ import {
import {resolveMessagePageState} from '@app/features/messaging/commands/MessagePageStateMachine';
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 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';
@@ -34,8 +36,10 @@ import {
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';
@@ -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).',
});
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>>>();
@@ -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,
messageId: string,
content?: string,
@@ -609,31 +650,22 @@ export function edit(
allowedMentions?: AllowedMentions,
attachments?: Array<ApiMessageEditAttachmentMetadata>,
): Promise<WireMessage | null> {
return new Promise<WireMessage | null>((resolve) => {
logger.debug(`Enqueueing edit for message ${messageId} in channel ${channelId}`);
MessageQueue.enqueue(
{
type: 'edit',
channelId,
messageId,
content,
allowedMentions,
flags,
attachments,
},
(result, error) => {
if (result?.body) {
logger.debug(`Message edited successfully: ${messageId} in channel ${channelId}`);
resolve(result.body);
} else {
if (error) {
logger.debug(`Message edit failed: ${messageId} in channel ${channelId}`, error);
}
resolve(null);
}
},
);
});
logger.debug(`Editing message ${messageId} in channel ${channelId}`);
try {
const response = await http.patch<WireMessage>(Endpoints.CHANNEL_MESSAGE(channelId, messageId), {
body: buildMessageEditRequest({content, flags, allowedMentions, attachments}),
mode: '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> {
@@ -481,18 +481,15 @@ export const useTextareaSubmit = ({
finishMobileEdit();
return;
}
MessageCommands.edit(
finishMobileEdit();
void MessageCommands.edit(
channelId,
editingMessage.id,
'',
undefined,
editingMessage._allowedMentions,
buildExistingAttachmentEditReferences(editingMessage),
).then((result) => {
if (result) {
finishMobileEdit();
}
});
);
return;
}
MessageCommands.showDeleteConfirmation(i18n, {
@@ -506,17 +503,14 @@ export const useTextareaSubmit = ({
if (checkCustomEmojiAvailability(resolvedContent)) {
return;
}
MessageCommands.edit(
finishMobileEdit();
void MessageCommands.edit(
channelId,
editingMessage.id,
resolvedContent,
undefined,
editingMessage._allowedMentions,
).then((result) => {
if (result) {
finishMobileEdit();
}
});
);
return;
}
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 {AttachmentUploadConnectivityModal} from '@app/features/messaging/components/alerts/AttachmentUploadConnectivityModal';
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 {MessageSendTooQuickModal} from '@app/features/messaging/components/alerts/MessageSendTooQuickModal';
import {
type MessageLocalSendRateLimitState,
type MessageQueueRequestOutcomeStatus,
resolveMessageLocalSendRateLimitDecision,
resolveMessageQueuePayloadRouteDecision,
resolveMessageQueueRequestOutcomeDecision,
resolveMessageQueueSendExecutionDecision,
} 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 {
type ApiAttachmentMetadata,
type ApiMessageEditAttachmentMetadata,
buildMessageCreateRequest,
type MessageCreateRequest,
type MessageEditRequest,
normalizeMessageEditContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
@@ -120,17 +113,7 @@ interface SendMessagePayload extends BaseMessagePayload {
tts?: boolean;
}
interface EditMessagePayload extends BaseMessagePayload {
type: 'edit';
messageId: string;
rateLimitRetryCount?: number;
content?: string;
allowedMentions?: AllowedMentions;
flags?: number;
attachments?: Array<ApiMessageEditAttachmentMetadata>;
}
export type MessageQueuePayload = SendMessagePayload | EditMessagePayload;
export type MessageQueuePayload = SendMessagePayload;
type MessageQueueCompletion<TResult> = {
retry: RetryError | null;
result?: TResult;
@@ -260,11 +243,6 @@ function isRateLimitError(error: HttpError): boolean {
return !isSlowmodeError(error);
}
function getRequestErrorOutcomeStatus(error: HttpError): MessageQueueRequestOutcomeStatus {
if (isRateLimitError(error)) return 'rateLimit';
return 'failure';
}
function isSlowmodeError(error: HttpError): boolean {
if (error.status !== 400 && error.status !== 429) return false;
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`);
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);
} else {
ModalCommands.push(
modal(() => <MessageEditFailedModal data-flx="messaging.message-queue.message-edit-failed-modal--capacity" />),
);
}
this.handleSendError(message.channelId, message.nonce, error, i18n, message.hasAttachments);
try {
success(undefined, error);
} catch (callbackError) {
@@ -407,19 +379,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
message: MessageQueuePayload,
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
): Promise<unknown> | undefined {
const route = resolveMessageQueuePayloadRouteDecision({
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;
}
return this.handleSend(message, completed);
}
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();
@@ -57,6 +57,13 @@ export interface MessageEditRequest {
flags?: number;
}
export interface MessageEditPayload {
content?: string;
attachments?: Array<ApiMessageEditAttachmentMetadata>;
allowedMentions?: AllowedMentions;
flags?: number;
}
export interface MessageCreatePayload {
content?: string | null;
nonce?: string;
@@ -121,6 +128,24 @@ export function buildMessageCreateRequest(payload: MessageCreatePayload): Messag
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 => {
return content.startsWith('@silent ');
};
@@ -78,6 +78,63 @@ type AttemptDecision =
| {next: 'retry-after'; delayMs: number; mode: 'backoff' | 'fixed'}
| {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 {
private readonly state: RuntimeState = {
baseUrl: '/api',
@@ -216,6 +273,7 @@ async function runRetryLoop<T>(
attempt: number,
): Promise<RestResponse<T>> {
const plan = composePlan(state, method, path, options, sudoApplied);
if (plan.retries > 0) await waitUntilOnline(plan.signal);
const pacingHit = consultPacing(state.pacing, plan.rateLimitKey);
if (pacingHit) {
if (plan.mode === 'auto-retry') {
@@ -240,7 +298,11 @@ async function runRetryLoop<T>(
return finalizeAfterRetriesExhausted<T>(state, plan, outcome);
}
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);
}
case 'fail':