mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(message): remove the unfinished scheduled messages feature (#2233)
This commit is contained in:
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
-10
@@ -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',
|
||||
|
||||
+2
-7
@@ -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 ?? []);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
-56
@@ -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();
|
||||
Reference in new issue
Block a user