diff --git a/fluxer_app/src/features/channel/components/UserMessage.tsx b/fluxer_app/src/features/channel/components/UserMessage.tsx index 505f79825..57b454202 100644 --- a/fluxer_app/src/features/channel/components/UserMessage.tsx +++ b/fluxer_app/src/features/channel/components/UserMessage.tsx @@ -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, diff --git a/fluxer_app/src/features/messaging/commands/MessageCommands.tsx b/fluxer_app/src/features/messaging/commands/MessageCommands.tsx index 6c3e7e648..b452d0242 100644 --- a/fluxer_app/src/features/messaging/commands/MessageCommands.tsx +++ b/fluxer_app/src/features/messaging/commands/MessageCommands.tsx @@ -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>(); const pendingFetchPromises = new Map>>(); @@ -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(() => ( + + )), + ); + return; + } + if (error.status === 403 && errorCode === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) { + ModalCommands.push( + modal(() => ( + + )), + ); + return; + } + if (errorCode === APIErrorCodes.CONTENT_BLOCKED) { + void import('@app/features/auth/components/ContentBlockedHandler').then((module) => + module.showContentBlockedModal(), + ); + return; + } + } + ModalCommands.push( + modal(() => ), + ); +} + +export async function edit( channelId: string, messageId: string, content?: string, @@ -609,31 +650,22 @@ export function edit( allowedMentions?: AllowedMentions, attachments?: Array, ): Promise { - return new Promise((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(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 { diff --git a/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts b/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts index 708321553..743bd5d42 100644 --- a/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts +++ b/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts @@ -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) { diff --git a/fluxer_app/src/features/messaging/state/MessageQueue.tsx b/fluxer_app/src/features/messaging/state/MessageQueue.tsx index 05c5aa43c..e139427c6 100644 --- a/fluxer_app/src/features/messaging/state/MessageQueue.tsx +++ b/fluxer_app/src/features/messaging/state/MessageQueue.tsx @@ -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; -} - -export type MessageQueuePayload = SendMessagePayload | EditMessagePayload; +export type MessageQueuePayload = SendMessagePayload; type MessageQueueCompletion = { 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 ), - ); - } + 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, error?: unknown) => void, ): Promise | 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, error?: unknown) => void, - ): Promise { - 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(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(() => ), - ); - 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, - ): 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, 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(() => ( - - )), - ); - } else if (getApiErrorBody(error)?.code === APIErrorCodes.CONTENT_BLOCKED) { - void import('@app/features/auth/components/ContentBlockedHandler').then((m) => m.showContentBlockedModal()); - } else { - ModalCommands.push( - modal(() => ), - ); - } - } - - private handleEditRateLimitError(retryAfter: number | null, onRetry?: () => void): void { - ModalCommands.push( - modal(() => { - if (retryAfter === null) { - return ( - - ); - } - return ( - - ); - }), - ); - } } export default new MessageQueue(); diff --git a/fluxer_app/src/features/messaging/utils/MessageRequestUtils.ts b/fluxer_app/src/features/messaging/utils/MessageRequestUtils.ts index d6b4a5478..1432d05ea 100644 --- a/fluxer_app/src/features/messaging/utils/MessageRequestUtils.ts +++ b/fluxer_app/src/features/messaging/utils/MessageRequestUtils.ts @@ -57,6 +57,13 @@ export interface MessageEditRequest { flags?: number; } +export interface MessageEditPayload { + content?: string; + attachments?: Array; + 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 '); }; diff --git a/fluxer_app/src/features/platform/transport/RestTransport.ts b/fluxer_app/src/features/platform/transport/RestTransport.ts index 0f76cf57a..edabf1ab4 100644 --- a/fluxer_app/src/features/platform/transport/RestTransport.ts +++ b/fluxer_app/src/features/platform/transport/RestTransport.ts @@ -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(); +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 { + if (signal?.aborted) return Promise.reject(createRequestAbortError()); + if (navigator.onLine) return Promise.resolve(); + return new Promise((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( attempt: number, ): Promise> { 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( return finalizeAfterRetriesExhausted(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(state, method, path, options, sudoApplied, attempt + 1); } case 'fail':