feat(message): remove the unfinished scheduled messages feature (#2233)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-31 13:32:55 +02:00
1 parent eeea391b63
commit 3c8466d714
125 files changed
+17 -3730

No files matched your search

@@ -1,46 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.text {
display: flex;
flex-direction: column;
gap: 0.125rem;
overflow: hidden;
}
.label {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--text-primary);
}
.timestamp {
font-size: 0.75rem;
color: var(--text-primary-muted);
}
.controls {
display: flex;
align-items: center;
}
.button {
cursor: pointer;
flex-shrink: 0;
border: none;
background-color: transparent;
padding: 0.5rem 0 0.5rem 1rem;
color: var(--text-primary-muted);
line-height: 0;
transition: color 200ms;
}
.button:hover {
color: var(--text-primary);
}
.icon {
height: 1.25rem;
width: 1.25rem;
}
@@ -1,80 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/channel/components/ScheduledMessageEditBar.module.css';
import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import {getFormattedDateTimeInZone} from '@fluxer/date_utils/src/DateFormatting';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ClockIcon, XCircleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useCallback, useMemo} from 'react';
const EDITING_SCHEDULED_MESSAGE_DESCRIPTOR = msg({
message: 'Editing scheduled message',
comment: 'Button or menu action label in the channel and chat scheduled message edit bar. Keep it concise.',
});
const CANCEL_EDITING_SCHEDULED_MESSAGE_DESCRIPTOR = msg({
message: 'Cancel editing scheduled message',
comment: 'Button or menu action label in the channel and chat scheduled message edit bar. Keep it concise.',
});
interface ScheduledMessageEditBarProps {
scheduledLocalAt: string;
timezone: string;
onCancel: () => void;
}
const formatScheduleLabel = (local: string, timezone: string): string => {
const locale = getCurrentLocale();
const formatted = getFormattedDateTimeInZone(local, timezone, locale);
return `${formatted} (${timezone})`;
};
export const ScheduledMessageEditBar = observer(
({scheduledLocalAt, timezone, onCancel}: ScheduledMessageEditBarProps) => {
const {i18n} = useLingui();
const scheduleLabel = useMemo(() => formatScheduleLabel(scheduledLocalAt, timezone), [scheduledLocalAt, timezone]);
const handleStopEditing = useCallback(() => {
onCancel();
}, [onCancel]);
return (
<div
className={`${wrapperStyles.box} ${wrapperStyles.wrapperSides} ${wrapperStyles.roundedTop} ${wrapperStyles.noBottomBorder}`}
data-flx="channel.scheduled-message-edit-bar.div"
>
<div
className={wrapperStyles.barInner}
style={{gridTemplateColumns: '1fr auto'}}
data-flx="channel.scheduled-message-edit-bar.div--2"
>
<div className={styles.text} data-flx="channel.scheduled-message-edit-bar.text">
<div className={styles.label} data-flx="channel.scheduled-message-edit-bar.label">
<ClockIcon className={styles.icon} weight="fill" data-flx="channel.scheduled-message-edit-bar.icon" />
<span data-flx="channel.scheduled-message-edit-bar.span">
{i18n._(EDITING_SCHEDULED_MESSAGE_DESCRIPTOR)}
</span>
</div>
<div className={styles.timestamp} data-flx="channel.scheduled-message-edit-bar.timestamp">
{scheduleLabel}
</div>
</div>
<div className={styles.controls} data-flx="channel.scheduled-message-edit-bar.controls">
<FocusRing offset={-2} data-flx="channel.scheduled-message-edit-bar.focus-ring">
<button
type="button"
className={styles.button}
onClick={handleStopEditing}
aria-label={i18n._(CANCEL_EDITING_SCHEDULED_MESSAGE_DESCRIPTOR)}
data-flx="channel.scheduled-message-edit-bar.button.stop-editing"
>
<XCircleIcon className={styles.icon} data-flx="channel.scheduled-message-edit-bar.icon--2" />
</button>
</FocusRing>
</div>
</div>
<div className={wrapperStyles.separator} data-flx="channel.scheduled-message-edit-bar.div--3" />
</div>
);
},
);
@@ -17,7 +17,6 @@ function signals(overrides: Partial<MobileTextareaButtonSignals> = {}): MobileTe
hasContent: false,
hasAttachments: false,
hasPendingSticker: false,
isEditingScheduledMessage: false,
...overrides,
};
}
@@ -93,15 +92,6 @@ describe('MobileTextareaButtonStateMachine', () => {
});
});
it('does not expose voice recording while editing a scheduled message', () => {
expect(state({isEditingScheduledMessage: true})).toMatchObject({
mode: 'sendBlocked',
visibleButton: 'send',
sendButton: {disabled: true},
voiceButton: {disabled: true},
});
});
it('falls back to a disabled send button when voice recording is unavailable and the composer is empty', () => {
expect(state({canRecordVoice: false})).toMatchObject({
mode: 'sendBlocked',
@@ -16,7 +16,6 @@ export interface MobileTextareaButtonSignals {
hasContent: boolean;
hasAttachments: boolean;
hasPendingSticker: boolean;
isEditingScheduledMessage: boolean;
}
export interface MobileTextareaButtonModel {
@@ -51,16 +50,13 @@ export function selectMobileTextareaButtonModel(signals: MobileTextareaButtonSig
const typedText = hasTypedText(signals.value);
const hasTextContent = signals.hasContent || typedText;
const hasSubmissionContent = hasTextContent || signals.hasAttachments || signals.hasPendingSticker;
const shouldShowVoice =
signals.canRecordVoice && !signals.isEditingMessage && !signals.isEditingScheduledMessage && !hasSubmissionContent;
const shouldShowVoice = signals.canRecordVoice && !signals.isEditingMessage && !hasSubmissionContent;
const sendDisabled =
signals.disabled ||
signals.isOverCharacterLimit ||
signals.isEditingScheduledMessage ||
(signals.isSlowmodeActive && !signals.isEditingMessage) ||
(!hasSubmissionContent && !signals.isEditingMessage);
const voiceDisabled =
signals.disabled || signals.isSlowmodeActive || signals.isOverCharacterLimit || signals.isEditingScheduledMessage;
const voiceDisabled = signals.disabled || signals.isSlowmodeActive || signals.isOverCharacterLimit;
const visibleButton = shouldShowVoice ? 'voice' : 'send';
const mode = visibleButton === 'voice' ? 'voice' : sendDisabled ? 'sendBlocked' : 'sendReady';
return {
@@ -84,7 +80,6 @@ const DEFAULT_SIGNALS: MobileTextareaButtonSignals = {
hasContent: false,
hasAttachments: false,
hasPendingSticker: false,
isEditingScheduledMessage: false,
};
const DEFAULT_MODEL = selectMobileTextareaButtonModel(DEFAULT_SIGNALS);
@@ -106,7 +106,6 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
hasContent,
hasAttachments,
hasPendingSticker,
isEditingScheduledMessage: false,
}),
[
canRecordVoice,
@@ -28,7 +28,6 @@ import MemberSidebar from '@app/features/member/state/MemberSidebar';
import MessageReactions from '@app/features/messaging/state/MessageReactions';
import Messages from '@app/features/messaging/state/MessagingMessages';
import SavedMessages from '@app/features/messaging/state/SavedMessages';
import ScheduledMessages from '@app/features/messaging/state/ScheduledMessages';
import MentionFeed from '@app/features/notification/state/MentionFeed';
import Permission from '@app/features/permissions/state/Permission';
import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -170,7 +169,6 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext)
MemberSearch.handleGatewayReady();
SavedMessages.handleGatewayReady();
MentionFeed.handleGatewayReady();
ScheduledMessages.handleGatewayReady();
ChannelPins.handleGatewayReady();
UserConnection.handleGatewayReady();
UserGuildSettings.handleGatewayReady(data.user_guild_settings ?? []);
+1 -1
View File
@@ -6,7 +6,7 @@ import {makeAutoObservable} from 'mobx';
const logger = new Logger('Inbox');
export type InboxTab = 'bookmarks' | 'mentions' | 'scheduled' | 'unreadChannels';
export type InboxTab = 'bookmarks' | 'mentions' | 'unreadChannels';
class Inbox {
selectedTab: InboxTab = 'bookmarks';
@@ -1,495 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {FeatureTemporarilyDisabledModal} from '@app/features/app/components/alerts/FeatureTemporarilyDisabledModal';
import {Endpoints} from '@app/features/app/constants/Endpoints';
import * as DraftCommands from '@app/features/messaging/commands/DraftCommands';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
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 {
ScheduledAttachment,
ScheduledMessagePayload,
ScheduledMessageResponse,
} from '@app/features/messaging/models/ScheduledMessage';
import {ScheduledMessage} from '@app/features/messaging/models/ScheduledMessage';
import ScheduledMessages from '@app/features/messaging/state/ScheduledMessages';
import {CloudUpload} from '@app/features/messaging/upload/CloudUpload';
import {prepareAttachmentsForNonce} from '@app/features/messaging/utils/MessageAttachmentUtils';
import {
type ApiAttachmentMetadata,
buildMessageCreateRequest,
type MessageCreateRequest,
type NormalizedMessageContent,
normalizeMessageContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils';
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 {TypingUtils} from '@app/features/typing/utils/TypingUtils';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {
AllowedMentions,
MessageReference,
MessageStickerItem,
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
const SCHEDULED_MESSAGE_FOR_DESCRIPTOR = msg({
message: 'Scheduled message for {scheduledTimeLabel}',
comment: 'Label in the messaging commands. {scheduledTimeLabel} is the localized scheduled date and time.',
});
const UPDATED_SCHEDULED_MESSAGE_FOR_DESCRIPTOR = msg({
message: 'Updated scheduled message for {scheduledTimeLabel}',
comment: 'Label in the messaging commands. {scheduledTimeLabel} is the localized scheduled date and time.',
});
const REMOVED_SCHEDULED_MESSAGE_DESCRIPTOR = msg({
message: 'Removed scheduled message',
comment: 'Button or menu action label in the messaging commands. Keep it concise. Keep the tone plain and specific.',
});
const logger = new Logger('ScheduledMessages');
type ScheduledMessageRequest = MessageCreateRequest & {
scheduled_local_at: string;
timezone: string;
};
interface ApiErrorBody {
code?: number | string;
message?: string;
}
export interface ScheduleMessageParams {
channelId: string;
content: string;
scheduledLocalAt: string;
timezone: string;
messageReference?: MessageReference;
replyMentioning?: boolean;
favoriteMemeId?: string;
stickers?: Array<MessageStickerItem>;
tts?: boolean;
hasAttachments: boolean;
}
interface UpdateScheduledMessageParams {
channelId: string;
scheduledMessageId: string;
scheduledLocalAt: string;
timezone: string;
normalized: NormalizedMessageContent;
payload: ScheduledMessagePayload;
replyMentioning?: boolean;
}
interface PreparedScheduledMessage {
nonce: string;
payload: ScheduledMessageRequest;
files?: Array<File>;
normalized: NormalizedMessageContent;
}
const formatScheduledLabel = (local: string, timezone: string): string => {
return `${local.replace('T', ' ')} (${timezone})`;
};
function mapScheduledAttachments(
attachments?: ReadonlyArray<ScheduledAttachment>,
): Array<ApiAttachmentMetadata> | undefined {
if (!attachments || attachments.length === 0) {
return undefined;
}
return attachments.map((attachment) => ({
id: attachment.id,
filename: attachment.filename,
title: attachment.title ?? attachment.filename,
description: attachment.description ?? undefined,
flags: attachment.flags,
}));
}
function scheduleAllowedMentions(params: ScheduleMessageParams): AllowedMentions {
return {replied_user: params.replyMentioning ?? true};
}
function claimScheduleAttachments(params: ScheduleMessageParams, nonce: string): void {
if (!params.hasAttachments) {
return;
}
MessageSubmitUtils.claimMessageAttachments(
params.channelId,
nonce,
params.content,
params.messageReference,
params.replyMentioning,
);
}
async function prepareScheduledMessage(params: ScheduleMessageParams): Promise<PreparedScheduledMessage> {
const nonce = SnowflakeUtils.fromTimestamp(Date.now());
const normalized = normalizeMessageContent(params.content);
claimScheduleAttachments(params, nonce);
let attachments: Array<ApiAttachmentMetadata> | undefined;
let files: Array<File> | undefined;
if (params.hasAttachments) {
const result = await prepareAttachmentsForNonce(nonce, params.favoriteMemeId);
attachments = result.attachments;
files = result.files;
}
const requestBody = buildMessageCreateRequest({
content: normalized.content,
nonce,
attachments,
allowedMentions: scheduleAllowedMentions(params),
messageReference: params.messageReference,
flags: normalized.flags,
favoriteMemeId: params.favoriteMemeId,
stickers: params.stickers,
tts: params.tts,
});
return {
nonce,
normalized,
files,
payload: {
...requestBody,
scheduled_local_at: params.scheduledLocalAt,
timezone: params.timezone,
},
};
}
function handleScheduleSuccess(
i18n: I18n,
params: ScheduleMessageParams,
nonce: string,
response: ScheduledMessageResponse,
): ScheduledMessage {
const record = ScheduledMessage.fromResponse(response);
ScheduledMessages.upsert(record);
DraftCommands.deleteDraft(params.channelId);
TypingUtils.clear(params.channelId);
MessageCommands.stopReply(params.channelId);
if (params.hasAttachments) {
CloudUpload.removeMessageUpload(nonce);
}
ToastCommands.createToast({
type: 'success',
children: i18n._(SCHEDULED_MESSAGE_FOR_DESCRIPTOR, {
scheduledTimeLabel: formatScheduledLabel(params.scheduledLocalAt, params.timezone),
}),
});
return record;
}
function scheduledMessageUpdateRequest(params: UpdateScheduledMessageParams): ScheduledMessageRequest {
return {
content: params.normalized.content,
attachments: mapScheduledAttachments(params.payload.attachments),
allowed_mentions: params.payload.allowed_mentions ?? (params.replyMentioning ? {replied_user: true} : undefined),
message_reference:
params.payload.message_reference?.channel_id && params.payload.message_reference.message_id
? {
channel_id: params.payload.message_reference.channel_id,
message_id: params.payload.message_reference.message_id,
guild_id: params.payload.message_reference.guild_id,
type: params.payload.message_reference.type,
}
: undefined,
flags: params.normalized.flags,
favorite_meme_id: params.payload.favorite_meme_id ?? undefined,
sticker_ids: params.payload.sticker_ids,
tts: params.payload.tts ? true : undefined,
scheduled_local_at: params.scheduledLocalAt,
timezone: params.timezone,
};
}
async function requestScheduledMessageUpdate(
scheduledMessageId: string,
requestBody: ScheduledMessageRequest,
): Promise<ScheduledMessageResponse> {
const response = await http.patch<ScheduledMessageResponse>(Endpoints.USER_SCHEDULED_MESSAGE(scheduledMessageId), {
body: requestBody,
});
return response.body;
}
export async function fetchScheduledMessages(): Promise<Array<ScheduledMessage>> {
logger.debug('Fetching scheduled messages');
ScheduledMessages.fetchStart();
try {
const response = await http.get<Array<ScheduledMessageResponse>>(Endpoints.USER_SCHEDULED_MESSAGES);
const data = response.body ?? [];
const messages = data.map(ScheduledMessage.fromResponse);
ScheduledMessages.fetchSuccess(messages);
logger.debug('Scheduled messages fetched successfully');
return messages;
} catch (error) {
ScheduledMessages.fetchError();
logger.error('Failed to fetch scheduled messages:', error);
throw error;
}
}
export async function scheduleMessage(i18n: I18n, params: ScheduleMessageParams): Promise<ScheduledMessage> {
logger.debug('Scheduling message', params);
const prepared = await prepareScheduledMessage(params);
try {
const response = await scheduleMessageRequest(params.channelId, prepared.payload, prepared.files, prepared.nonce);
return handleScheduleSuccess(i18n, params, prepared.nonce, response.body);
} catch (error) {
handleScheduleError(
i18n,
error,
params.channelId,
prepared.nonce,
params.content,
params.messageReference,
params.replyMentioning,
params.hasAttachments,
);
throw error;
}
}
export async function updateScheduledMessage(
i18n: I18n,
params: UpdateScheduledMessageParams,
): Promise<ScheduledMessage> {
logger.debug('Updating scheduled message', params);
try {
const record = ScheduledMessage.fromResponse(
await requestScheduledMessageUpdate(params.scheduledMessageId, scheduledMessageUpdateRequest(params)),
);
ScheduledMessages.upsert(record);
DraftCommands.deleteDraft(params.channelId);
TypingUtils.clear(params.channelId);
MessageCommands.stopReply(params.channelId);
ToastCommands.createToast({
type: 'success',
children: i18n._(UPDATED_SCHEDULED_MESSAGE_FOR_DESCRIPTOR, {
scheduledTimeLabel: formatScheduledLabel(params.scheduledLocalAt, params.timezone),
}),
});
return record;
} catch (error) {
logger.error('Failed to update scheduled message', error);
throw error;
}
}
export async function cancelScheduledMessage(i18n: I18n, scheduledMessageId: string): Promise<void> {
logger.debug('Canceling scheduled message', scheduledMessageId);
try {
await http.delete(Endpoints.USER_SCHEDULED_MESSAGE(scheduledMessageId));
ScheduledMessages.remove(scheduledMessageId);
ToastCommands.createToast({
type: 'success',
children: i18n._(REMOVED_SCHEDULED_MESSAGE_DESCRIPTOR),
});
} catch (error) {
logger.error('Failed to cancel scheduled message', error);
throw error;
}
}
function restoreDraftAfterScheduleFailure(
channelId: string,
nonce: string,
content: string,
messageReference?: MessageReference,
replyMentioning?: boolean,
hadAttachments?: boolean,
): void {
if (hadAttachments) {
CloudUpload.restoreAttachmentsToTextarea(nonce);
}
DraftCommands.createDraft(channelId, content);
if (messageReference && replyMentioning !== undefined) {
MessageCommands.startReply(channelId, messageReference.message_id, replyMentioning);
}
}
async function scheduleMessageRequest(
channelId: string,
payload: ScheduledMessageRequest,
files?: Array<File>,
nonce?: string,
): Promise<RestResponse<ScheduledMessageResponse>> {
const abortController = new AbortController();
try {
if (files?.length) {
return await scheduleMultipartMessage(channelId, payload, files, abortController.signal, nonce);
}
return await http.post<ScheduledMessageResponse>(Endpoints.CHANNEL_MESSAGE_SCHEDULE(channelId), {
body: payload,
signal: abortController.signal,
});
} finally {
abortController.abort();
}
}
async function scheduleMultipartMessage(
channelId: string,
payload: ScheduledMessageRequest,
files: Array<File>,
signal: AbortSignal,
nonce?: string,
): Promise<RestResponse<ScheduledMessageResponse>> {
return http.post<ScheduledMessageResponse>(Endpoints.CHANNEL_MESSAGE_SCHEDULE(channelId), {
multipart: {
files: files.map((file, index) => ({name: `files[${index}]`, file, filename: file.name})),
fields: {payload_json: JSON.stringify(payload)},
},
signal,
onProgress: nonce
? (event) => {
if (event.lengthComputable && event.total > 0) {
const progress = (event.loaded / event.total) * 100;
CloudUpload.updateSendingProgress(nonce, progress);
}
}
: undefined,
});
}
const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
return typeof error.body === 'object' && error.body !== null ? (error.body as ApiErrorBody) : undefined;
};
function handleScheduleError(
i18n: I18n,
error: unknown,
channelId: string,
nonce: string,
content: string,
messageReference?: MessageReference,
replyMentioning?: boolean,
hadAttachments?: boolean,
): void {
restoreDraftAfterScheduleFailure(channelId, nonce, content, messageReference, replyMentioning, hadAttachments);
if (!(error instanceof HttpError)) {
ModalCommands.push(
modal(() => (
<MessageSendFailedModal
hasAttachments={hadAttachments}
data-flx="messaging.scheduled-message-commands.handle-schedule-error.message-send-failed-modal--request"
/>
)),
);
return;
}
if (isRateLimitError(error)) {
handleScheduleRateLimit(i18n, error);
return;
}
if (isSlowmodeError(error)) {
const retryAfterMs = SlowmodeCommands.clampSlowmodeRetryAfterMs(resolveRetryAfterMs(error));
if (retryAfterMs <= 0) {
ModalCommands.push(
modal(() => (
<MessageSendFailedModal
hasAttachments={hadAttachments}
data-flx="messaging.scheduled-message-commands.handle-schedule-error.message-send-failed-modal"
/>
)),
);
return;
}
const retryAfter = Math.ceil(retryAfterMs / 1000);
SlowmodeCommands.updateSlowmodeRemaining(channelId, retryAfterMs);
ModalCommands.push(
modal(() => (
<SlowmodeRateLimitedModal
retryAfter={retryAfter}
data-flx="messaging.scheduled-message-commands.handle-schedule-error.slowmode-rate-limited-modal"
/>
)),
);
return;
}
if (isFeatureDisabledError(error)) {
ModalCommands.push(
modal(() => (
<FeatureTemporarilyDisabledModal data-flx="messaging.scheduled-message-commands.handle-schedule-error.feature-temporarily-disabled-modal" />
)),
);
return;
}
if (isExplicitContentError(error)) {
ModalCommands.push(
modal(() => (
<MatureContentRejectedModal data-flx="messaging.scheduled-message-commands.handle-schedule-error.mature-content-rejected-modal" />
)),
);
return;
}
if (isFileTooLargeError(error)) {
ModalCommands.push(
modal(() => (
<FileSizeTooLargeModal data-flx="messaging.scheduled-message-commands.handle-schedule-error.file-size-too-large-modal" />
)),
);
return;
}
ModalCommands.push(
modal(() => (
<MessageSendFailedModal
hasAttachments={hadAttachments}
data-flx="messaging.scheduled-message-commands.handle-schedule-error.message-send-failed-modal--http"
/>
)),
);
}
function handleScheduleRateLimit(_i18n: I18n, error: HttpError): void {
const retryAfterMs = resolveRetryAfterMs(error);
const retryAfterSeconds = retryAfterMs === null ? undefined : Math.ceil(retryAfterMs / 1000);
ModalCommands.push(
modal(() => (
<MessageSendTooQuickModal
retryAfter={retryAfterSeconds}
onRetry={undefined}
data-flx="messaging.scheduled-message-commands.handle-schedule-rate-limit.message-send-too-quick-modal"
/>
)),
);
logger.warn('Scheduled message rate limited, retry after', retryAfterSeconds);
}
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;
}
@@ -7,7 +7,6 @@ import Inbox, {type InboxTab} from '@app/features/inbox/state/Inbox';
import styles from '@app/features/messaging/components/popouts/InboxPopout.module.css';
import {RecentMentionsContent} from '@app/features/messaging/components/popouts/RecentMentionsContent';
import {SavedMessagesContent} from '@app/features/messaging/components/popouts/SavedMessagesContent';
import {ScheduledMessagesContent} from '@app/features/messaging/components/popouts/ScheduledMessagesContent';
import ReadStates from '@app/features/read_state/state/ReadStates';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
@@ -20,10 +19,9 @@ import {
import {getNextTabIndex, getTabNavigationDirection} from '@app/features/ui/tabs/TabKeyboardNavigation';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
import Users from '@app/features/user/state/Users';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {AtIcon, BellIcon, BookmarkSimpleIcon, CheckIcon, ClockIcon} from '@phosphor-icons/react';
import {AtIcon, BellIcon, BookmarkSimpleIcon, CheckIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
@@ -37,10 +35,6 @@ const BOOKMARKS_DESCRIPTOR = msg({
message: 'Bookmarks',
comment: 'Tab label in the inbox popout listing the user bookmarks.',
});
const SCHEDULED_DESCRIPTOR = msg({
message: 'Scheduled',
comment: 'Tab label in the inbox popout listing the user scheduled messages.',
});
const INBOX_TABS_DESCRIPTOR = msg({
message: 'Inbox tabs',
comment: 'Accessible label for the inbox popout tablist.',
@@ -141,15 +135,7 @@ export const InboxPopout = observer(({initialTab}: {initialTab?: InboxTab} = {})
icon: <AtIcon weight="bold" className={styles.iconSmall} data-flx="messaging.inbox-popout.icon-small--3" />,
},
];
const scheduledTab: TabConfig = {
key: 'scheduled',
label: i18n._(SCHEDULED_DESCRIPTOR),
icon: <ClockIcon className={styles.iconSmall} data-flx="messaging.inbox-popout.icon-small--4" />,
};
const currentUser = Users.getCurrentUser();
let showScheduledTab = false;
if (currentUser != null) showScheduledTab = currentUser.isStaff();
const tabs = showScheduledTab ? [...baseTabs, scheduledTab] : baseTabs;
const tabs = baseTabs;
const normalizedActiveTab = tabs.some((tab) => tab.key === activeTab) ? activeTab : tabs[0].key;
const setActiveTab = useCallback((tab: InboxTab) => {
InboxCommands.setTab(tab);
@@ -253,19 +239,6 @@ export const InboxPopout = observer(({initialTab}: {initialTab?: InboxTab} = {})
/>
</div>
)}
{showScheduledTab && normalizedActiveTab === 'scheduled' && (
<div
id="inbox-panel-scheduled"
className={styles.tabContent}
role="tabpanel"
tabIndex={-1}
aria-labelledby="scheduled"
data-autofocus=""
data-flx="messaging.inbox-popout.inbox-panel-scheduled"
>
<ScheduledMessagesContent data-flx="messaging.inbox-popout.scheduled-messages-content" />
</div>
)}
</div>
);
return (
@@ -1,56 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.375rem;
gap: 0.5rem;
}
.statusBadge {
font-size: 0.6875rem;
font-weight: 600;
padding: 0.125rem 0.625rem;
border-radius: 62.4375rem;
background-color: var(--background-modifier-accent);
color: var(--text-primary);
}
.statusInvalid {
background-color: var(--text-warning);
color: var(--background-primary);
}
.timestamp {
font-size: 0.75rem;
color: var(--text-primary-muted);
}
.messageText {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary);
margin-bottom: 0.375rem;
min-height: 2.75rem;
}
.attachmentsInfo {
font-size: 0.75rem;
color: var(--text-primary-muted);
margin-bottom: 0.375rem;
}
.statusReason {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: var(--text-warning);
margin-bottom: 0.375rem;
}
.warningIcon {
width: 1rem;
height: 1rem;
}
@@ -1,218 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import previewStyles from '@app/features/app/components/shared/MessagePreview.module.css';
import * as ScheduledMessageCommands from '@app/features/messaging/commands/ScheduledMessageCommands';
import styles from '@app/features/messaging/components/popouts/ScheduledMessagesContent.module.css';
import {useMessageSelectionCopy} from '@app/features/messaging/hooks/useMessageSelectionCopy';
import ScheduledMessages from '@app/features/messaging/state/ScheduledMessages';
import {Scroller} from '@app/features/ui/components/Scroller';
import {Spinner} from '@app/features/ui/components/Spinner';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import {formatScheduledMessage} from '@fluxer/date_utils/src/DateFormatting';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {FlagCheckeredIcon, WarningCircleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useState} from 'react';
const NO_SCHEDULED_MESSAGES_DESCRIPTOR = msg({
message: 'No scheduled messages',
comment: 'Empty state title in the scheduled messages popout when the user has none scheduled.',
});
const RIGHT_CLICK_THE_SEND_BUTTON_TO_SCHEDULE_A_DESCRIPTOR = msg({
message: 'Right-click the send button to schedule a message.',
comment: 'Empty state hint in the scheduled messages popout explaining how to schedule a new message.',
});
const INVALID_DESCRIPTOR = msg({
message: 'Invalid',
comment: 'Status pill on a scheduled message row when the schedule is in the past or otherwise invalid.',
});
const SCHEDULED_DESCRIPTOR = msg({
message: 'Scheduled',
comment: 'Status pill on a scheduled message row indicating it will send at the chosen time.',
});
const ATTACHMENT_ONLY_MESSAGE_DESCRIPTOR = msg({
message: 'Attachment only message',
comment: 'Placeholder content text shown for scheduled messages that have attachments but no text content.',
});
const NO_CONTENT_DESCRIPTOR = msg({
message: '(no content)',
comment:
'Placeholder content text shown for scheduled messages with neither text nor attachments. Keep the parentheses.',
});
const ATTACHMENTS_DESCRIPTOR = msg({
message: 'Attachments',
comment: 'Label in the scheduled message row preview indicating that attachments are included.',
});
const CANCEL_DESCRIPTOR = msg({
message: 'Cancel',
comment: 'Button label on a scheduled message row that cancels (deletes) the scheduled send.',
});
const YOU_RE_CAUGHT_UP_DESCRIPTOR = msg({
message: "You're caught up",
comment:
'End-of-list title in the scheduled messages popout when all scheduled messages are visible. Tone can be friendly.',
});
const NO_MORE_SCHEDULED_MESSAGES_DESCRIPTOR = msg({
message: "That's everything in the queue.",
comment: 'End-of-list body text in the scheduled messages popout when all scheduled messages are visible.',
});
export const ScheduledMessagesContent = observer(() => {
const {i18n} = useLingui();
const {scheduledMessages, fetched, fetching} = ScheduledMessages;
const [cancellingId, setCancellingId] = useState<string | null>(null);
const getScheduledMessagePlaintext = useCallback(
(messageId: string) => {
const message = scheduledMessages.find((entry) => entry.id === messageId);
if (!message) {
return null;
}
const blocks: Array<string> = [];
const content = message.payload.content?.trim();
const attachmentCount = message.payload.attachments?.length ?? 0;
if (content) {
blocks.push(content);
}
if (attachmentCount > 0) {
blocks.push(`${i18n._(ATTACHMENTS_DESCRIPTOR)}: ${attachmentCount}`);
}
if (message.status === 'invalid' && message.statusReason) {
blocks.push(message.statusReason);
}
return blocks.length > 0 ? blocks.join('\n') : i18n._(NO_CONTENT_DESCRIPTOR);
},
[i18n, scheduledMessages],
);
const onCopySelectedMessages = useMessageSelectionCopy<HTMLDivElement>({
getMessagePlaintext: getScheduledMessagePlaintext,
});
useEffect(() => {
if (!fetched && !fetching) {
ScheduledMessageCommands.fetchScheduledMessages();
}
}, [fetched, fetching]);
const handleCancel = useCallback(
async (messageId: string) => {
setCancellingId(messageId);
try {
await ScheduledMessageCommands.cancelScheduledMessage(i18n, messageId);
} finally {
setCancellingId(null);
}
},
[i18n],
);
if (fetching) {
return (
<div className={previewStyles.emptyState} data-flx="messaging.scheduled-messages-content.loading-state">
<Spinner data-flx="messaging.scheduled-messages-content.spinner" />
</div>
);
}
if (scheduledMessages.length === 0) {
return (
<div className={previewStyles.emptyState} data-flx="messaging.scheduled-messages-content.div">
<div className={previewStyles.emptyStateContent} data-flx="messaging.scheduled-messages-content.div--2">
<FlagCheckeredIcon
className={previewStyles.emptyStateIcon}
data-flx="messaging.scheduled-messages-content.flag-checkered-icon"
/>
<div className={previewStyles.emptyStateTextContainer} data-flx="messaging.scheduled-messages-content.div--3">
<h3 className={previewStyles.emptyStateTitle} data-flx="messaging.scheduled-messages-content.h3">
{i18n._(NO_SCHEDULED_MESSAGES_DESCRIPTOR)}
</h3>
<p className={previewStyles.emptyStateDescription} data-flx="messaging.scheduled-messages-content.p">
{i18n._(RIGHT_CLICK_THE_SEND_BUTTON_TO_SCHEDULE_A_DESCRIPTOR)}
</p>
</div>
</div>
</div>
);
}
const formatScheduledAt = (message: (typeof scheduledMessages)[number]) => {
try {
return formatScheduledMessage(message.scheduledAt, getCurrentLocale(), message.timezone);
} catch {
return `${message.scheduledLocalAt} (${message.timezone})`;
}
};
return (
<Scroller
className={previewStyles.scroller}
key="scheduled-messages-scroller"
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="messaging.scheduled-messages-content.scroller"
>
{scheduledMessages.map((message) => (
<div
key={message.id}
className={previewStyles.previewCard}
data-message-id={message.id}
data-is-group-start="true"
data-flx="messaging.scheduled-messages-content.div--4"
>
<div className={styles.cardHeader} data-flx="messaging.scheduled-messages-content.card-header">
<span
className={`${styles.statusBadge} ${message.status === 'invalid' ? styles.statusInvalid : ''}`}
data-flx="messaging.scheduled-messages-content.status-badge"
>
{message.status === 'invalid' ? i18n._(INVALID_DESCRIPTOR) : i18n._(SCHEDULED_DESCRIPTOR)}
</span>
<span className={styles.timestamp} data-flx="messaging.scheduled-messages-content.timestamp">
{formatScheduledAt(message)}
</span>
</div>
<p className={styles.messageText} data-flx="messaging.scheduled-messages-content.message-text">
{message.payload.content ??
(message.payload.attachments?.length
? i18n._(ATTACHMENT_ONLY_MESSAGE_DESCRIPTOR)
: i18n._(NO_CONTENT_DESCRIPTOR))}
</p>
{message.payload.attachments?.length ? (
<div className={styles.attachmentsInfo} data-flx="messaging.scheduled-messages-content.attachments-info">
{i18n._(ATTACHMENTS_DESCRIPTOR)}: {message.payload.attachments.length}
</div>
) : null}
{message.status === 'invalid' && message.statusReason ? (
<div className={styles.statusReason} data-flx="messaging.scheduled-messages-content.status-reason">
<WarningCircleIcon
className={styles.warningIcon}
weight="fill"
data-flx="messaging.scheduled-messages-content.warning-icon"
/>
<span data-flx="messaging.scheduled-messages-content.span">{message.statusReason}</span>
</div>
) : null}
<div className={previewStyles.actionButtons} data-flx="messaging.scheduled-messages-content.div--5">
<button
type="button"
className={previewStyles.actionButton}
onClick={() => handleCancel(message.id)}
disabled={cancellingId === message.id}
data-flx="messaging.scheduled-messages-content.button.cancel"
>
{i18n._(CANCEL_DESCRIPTOR)}
</button>
</div>
</div>
))}
<div className={previewStyles.endState} data-flx="messaging.scheduled-messages-content.div--6">
<div className={previewStyles.endStateContent} data-flx="messaging.scheduled-messages-content.div--7">
<FlagCheckeredIcon
className={previewStyles.endStateIcon}
data-flx="messaging.scheduled-messages-content.flag-checkered-icon--2"
/>
<div className={previewStyles.endStateTextContainer} data-flx="messaging.scheduled-messages-content.div--8">
<h3 className={previewStyles.endStateTitle} data-flx="messaging.scheduled-messages-content.h3--2">
{i18n._(YOU_RE_CAUGHT_UP_DESCRIPTOR)}
</h3>
<p className={previewStyles.endStateDescription} data-flx="messaging.scheduled-messages-content.p--2">
{i18n._(NO_MORE_SCHEDULED_MESSAGES_DESCRIPTOR)}
</p>
</div>
</div>
</div>
</Scroller>
);
});
@@ -1,81 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface ScheduledMessageReference {
message_id: string;
channel_id?: string;
guild_id?: string;
type?: number;
}
export interface ScheduledAllowedMentions {
parse?: Array<'users' | 'roles' | 'everyone'>;
users?: Array<string>;
roles?: Array<string>;
replied_user?: boolean;
}
export interface ScheduledAttachment {
id: string;
filename: string;
title?: string;
description?: string;
flags?: number;
}
export interface ScheduledMessagePayload {
content?: string | null;
embeds?: Array<unknown>;
attachments?: Array<ScheduledAttachment>;
message_reference?: ScheduledMessageReference;
allowed_mentions?: ScheduledAllowedMentions;
flags?: number;
nonce?: string;
favorite_meme_id?: string;
sticker_ids?: Array<string>;
tts?: boolean;
}
export type ScheduledMessageStatus = 'pending' | 'invalid';
export interface ScheduledMessageResponse {
id: string;
channel_id: string;
scheduled_at: string;
scheduled_local_at: string;
timezone: string;
status: ScheduledMessageStatus;
status_reason: string | null;
payload: ScheduledMessagePayload;
created_at: string;
invalidated_at: string | null;
}
export class ScheduledMessage {
readonly id: string;
readonly channelId: string;
readonly scheduledAt: Date;
readonly scheduledLocalAt: string;
readonly timezone: string;
readonly payload: ScheduledMessagePayload;
readonly status: ScheduledMessageStatus;
readonly statusReason: string | null;
readonly createdAt: Date;
readonly invalidatedAt: Date | null;
constructor(response: ScheduledMessageResponse) {
this.id = response.id;
this.channelId = response.channel_id;
this.scheduledAt = new Date(response.scheduled_at);
this.scheduledLocalAt = response.scheduled_local_at;
this.timezone = response.timezone;
this.payload = response.payload;
this.status = response.status ?? 'pending';
this.statusReason = response.status_reason;
this.createdAt = new Date(response.created_at);
this.invalidatedAt = response.invalidated_at ? new Date(response.invalidated_at) : null;
}
static fromResponse(response: ScheduledMessageResponse): ScheduledMessage {
return new ScheduledMessage(response);
}
}
@@ -1,44 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ScheduledMessage, ScheduledMessagePayload} from '@app/features/messaging/models/ScheduledMessage';
import {makeAutoObservable} from 'mobx';
interface ScheduledMessageEditState {
scheduledMessageId: string;
channelId: string;
payload: ScheduledMessagePayload;
scheduledLocalAt: string;
timezone: string;
}
class ScheduledMessageEditor {
private state: ScheduledMessageEditState | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
startEditing(record: ScheduledMessage): void {
this.state = {
scheduledMessageId: record.id,
channelId: record.channelId,
payload: record.payload,
scheduledLocalAt: record.scheduledLocalAt,
timezone: record.timezone,
};
}
stopEditing(): void {
this.state = null;
}
isEditingChannel(channelId: string): boolean {
return this.state?.channelId === channelId;
}
getEditingState(): ScheduledMessageEditState | null {
return this.state;
}
}
export default new ScheduledMessageEditor();
@@ -1,61 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ScheduledMessage} from '@app/features/messaging/models/ScheduledMessage';
import {makeAutoObservable} from 'mobx';
class ScheduledMessages {
scheduledMessages: Array<ScheduledMessage> = [];
fetched = false;
fetching = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
get hasScheduledMessages(): boolean {
return this.scheduledMessages.length > 0;
}
fetchStart(): void {
this.fetching = true;
}
fetchSuccess(messages: Array<ScheduledMessage>): void {
this.scheduledMessages = sortScheduledMessages(messages);
this.fetching = false;
this.fetched = true;
}
fetchError(): void {
this.fetching = false;
this.fetched = false;
this.scheduledMessages = [];
}
handleGatewayReady(): void {
this.scheduledMessages = [];
this.fetched = false;
this.fetching = false;
}
upsert(message: ScheduledMessage): void {
const existingIndex = this.scheduledMessages.findIndex((entry) => entry.id === message.id);
const next = [...this.scheduledMessages];
if (existingIndex === -1) {
next.push(message);
} else {
next[existingIndex] = message;
}
this.scheduledMessages = sortScheduledMessages(next);
}
remove(messageId: string): void {
this.scheduledMessages = this.scheduledMessages.filter((message) => message.id !== messageId);
}
}
function sortScheduledMessages(messages: Array<ScheduledMessage>): Array<ScheduledMessage> {
return [...messages].sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
}
export default new ScheduledMessages();