feat(app): toggle for sequential msg file send order (#1245)

Co-authored-by: Hampus <hampus@fluxer.app>
This commit is contained in:
Mizarc
2026-07-02 03:35:03 +02:00
committed by GitHub
co-authored by Hampus
parent 33d05f9763
commit de56e3fd81
7 changed files with 150 additions and 3 deletions
@@ -601,6 +601,7 @@ export interface AccessibilitySettings {
stayInteractiveWhenUnfocused: boolean;
firstClickPassThroughWhenUnfocused: boolean;
scrollToBottomOnMessageSend: boolean;
sequentialFileSend: boolean;
showNeko: boolean;
keepNekoStill: boolean;
showVideoSeekPreviewThumbnails: boolean;
@@ -718,6 +719,7 @@ class Accessibility {
stayInteractiveWhenUnfocused = false;
firstClickPassThroughWhenUnfocused = false;
scrollToBottomOnMessageSend = true;
sequentialFileSend = false;
showNeko = false;
keepNekoStill = false;
showVideoSeekPreviewThumbnails = false;
@@ -828,6 +830,7 @@ class Accessibility {
'stayInteractiveWhenUnfocused',
'firstClickPassThroughWhenUnfocused',
'scrollToBottomOnMessageSend',
'sequentialFileSend',
],
toMessage: (s) => ({
saturationFactor: s.saturationFactor,
@@ -888,6 +891,7 @@ class Accessibility {
stayInteractiveWhenUnfocused: s.stayInteractiveWhenUnfocused,
firstClickPassThroughWhenUnfocused: s.firstClickPassThroughWhenUnfocused,
scrollToBottomOnMessageSend: s.scrollToBottomOnMessageSend,
sequentialFileSend: s.sequentialFileSend,
}),
applyMessage: (s, m) => {
if (m.saturationFactor !== undefined) s.saturationFactor = m.saturationFactor;
@@ -978,6 +982,7 @@ class Accessibility {
if (m.firstClickPassThroughWhenUnfocused !== undefined)
s.firstClickPassThroughWhenUnfocused = m.firstClickPassThroughWhenUnfocused;
if (m.scrollToBottomOnMessageSend !== undefined) s.scrollToBottomOnMessageSend = m.scrollToBottomOnMessageSend;
if (m.sequentialFileSend !== undefined) s.sequentialFileSend = m.sequentialFileSend;
},
});
await this.applyStoredZoom();
@@ -1314,6 +1319,8 @@ class Accessibility {
this.firstClickPassThroughWhenUnfocused = validated.firstClickPassThroughWhenUnfocused;
if (validated.scrollToBottomOnMessageSend !== undefined)
this.scrollToBottomOnMessageSend = validated.scrollToBottomOnMessageSend;
if (validated.sequentialFileSend !== undefined)
this.sequentialFileSend = validated.sequentialFileSend;
if (validated.showNeko !== undefined && validated.showNeko !== this.showNeko) {
this.showNeko = validated.showNeko;
persistLocalShowNeko(validated.showNeko);
@@ -1414,6 +1421,7 @@ class Accessibility {
firstClickPassThroughWhenUnfocused:
data.firstClickPassThroughWhenUnfocused ?? this.firstClickPassThroughWhenUnfocused,
scrollToBottomOnMessageSend: data.scrollToBottomOnMessageSend ?? this.scrollToBottomOnMessageSend,
sequentialFileSend: data.sequentialFileSend ?? this.sequentialFileSend,
showNeko: data.showNeko ?? this.showNeko,
keepNekoStill: data.keepNekoStill ?? this.keepNekoStill,
showVideoSeekPreviewThumbnails: data.showVideoSeekPreviewThumbnails ?? this.showVideoSeekPreviewThumbnails,
@@ -44,6 +44,7 @@ import {Logger} from '@app/features/platform/utils/AppLogger';
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
import * as ReadStateCommands from '@app/features/read_state/commands/ReadStateCommands';
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
import ReadStates from '@app/features/read_state/state/ReadStates';
import * as SlowmodeCommands from '@app/features/slowmode/commands/SlowmodeCommands';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
@@ -426,6 +427,61 @@ export async function fetchMessages(
return promise;
}
interface SequentialSendEntry {
task: () => Promise<RestResponse<WireMessage> | undefined>;
resolve: (value: RestResponse<WireMessage> | undefined) => void;
}
interface ChannelSendOrderState {
nextOrder: number;
nextExpected: number;
pending: Map<number, SequentialSendEntry>;
processing: boolean;
channelId: string;
}
const channelSendOrders = new Map<string, ChannelSendOrderState>();
function getOrCreateChannelState(channelId: string): ChannelSendOrderState {
let state = channelSendOrders.get(channelId);
if (!state) {
state = {nextOrder: 0, nextExpected: 0, pending: new Map(), processing: false, channelId};
channelSendOrders.set(channelId, state);
}
return state;
}
function orderedSendImmediately(
channelId: string,
order: number,
task: () => Promise<RestResponse<WireMessage> | undefined>,
): Promise<RestResponse<WireMessage> | undefined> {
return new Promise<RestResponse<WireMessage> | undefined>((resolve) => {
const state = getOrCreateChannelState(channelId);
state.pending.set(order, {task, resolve});
void processSequentialQueue(state);
});
}
async function processSequentialQueue(state: ChannelSendOrderState): Promise<void> {
if (state.processing) return;
state.processing = true;
try {
while (state.pending.has(state.nextExpected)) {
const entry = state.pending.get(state.nextExpected)!;
state.pending.delete(state.nextExpected);
state.nextExpected++;
const result = await entry.task();
entry.resolve(result);
}
} finally {
state.processing = false;
if (state.pending.size === 0 && state.nextExpected === state.nextOrder) {
channelSendOrders.delete(state.channelId);
}
}
}
async function prepareSendAttachments(
channelId: string,
params: SendMessageParams,
@@ -445,13 +501,24 @@ async function prepareSendAttachments(
return {attachments: prepared.attachments, files: prepared.files};
}
function nextChannelOrder(channelId: string): number {
return getOrCreateChannelState(channelId).nextOrder++;
}
export async function send(channelId: string, params: SendMessageParams): Promise<WireMessage | null> {
if (!MessageQueue.consumeLocalSendReservation(channelId, params.nonce)) {
MessageQueue.rejectLocalRateLimitedSend(channelId, params.nonce, params.hasAttachments);
return null;
}
const sendOrder =
Accessibility.sequentialFileSend && params.hasAttachments ? nextChannelOrder(channelId) : -1;
const prepared = await prepareSendAttachments(channelId, params);
if (!prepared) return null;
if (!prepared) {
if (Accessibility.sequentialFileSend) {
orderedSendImmediately(channelId, sendOrder, () => Promise.resolve(undefined));
}
return null;
}
const payload = {
type: 'send' as const,
channelId,
@@ -469,7 +536,9 @@ export async function send(channelId: string, params: SendMessageParams): Promis
};
if (params.hasAttachments) {
logger.debug(`Sending attachment message immediately for channel ${channelId}`);
const result = await MessageQueue.sendImmediately(payload);
const result = Accessibility.sequentialFileSend
? await orderedSendImmediately(channelId, sendOrder, () => MessageQueue.sendImmediately(payload))
: await MessageQueue.sendImmediately(payload);
if (result?.body) {
logger.debug(`Attachment message sent successfully in channel ${channelId}`);
Messages.handleIncomingMessage({channelId, message: result.body});
@@ -26,6 +26,7 @@ import {
SaveGifFavoritesControl,
ScrollToBottomOnSendControl,
SearchEnginesControl,
SequentialFileSendControl,
SkipMarkAllAsReadControl,
StripTrackingControl,
TranslatorsControl,
@@ -76,6 +77,7 @@ export const DIRECT_CONTROL_ITEM_IDS = new Set([
'chat-settings-input-buttons',
'chat-settings-convert-emoticons',
'chat-settings-preupload-attachments',
'chat-settings-sequential-file-send',
'chat-settings-scroll-to-bottom-on-send',
'chat-settings-skip-mark-all-as-read-confirmation',
'chat-settings-hide-muted-channels',
@@ -115,6 +117,7 @@ export const COMPACT_SWITCH_CONTROL_ITEM_IDS = new Set([
'chat-settings-trust-domains',
'chat-settings-convert-emoticons',
'chat-settings-preupload-attachments',
'chat-settings-sequential-file-send',
'chat-settings-scroll-to-bottom-on-send',
'chat-settings-skip-mark-all-as-read-confirmation',
'chat-settings-hide-muted-channels',
@@ -240,6 +243,10 @@ export const AdvancedSettingControl = observer(({item}: {item: SearchableSetting
return (
<PreuploadMessageAttachmentsControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.preupload-message-attachments-control" />
);
case 'chat-settings-sequential-file-send':
return (
<SequentialFileSendControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.sequential-file-send-control" />
);
case 'chat-settings-scroll-to-bottom-on-send':
return (
<ScrollToBottomOnSendControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.scroll-to-bottom-on-send-control" />
@@ -109,6 +109,10 @@ const UPLOAD_ATTACHMENTS_BEFORE_SENDING_DESCRIPTOR = msg({
message: 'Upload attachments before sending',
comment: 'Short label for an advanced message input privacy preference.',
});
const SEND_FILES_IN_SEQUENTIAL_ORDER_DESCRIPTOR = msg({
message: 'Send file messages in order',
comment: 'Short label for an advanced message input preference.',
});
const SHOW_STICKERS_BUTTON_DESCRIPTOR = msg({
message: 'Show stickers button',
comment: 'Short label for an advanced message input preference.',
@@ -579,6 +583,19 @@ export const PreuploadMessageAttachmentsControl = observer(() => {
);
});
export const SequentialFileSendControl = observer(() => {
const {i18n} = useLingui();
return (
<Switch
ariaLabel={i18n._(SEND_FILES_IN_SEQUENTIAL_ORDER_DESCRIPTOR)}
value={Accessibility.sequentialFileSend}
onChange={(value) => AccessibilityCommands.update({sequentialFileSend: value})}
compact
data-flx="user.advanced-settings-tab.switch.sequential-file-send"
/>
);
});
export const ScrollToBottomOnSendControl = observer(() => {
const {i18n} = useLingui();
return (
@@ -124,6 +124,30 @@ const FILE_UPLOAD_DESCRIPTOR = msg({
message: 'File upload',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const SEQUENTIAL_FILE_SEND_DESCRIPTOR = msg({
message: 'Send file messages in order',
comment: 'Settings search entry label. Names the settings search entry in the settings UI.',
});
const SEQUENTIAL_SEND_DESCRIPTOR = msg({
message: 'Sequential send',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const FILE_ORDERING_DESCRIPTOR = msg({
message: 'File ordering',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const ORDERED_UPLOAD_DESCRIPTOR = msg({
message: 'Ordered upload',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const SERIAL_SEND_DESCRIPTOR = msg({
message: 'Serial send',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const ENSURES_FILES_ARE_SENT_IN_THE_ORDER_THEY_WERE_ADDED_DESCRIPTOR = msg({
message: 'Ensures file messages appear in the order you sent them',
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
});
const START_UPLOADING_ATTACHMENTS_WHEN_THEY_ARE_ADDED_DESCRIPTOR = msg({
message: 'Start uploading attachments as soon as they are added to the message input',
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
@@ -711,4 +735,20 @@ export const chatSettingsIndex: Array<SearchableSettingDescriptor> = [
audience: 'advanced',
tags: ['chat'],
},
{
id: 'chat-settings-sequential-file-send',
tabType: 'advanced_settings',
sectionId: 'chat',
label: SEQUENTIAL_FILE_SEND_DESCRIPTOR,
keywords: [
SEQUENTIAL_SEND_DESCRIPTOR,
FILE_ORDERING_DESCRIPTOR,
ORDERED_UPLOAD_DESCRIPTOR,
SERIAL_SEND_DESCRIPTOR,
FILE_UPLOAD_DESCRIPTOR,
],
description: ENSURES_FILES_ARE_SENT_IN_THE_ORDER_THEY_WERE_ADDED_DESCRIPTOR,
audience: 'advanced',
tags: ['chat'],
},
];
@@ -76,6 +76,7 @@ message AccessibilitySettings {
optional double compact_message_group_spacing = 60;
optional bool scroll_to_bottom_on_message_send = 61;
optional bool dim_strikethrough_text = 62;
optional bool sequential_file_send = 63;
}
message AccessibilityOverrides {
File diff suppressed because one or more lines are too long