Scheduled Messages: Support repeated mode (#6503)

This commit is contained in:
Alexander Zinchuk 2025-11-22 12:54:18 +01:00
parent d81fe3ec96
commit 9d03d14af7
18 changed files with 459 additions and 62 deletions

View File

@ -47,7 +47,7 @@ import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnl
import { addTimestampEntities } from '../../../util/dates/timestamp';
import { omitUndefined, pick } from '../../../util/iteratees';
import { toJSNumber } from '../../../util/numbers';
import { getServerTime, getServerTimeOffset } from '../../../util/serverTime';
import { getServerTime } from '../../../util/serverTime';
import { interpolateArray } from '../../../util/waveform';
import {
buildApiCurrencyAmount,
@ -254,6 +254,7 @@ export function buildApiMessageWithChatId(
viewsCount: mtpMessage.views,
forwardsCount: mtpMessage.forwards,
isScheduled,
scheduleRepeatPeriod: mtpMessage.scheduleRepeatPeriod,
isFromScheduled: mtpMessage.fromScheduled,
isSilent: mtpMessage.silent,
isPinned: mtpMessage.pinned,
@ -442,6 +443,7 @@ export function buildLocalMessage(
contact?: ApiContact,
groupedId?: string,
scheduledAt?: number,
scheduleRepeatPeriod?: number,
sendAs?: ApiPeer,
story?: ApiStory | ApiStorySkipped,
isInvertedMedia?: true,
@ -475,7 +477,7 @@ export function buildLocalMessage(
pollId: localPoll?.id,
todo: localTodo,
}),
date: scheduledAt || Math.round(Date.now() / 1000) + getServerTimeOffset(),
date: scheduledAt || getServerTime(),
isOutgoing: !isChannel,
senderId: chat.type !== 'chatTypePrivate' ? (sendAs?.id || currentUserId) : undefined,
replyInfo: resultReplyInfo,
@ -485,6 +487,7 @@ export function buildLocalMessage(
...(media && (media.photo || media.video) && { isInAlbum: true }),
}),
...(scheduledAt && { isScheduled: true }),
scheduleRepeatPeriod,
isForwardingAllowed: true,
isInvertedMedia,
effectId,
@ -506,6 +509,7 @@ export function buildLocalForwardedMessage({
toThreadId,
message,
scheduledAt,
scheduleRepeatPeriod,
noAuthors,
noCaptions,
isCurrentUserPremium,
@ -516,6 +520,7 @@ export function buildLocalForwardedMessage({
toThreadId?: number;
message: ApiMessage;
scheduledAt?: number;
scheduleRepeatPeriod?: number;
noAuthors?: boolean;
noCaptions?: boolean;
isCurrentUserPremium?: boolean;
@ -565,7 +570,8 @@ export function buildLocalForwardedMessage({
id: localId,
chatId: toChat.id,
content: updatedContent,
date: scheduledAt || Math.round(Date.now() / 1000) + getServerTimeOffset(),
date: scheduledAt || getServerTime(),
scheduleRepeatPeriod,
isOutgoing: !asIncomingInChatWithSelf && toChat.type !== 'chatTypeChannel',
senderId: toChat.type !== 'chatTypePrivate' ? (sendAs?.id || currentUserId) : undefined,
sendingState: 'messageSendingStatePending',

View File

@ -300,7 +300,8 @@ export function sendMessageLocal(
) {
const {
chat, lastMessageId, text, entities, replyInfo, suggestedPostInfo, attachment, sticker, story, gif, poll, todo,
contact, scheduledAt, groupedId, sendAs, wasDrafted, isInvertedMedia, effectId, isPending, messagePriceInStars,
contact, scheduledAt, scheduleRepeatPeriod, groupedId, sendAs, wasDrafted, isInvertedMedia, effectId, isPending,
messagePriceInStars,
} = params;
if (!chat) return undefined;
@ -323,6 +324,7 @@ export function sendMessageLocal(
contact,
groupedId,
scheduledAt,
scheduleRepeatPeriod,
sendAs,
story,
isInvertedMedia,
@ -352,7 +354,7 @@ export function sendApiMessage(
chat, text, entities, replyInfo, suggestedPostInfo, suggestedMedia,
attachment, sticker, story, gif, poll, todo, contact,
isSilent, scheduledAt, groupedId, noWebPage, sendAs, shouldUpdateStickerSetOrder,
isSilent, scheduledAt, scheduleRepeatPeriod, groupedId, noWebPage, sendAs, shouldUpdateStickerSetOrder,
isInvertedMedia, effectId, webPageMediaSize, webPageUrl, messagePriceInStars,
} = params;
@ -384,6 +386,7 @@ export function sendApiMessage(
groupedId,
isSilent,
scheduledAt,
scheduleRepeatPeriod,
messagePriceInStars,
}, randomId, localMessage, onProgress);
}
@ -484,6 +487,7 @@ export function sendApiMessage(
replyTo: replyInfo && buildInputReplyTo(replyInfo),
silent: isSilent || undefined,
scheduleDate: scheduledAt,
scheduleRepeatPeriod,
sendAs: sendAs && buildInputPeer(sendAs.id, sendAs.accessHash),
updateStickersetsOrder: shouldUpdateStickerSetOrder || undefined,
invertMedia: isInvertedMedia || undefined,
@ -556,6 +560,7 @@ function sendGroupedMedia(
groupedId,
isSilent,
scheduledAt,
scheduleRepeatPeriod,
sendAs,
messagePriceInStars,
}: {
@ -568,6 +573,7 @@ function sendGroupedMedia(
groupedId: string;
isSilent?: boolean;
scheduledAt?: number;
scheduleRepeatPeriod?: number;
sendAs?: ApiPeer;
messagePriceInStars?: number;
},
@ -645,6 +651,7 @@ function sendGroupedMedia(
replyTo: replyInfo && buildInputReplyTo(replyInfo),
...(isSilent && { silent: isSilent }),
...(scheduledAt && { scheduleDate: scheduledAt }),
...(scheduleRepeatPeriod && { scheduleRepeatPeriod }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
...(messagePriceInStars && { allowPaidStars: BigInt(messagePriceInStars * count) }),
...(suggestedPostInfo && { suggestedPost: buildInputSuggestedPost(suggestedPostInfo) }),
@ -756,6 +763,7 @@ export async function editMessage({
peer: buildInputPeer(chat.id, chat.accessHash),
id: message.id,
...(isScheduled && { scheduleDate: message.date }),
...(message.scheduleRepeatPeriod && { scheduleRepeatPeriod: message.scheduleRepeatPeriod }),
...(noWebPage && { noWebpage: noWebPage }),
...(isInvertedMedia && { invertMedia: isInvertedMedia }),
}), { shouldThrow: true });
@ -893,15 +901,18 @@ export async function rescheduleMessage({
chat,
message,
scheduledAt,
scheduleRepeatPeriod,
}: {
chat: ApiChat;
message: ApiMessage;
scheduledAt: number;
scheduleRepeatPeriod?: number;
}) {
await invokeRequest(new GramJs.messages.EditMessage({
peer: buildInputPeer(chat.id, chat.accessHash),
id: message.id,
scheduleDate: scheduledAt,
scheduleRepeatPeriod,
}));
}
@ -1842,7 +1853,7 @@ export async function fetchExtendedMedia({
export function forwardMessagesLocal(params: ForwardMessagesParams) {
const {
toChat, toThreadId, messages,
scheduledAt, sendAs, noAuthors, noCaptions,
scheduledAt, scheduleRepeatPeriod, sendAs, noAuthors, noCaptions,
isCurrentUserPremium, wasDrafted, lastMessageId,
} = params;
@ -1855,6 +1866,7 @@ export function forwardMessagesLocal(params: ForwardMessagesParams) {
toThreadId: Number(toThreadId),
message,
scheduledAt,
scheduleRepeatPeriod,
noAuthors,
noCaptions,
isCurrentUserPremium,
@ -1877,7 +1889,7 @@ export function forwardMessagesLocal(params: ForwardMessagesParams) {
export async function forwardApiMessages(params: ForwardMessagesParams) {
const {
fromChat, toChat, toThreadId, isSilent,
scheduledAt, sendAs, withMyScore, noAuthors, noCaptions,
scheduledAt, scheduleRepeatPeriod, sendAs, withMyScore, noAuthors, noCaptions,
forwardedLocalMessagesSlice, messagePriceInStars,
} = params;
@ -1902,6 +1914,7 @@ export async function forwardApiMessages(params: ForwardMessagesParams) {
dropMediaCaptions: noCaptions || undefined,
...(toThreadId && { topMsgId: Number(toThreadId) }),
...(scheduledAt && { scheduleDate: scheduledAt }),
...(scheduleRepeatPeriod && { scheduleRepeatPeriod }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
...(priceInStars && { allowPaidStars: BigInt(priceInStars) }),
}), {

View File

@ -648,6 +648,7 @@ export interface ApiMessage {
viaBusinessBotId?: string;
postAuthorTitle?: string;
isScheduled?: boolean;
scheduleRepeatPeriod?: number;
shouldHideKeyboardButtons?: boolean;
isHideKeyboardSelective?: boolean;
isFromScheduled?: boolean;

View File

@ -1167,6 +1167,16 @@
"SecretChat" = "Secret Chat";
"ChannelPermissionDeniedSendMessagesUntil" = "The admins of this group have restricted you from messaging until {date}.";
"FormatDateAtTime" = "{date} at {time}";
"MessageRepeatPeriodEveryMinutes_one" = "every minute";
"MessageRepeatPeriodEveryMinutes_other" = "every {count} minutes";
"MessageRepeatPeriodDaily" = "daily";
"MessageRepeatPeriodWeekly" = "weekly";
"MessageRepeatPeriodBiweekly" = "biweekly";
"MessageRepeatPeriodMonthly" = "monthly";
"MessageRepeatPeriodEveryMonths_one" = "every month";
"MessageRepeatPeriodEveryMonths_other" = "every {count} months";
"MessageRepeatPeriodYearly" = "yearly";
"MessageScheduledRepeatPremium" = "Subscribe to **Telegram Premium** to schedule repeating messages.";
"StickerPackRemoveStickerCount_one" = "Remove {count} sticker";
"StickerPackRemoveStickerCount_other" = "Remove {count} stickers";
"StickerPackAddStickerCount_one" = "Add {count} sticker";
@ -1225,6 +1235,17 @@
"Archive" = "Archive";
"WaitingForNetwork" = "Waiting for network...";
"ScheduleSendWhenOnline" = "Send When Online";
"ScheduleRepeat" = "Repeat: {value}";
"ScheduleRepeatNever" = "Never";
"ScheduleRepeatEveryMinutes_one" = "Every Minute";
"ScheduleRepeatEveryMinutes_other" = "Every {count} Minutes";
"ScheduleRepeatDaily" = "Daily";
"ScheduleRepeatWeekly" = "Weekly";
"ScheduleRepeatBiweekly" = "Biweekly";
"ScheduleRepeatMonthly" = "Monthly";
"ScheduleRepeatEveryMonths_one" = "Every {count} Month";
"ScheduleRepeatEveryMonths_other" = "Every {count} Months";
"ScheduleRepeatYearly" = "Yearly";
"VoipIncoming" = "Incoming call";
"Years_one" = "{count} year";
"Years_other" = "{count} years";

View File

@ -6,6 +6,28 @@
}
}
.repeat-mode {
position: relative;
.repeat-mode-button {
margin-bottom: 0.5rem;
}
.drop-down-icon {
transition: transform 0.3s ease;
}
.expanded-icon {
transform: rotate(-180deg);
}
.repeated-mode-transition-slide {
display: flex;
align-items: center;
justify-content: center;
}
}
.timepicker {
display: flex;
align-items: center;

View File

@ -1,20 +1,29 @@
import type { FC } from '../../lib/teact/teact';
import type React from '../../lib/teact/teact';
import {
memo, useCallback, useEffect, useMemo, useState,
memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { OldLangFn } from '../../hooks/useOldLang';
import type { RepeatedMessageMode } from '../../util/scheduledMessages';
import { MAX_INT_32 } from '../../config';
import { selectIsCurrentUserPremium } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { formatDateToString, formatTime, getDayStart } from '../../util/dates/dateFormat';
import { ALL_REPEAT_MODES, getScheduleRepeatModeText, TEST_SERVER_ONLY_MODES } from '../../util/scheduledMessages';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import usePreviousDeprecated from '../../hooks/usePreviousDeprecated';
import Button from '../ui/Button';
import Menu from '../ui/Menu';
import MenuItem from '../ui/MenuItem';
import Modal from '../ui/Modal';
import './CalendarModal.scss';
@ -30,15 +39,22 @@ export type OwnProps = {
isPastMode?: boolean;
isOpen: boolean;
withTimePicker?: boolean;
withRepeatMode?: boolean;
initialRepeatMode?: RepeatedMessageMode;
submitButtonLabel?: string;
secondButtonLabel?: string;
description?: string;
onClose: () => void;
onSubmit: (date: Date) => void;
onSubmit: (date: Date, repeatMode?: RepeatedMessageMode) => void;
onDateChange?: (date: Date) => void;
onSecondButtonClick?: NoneToVoidFunction;
};
type StateProps = {
isTestServer?: boolean;
isCurrentUserPremium?: boolean;
};
const WEEKDAY_LETTERS = [
'lng_weekday1',
'lng_weekday2',
@ -49,7 +65,7 @@ const WEEKDAY_LETTERS = [
'lng_weekday7',
];
const CalendarModal: FC<OwnProps> = ({
const CalendarModal: FC<OwnProps & StateProps> = ({
selectedAt,
minAt,
maxAt,
@ -57,17 +73,39 @@ const CalendarModal: FC<OwnProps> = ({
isPastMode,
isOpen,
withTimePicker,
withRepeatMode,
initialRepeatMode,
submitButtonLabel,
secondButtonLabel,
description,
isTestServer,
isCurrentUserPremium,
onClose,
onSubmit,
onDateChange,
onSecondButtonClick,
}) => {
const lang = useOldLang();
const { showNotification } = getActions();
const menuRef = useRef<HTMLDivElement>();
const dialogRef = useRef<HTMLDivElement>();
const oldLang = useOldLang();
const lang = useLang();
const now = new Date();
const {
isContextMenuOpen: isRepeatMenuOpen,
contextMenuAnchor: repeatMenuAnchor,
handleContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(menuRef);
const getRootElement = useLastCallback(() => dialogRef.current);
const getMenuElement = useLastCallback(() => menuRef.current!.querySelector('.bubble'));
const getTriggerElement = useLastCallback(() => dialogRef.current!.querySelector('.repeat-mode-button'));
const minDate = useMemo(() => {
if (isFutureMode && !minAt) return new Date();
return new Date(Math.max(minAt || MIN_SAFE_DATE, MIN_SAFE_DATE));
@ -91,6 +129,9 @@ const CalendarModal: FC<OwnProps> = ({
const [selectedMinutes, setSelectedMinutes] = useState<string>(() => (
formatInputTime(passedSelectedDate.getMinutes())
));
const [repeatedMessageMode, setRepeatedMessageMode] = useState<RepeatedMessageMode>(
initialRepeatMode || 'never',
);
const selectedDay = formatDay(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());
const currentYear = currentMonthAndYear.getFullYear();
@ -99,6 +140,16 @@ const CalendarModal: FC<OwnProps> = ({
const isDisabled = (isFutureMode && selectedDate.getTime() < minDate.getTime())
|| (isPastMode && selectedDate.getTime() > maxDate.getTime());
useEffect(() => {
if (!isOpen) {
setRepeatedMessageMode('never');
}
}, [isOpen]);
useEffect(() => {
setRepeatedMessageMode(initialRepeatMode || 'never');
}, [initialRepeatMode]);
useEffect(() => {
if (!prevIsOpen && isOpen) {
setSelectedDate(passedSelectedDate);
@ -144,8 +195,23 @@ const CalendarModal: FC<OwnProps> = ({
), [currentMonth, currentYear]);
const submitLabel = useMemo(() => {
return submitButtonLabel || formatSubmitLabel(lang, selectedDate);
}, [lang, selectedDate, submitButtonLabel]);
return submitButtonLabel || formatSubmitLabel(oldLang, selectedDate);
}, [oldLang, selectedDate, submitButtonLabel]);
const handleRepeatModeClick = useLastCallback((e: React.MouseEvent<HTMLButtonElement>) => {
if (!isCurrentUserPremium) {
showNotification({
message: lang('MessageScheduledRepeatPremium'),
action: {
action: 'openPremiumModal',
payload: { },
},
actionText: lang('PremiumMore'),
});
return;
}
handleContextMenu(e);
});
function handlePrevMonth() {
setCurrentMonthAndYear((d) => {
@ -178,14 +244,15 @@ const CalendarModal: FC<OwnProps> = ({
}
const handleSubmit = useCallback(() => {
const repeatMode = withRepeatMode && repeatedMessageMode !== 'never' ? repeatedMessageMode : undefined;
if (isFutureMode && selectedDate < minDate) {
onSubmit(minDate);
onSubmit(minDate, repeatMode);
} else if (isPastMode && selectedDate > maxDate) {
onSubmit(maxDate);
onSubmit(maxDate, repeatMode);
} else {
onSubmit(selectedDate);
onSubmit(selectedDate, repeatMode);
}
}, [isFutureMode, isPastMode, minDate, maxDate, onSubmit, selectedDate]);
}, [isFutureMode, isPastMode, minDate, maxDate, onSubmit, selectedDate, withRepeatMode, repeatedMessageMode]);
const handleChangeHours = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/[^\d]+/g, '');
@ -227,6 +294,18 @@ const CalendarModal: FC<OwnProps> = ({
e.target.value = minutesStr;
}, [selectedDate, onDateChange]);
const renderRepeatMenuItem = useCallback((mode: RepeatedMessageMode) => {
return (
<MenuItem key={mode} onClick={() => setRepeatedMessageMode(mode)}>
{getScheduleRepeatModeText(mode, lang)}
</MenuItem>
);
}, [lang]);
const availableRepeatModes = useMemo(() => {
return ALL_REPEAT_MODES.filter((mode) => isTestServer || !TEST_SERVER_ONLY_MODES.has(mode));
}, [isTestServer]);
function renderTimePicker() {
return (
<div className="timepicker">
@ -251,12 +330,46 @@ const CalendarModal: FC<OwnProps> = ({
);
}
function renderRepeatMode() {
const dropDownIconClass = buildClassName('drop-down-icon', !isRepeatMenuOpen && 'expanded-icon');
return (
<div className="repeat-mode" ref={menuRef}>
<Button
className="repeat-mode-button"
onClick={handleRepeatModeClick}
noForcedUpperCase
isText
iconName={isCurrentUserPremium ? 'down' : 'lock-badge'}
iconClassName={isCurrentUserPremium ? dropDownIconClass : undefined}
iconAlignment="end"
>
{lang('ScheduleRepeat', { value: getScheduleRepeatModeText(repeatedMessageMode, lang) })}
</Button>
<Menu
isOpen={isRepeatMenuOpen}
className="with-menu-transitions"
anchor={repeatMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
autoClose
>
{availableRepeatModes.map(renderRepeatMenuItem)}
</Menu>
</div>
);
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="CalendarModal"
onEnter={handleSubmit}
dialogRef={dialogRef}
>
<div className="container">
<div className="month-selector">
@ -269,7 +382,7 @@ const CalendarModal: FC<OwnProps> = ({
/>
<h4>
{lang(`lng_month${currentMonth + 1}`)}
{oldLang(`lng_month${currentMonth + 1}`)}
{' '}
{currentYear}
</h4>
@ -298,7 +411,7 @@ const CalendarModal: FC<OwnProps> = ({
<div className="calendar-grid">
{WEEKDAY_LETTERS.map((day) => (
<div className="day-button faded weekday">
<span>{lang(day)}</span>
<span>{oldLang(day)}</span>
</div>
))}
{prevMonthGrid.map((gridDate) => (
@ -332,6 +445,7 @@ const CalendarModal: FC<OwnProps> = ({
</div>
{withTimePicker && renderTimePicker()}
{withRepeatMode && isOpen && renderRepeatMode()}
<div className="footer">
{description && (
@ -422,4 +536,13 @@ function formatSubmitLabel(lang: OldLangFn, date: Date) {
return lang('Conversation.ScheduleMessage.SendOn', [day, formatTime(lang, date)]);
}
export default memo(CalendarModal);
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
return {
isTestServer: global.config?.isTestServer,
isCurrentUserPremium,
};
},
)(CalendarModal));

View File

@ -1079,6 +1079,7 @@ const Composer: FC<OwnProps & StateProps> = ({
sendGrouped = attachmentSettings.shouldSendGrouped,
isSilent,
scheduledAt,
scheduleRepeatPeriod,
isInvertedMedia,
}: {
attachments: ApiAttachment[];
@ -1086,6 +1087,7 @@ const Composer: FC<OwnProps & StateProps> = ({
sendGrouped?: boolean;
isSilent?: boolean;
scheduledAt?: number;
scheduleRepeatPeriod?: number;
isInvertedMedia?: true;
}) => {
if (!currentMessageList && !storyId) {
@ -1110,6 +1112,7 @@ const Composer: FC<OwnProps & StateProps> = ({
text,
entities,
scheduledAt,
scheduleRepeatPeriod,
isSilent,
shouldUpdateStickerSetOrder,
attachments: prepareAttachmentsToSend(attachmentsToSend, sendCompressed),
@ -1159,6 +1162,7 @@ const Composer: FC<OwnProps & StateProps> = ({
isSilent?: boolean,
scheduledAt?: number,
isInvertedMedia?: true,
scheduleRepeatPeriod?: number,
) => {
if (canSendAttachments(attachments)) {
sendAttachments({
@ -1167,13 +1171,19 @@ const Composer: FC<OwnProps & StateProps> = ({
sendGrouped,
isSilent,
scheduledAt,
scheduleRepeatPeriod,
isInvertedMedia,
});
}
});
const handleSendCore = useLastCallback(
(currentAttachments: ApiAttachment[], isSilent = false, scheduledAt?: number) => {
(
currentAttachments: ApiAttachment[],
isSilent = false,
scheduledAt?: number,
scheduleRepeatPeriod?: number,
) => {
const { text, entities } = parseHtmlAsFormattedText(getHtml());
if (currentAttachments.length) {
@ -1181,6 +1191,7 @@ const Composer: FC<OwnProps & StateProps> = ({
sendAttachments({
attachments: currentAttachments,
scheduledAt,
scheduleRepeatPeriod,
isSilent,
});
}
@ -1209,6 +1220,7 @@ const Composer: FC<OwnProps & StateProps> = ({
text,
entities,
scheduledAt,
scheduleRepeatPeriod,
isSilent,
shouldUpdateStickerSetOrder,
isInvertedMedia,
@ -1235,7 +1247,11 @@ const Composer: FC<OwnProps & StateProps> = ({
},
);
const handleSend = useLastCallback(async (isSilent = false, scheduledAt?: number) => {
const handleSend = useLastCallback(async (
isSilent = false,
scheduledAt?: number,
scheduleRepeatPeriod?: number,
) => {
if (!currentMessageList && !storyId) {
return;
}
@ -1257,11 +1273,15 @@ const Composer: FC<OwnProps & StateProps> = ({
}
}
handleSendCore(currentAttachments, isSilent, scheduledAt);
handleSendCore(currentAttachments, isSilent, scheduledAt, scheduleRepeatPeriod);
});
const handleSendWithConfirmation = useLastCallback((isSilent = false, scheduledAt?: number) => {
handleActionWithPaymentConfirmation(handleSend, isSilent, scheduledAt);
const handleSendWithConfirmation = useLastCallback((
isSilent = false,
scheduledAt?: number,
scheduleRepeatPeriod?: number,
) => {
handleActionWithPaymentConfirmation(handleSend, isSilent, scheduledAt, scheduleRepeatPeriod);
});
const handleTodoListCreate = useLastCallback(() => {
@ -1302,7 +1322,11 @@ const Composer: FC<OwnProps & StateProps> = ({
});
const handleMessageSchedule = useLastCallback((
args: ScheduledMessageArgs, scheduledAt: number, messageList: MessageList, effectId?: string,
args: ScheduledMessageArgs,
scheduledAt: number,
scheduleRepeatPeriod: number | undefined,
messageList: MessageList,
effectId?: string,
) => {
if (args && 'queryId' in args) {
const { id, queryId, isSilent } = args;
@ -1320,15 +1344,17 @@ const Composer: FC<OwnProps & StateProps> = ({
const { isSilent, ...restArgs } = args || {};
if (!args || Object.keys(restArgs).length === 0) {
void handleSend(Boolean(isSilent), scheduledAt);
void handleSend(Boolean(isSilent), scheduledAt, scheduleRepeatPeriod);
} else if (args.sendCompressed !== undefined || args.sendGrouped !== undefined) {
const { sendCompressed = false, sendGrouped = false, isInvertedMedia } = args;
void handleSendAttachments(sendCompressed, sendGrouped, isSilent, scheduledAt, isInvertedMedia);
void handleSendAttachments(sendCompressed, sendGrouped, isSilent, scheduledAt, isInvertedMedia,
scheduleRepeatPeriod);
} else {
sendMessage({
...args,
messageList,
scheduledAt,
scheduleRepeatPeriod,
effectId,
});
}
@ -1336,8 +1362,8 @@ const Composer: FC<OwnProps & StateProps> = ({
useEffectWithPrevDeps(([prevContentToBeScheduled]) => {
if (currentMessageList && contentToBeScheduled && contentToBeScheduled !== prevContentToBeScheduled) {
requestCalendar((scheduledAt) => {
handleMessageSchedule(contentToBeScheduled, scheduledAt, currentMessageList);
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
handleMessageSchedule(contentToBeScheduled, scheduledAt, scheduleRepeatPeriod, currentMessageList, undefined);
});
}
}, [contentToBeScheduled, currentMessageList, handleMessageSchedule, requestCalendar]);
@ -1393,9 +1419,15 @@ const Composer: FC<OwnProps & StateProps> = ({
if (isInScheduledList || isScheduleRequested) {
forceShowSymbolMenu();
requestCalendar((scheduledAt) => {
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
cancelForceShowSymbolMenu();
handleActionWithPaymentConfirmation(handleMessageSchedule, { gif, isSilent }, scheduledAt, currentMessageList!);
handleActionWithPaymentConfirmation(
handleMessageSchedule,
{ gif, isSilent },
scheduledAt,
scheduleRepeatPeriod,
currentMessageList!,
);
requestMeasure(() => {
resetComposer(true);
});
@ -1430,10 +1462,14 @@ const Composer: FC<OwnProps & StateProps> = ({
if (isInScheduledList || isScheduleRequested) {
forceShowSymbolMenu();
requestCalendar((scheduledAt) => {
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
cancelForceShowSymbolMenu();
handleActionWithPaymentConfirmation(
handleMessageSchedule, { sticker, isSilent }, scheduledAt, currentMessageList!,
handleMessageSchedule,
{ sticker, isSilent },
scheduledAt,
scheduleRepeatPeriod,
currentMessageList!,
);
requestMeasure(() => {
resetComposer(shouldPreserveInput);
@ -1467,7 +1503,7 @@ const Composer: FC<OwnProps & StateProps> = ({
isSilent = isSilent || isSilentPosting;
if (isInScheduledList || isScheduleRequested) {
requestCalendar((scheduledAt) => {
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
handleActionWithPaymentConfirmation(
handleMessageSchedule,
{
@ -1476,6 +1512,7 @@ const Composer: FC<OwnProps & StateProps> = ({
isSilent,
},
scheduledAt,
scheduleRepeatPeriod,
currentMessageList!,
);
});
@ -1516,11 +1553,12 @@ const Composer: FC<OwnProps & StateProps> = ({
}
if (isInScheduledList) {
requestCalendar((scheduledAt) => {
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
handleActionWithPaymentConfirmation(
handleMessageSchedule,
{ poll },
scheduledAt,
scheduleRepeatPeriod,
currentMessageList,
);
});
@ -1540,11 +1578,12 @@ const Composer: FC<OwnProps & StateProps> = ({
}
if (isInScheduledList) {
requestCalendar((scheduledAt) => {
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
handleActionWithPaymentConfirmation(
handleMessageSchedule,
{ todo },
scheduledAt,
scheduleRepeatPeriod,
currentMessageList,
);
});
@ -1558,8 +1597,13 @@ const Composer: FC<OwnProps & StateProps> = ({
const sendSilent = useLastCallback((additionalArgs?: ScheduledMessageArgs) => {
if (isInScheduledList) {
requestCalendar((scheduledAt) => {
handleMessageSchedule({ ...additionalArgs, isSilent: true }, scheduledAt, currentMessageList!);
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
handleMessageSchedule(
{ ...additionalArgs, isSilent: true },
scheduledAt,
scheduleRepeatPeriod,
currentMessageList!,
);
});
} else if (additionalArgs && ('sendCompressed' in additionalArgs || 'sendGrouped' in additionalArgs)) {
const { sendCompressed = false, sendGrouped = false, isInvertedMedia } = additionalArgs;
@ -1779,8 +1823,8 @@ const Composer: FC<OwnProps & StateProps> = ({
if (!currentMessageList) {
return;
}
requestCalendar((scheduledAt) => {
handleMessageSchedule({}, scheduledAt, currentMessageList, effect?.id);
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
handleMessageSchedule({}, scheduledAt, scheduleRepeatPeriod, currentMessageList, effect?.id);
});
break;
default:
@ -1870,8 +1914,8 @@ const Composer: FC<OwnProps & StateProps> = ({
});
const handleSendScheduled = useLastCallback(() => {
requestCalendar((scheduledAt) => {
handleMessageSchedule({}, scheduledAt, currentMessageList!);
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
handleMessageSchedule({}, scheduledAt, scheduleRepeatPeriod, currentMessageList!, undefined);
});
});
@ -1881,18 +1925,20 @@ const Composer: FC<OwnProps & StateProps> = ({
const handleSendWhenOnline = useLastCallback(() => {
handleActionWithPaymentConfirmation(
handleMessageSchedule, {}, SCHEDULED_WHEN_ONLINE, currentMessageList!, effect?.id,
handleMessageSchedule, {}, SCHEDULED_WHEN_ONLINE, undefined, currentMessageList!, effect?.id,
);
});
const handleSendScheduledAttachments = useLastCallback(
(sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true) => {
requestCalendar((scheduledAt) => {
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
handleActionWithPaymentConfirmation(
handleMessageSchedule,
{ sendCompressed, sendGrouped, isInvertedMedia },
scheduledAt,
scheduleRepeatPeriod,
currentMessageList!,
undefined,
);
});
},

View File

@ -125,9 +125,9 @@ const StickerSetModal: FC<OwnProps & StateProps> = ({
};
if (shouldSchedule || isScheduleRequested) {
requestCalendar((scheduledAt) => {
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
sendMessage({
messageList: currentMessageList, sticker, isSilent, scheduledAt,
messageList: currentMessageList, sticker, isSilent, scheduledAt, scheduleRepeatPeriod,
});
onClose();
});

View File

@ -287,7 +287,12 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
const [isPinModalOpen, setIsPinModalOpen] = useState(false);
const [isClosePollDialogOpen, openClosePollDialog, closeClosePollDialog] = useFlag();
const [selectionQuoteOffset, setSelectionQuoteOffset] = useState(UNQUOTABLE_OFFSET);
const [requestCalendar, calendar] = useSchedule(canScheduleUntilOnline, onClose, message.date);
const [requestCalendar, calendar] = useSchedule(
canScheduleUntilOnline,
onClose,
message.date,
message.scheduleRepeatPeriod,
);
// `undefined` indicates that emoji are present and loading
const hasCustomEmoji = customEmojiSetsInfo === undefined || Boolean(customEmojiSetsInfo.length);
@ -535,11 +540,15 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu();
});
const handleRescheduleMessage = useLastCallback((scheduledAt: number) => {
const handleRescheduleMessage = useLastCallback((
scheduledAt: number,
scheduleRepeatPeriod?: number,
) => {
rescheduleMessage({
chatId: message.chatId,
messageId: message.id,
scheduledAt,
scheduleRepeatPeriod,
});
onClose();
});

View File

@ -10,6 +10,7 @@ import type {
import buildClassName from '../../../util/buildClassName';
import { formatDateTimeToString, formatPastTimeShort, formatTime } from '../../../util/dates/dateFormat';
import { formatStarsAsIcon } from '../../../util/localization/format';
import { getRepeatPeriodText } from '../../../util/scheduledMessages';
import { formatIntegerCompact } from '../../../util/textFormat';
import renderText from '../../common/helpers/renderText';
@ -83,6 +84,10 @@ const MessageMeta: FC<OwnProps> = ({
onOpenThread();
}
const repeatPeriodText = useMemo(() => {
return getRepeatPeriodText(message.scheduleRepeatPeriod, lang);
}, [message.scheduleRepeatPeriod, lang]);
const dateTitle = useMemo(() => {
if (!isActivated) return undefined;
const createDateTime = formatDateTimeToString(message.date * 1000, oldLang.code, undefined, oldLang.timeFormat);
@ -134,12 +139,16 @@ const MessageMeta: FC<OwnProps> = ({
const date = useMemo(() => {
const time = formatTime(oldLang, message.date * 1000);
if (!withFullDate) {
return time;
const baseDate = !withFullDate
? time
: formatPastTimeShort(oldLang, (message.forwardInfo?.date || message.date) * 1000, true);
if (repeatPeriodText) {
return lang('FormatDateAtTime', { date: repeatPeriodText, time: baseDate });
}
return formatPastTimeShort(oldLang, (message.forwardInfo?.date || message.date) * 1000, true);
}, [oldLang, message.date, message.forwardInfo?.date, withFullDate]);
return baseDate;
}, [oldLang, message.date, message.forwardInfo?.date, withFullDate, repeatPeriodText, lang]);
const fullClassName = buildClassName(
'MessageMeta',

View File

@ -87,11 +87,12 @@ const GifSearch: FC<OwnProps & StateProps> = ({
}
if (shouldSchedule) {
requestCalendar((scheduledAt) => {
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
sendMessage({
messageList: currentMessageList,
gif,
scheduledAt,
scheduleRepeatPeriod,
isSilent,
});
});

View File

@ -1545,7 +1545,7 @@ addActionHandler('sendScheduledMessages', (global, actions, payload): ActionRetu
addActionHandler('rescheduleMessage', (global, actions, payload): ActionReturnType => {
const {
chatId, messageId, scheduledAt,
chatId, messageId, scheduledAt, scheduleRepeatPeriod,
} = payload;
const chat = selectChat(global, chatId);
@ -1558,6 +1558,7 @@ addActionHandler('rescheduleMessage', (global, actions, payload): ActionReturnTy
chat,
message,
scheduledAt,
scheduleRepeatPeriod,
});
});
@ -1610,19 +1611,19 @@ addActionHandler('loadCustomEmojis', async (global, actions, payload): Promise<v
addActionHandler('forwardMessages', (global, actions, payload): ActionReturnType => {
const {
isSilent, scheduledAt, tabId = getCurrentTabId(),
isSilent, scheduledAt, scheduleRepeatPeriod, tabId = getCurrentTabId(),
} = payload;
const { toChatId } = selectTabState(global, tabId).forwardMessages;
const toChat = toChatId ? selectChat(global, toChatId) : undefined;
if (!toChat) return;
executeForwardMessages(global, { chat: toChat, isSilent, scheduledAt }, tabId);
executeForwardMessages(global, { chat: toChat, isSilent, scheduledAt, scheduleRepeatPeriod }, tabId);
});
async function executeForwardMessages(global: GlobalState, sendParams: SendMessageParams, tabId: number) {
const {
fromChatId, messageIds, toChatId, withMyScore, noAuthors, noCaptions, toThreadId = MAIN_THREAD_ID,
} = selectTabState(global, tabId).forwardMessages;
const { messagePriceInStars, isSilent, scheduledAt } = sendParams;
const { messagePriceInStars, isSilent, scheduledAt, scheduleRepeatPeriod } = sendParams;
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
const isToMainThread = toThreadId === MAIN_THREAD_ID;
@ -1659,6 +1660,7 @@ async function executeForwardMessages(global: GlobalState, sendParams: SendMessa
messages: slice,
isSilent,
scheduledAt,
scheduleRepeatPeriod,
sendAs,
withMyScore,
noAuthors,
@ -1696,6 +1698,7 @@ async function executeForwardMessages(global: GlobalState, sendParams: SendMessa
sticker,
isSilent,
scheduledAt,
scheduleRepeatPeriod,
sendAs,
lastMessageId,
};

View File

@ -461,6 +461,7 @@ export interface ActionPayloads {
chatId: string;
messageId: number;
scheduledAt: number;
scheduleRepeatPeriod?: number;
};
deleteScheduledMessages: { messageIds: number[] } & WithTabId;
// Message
@ -1934,6 +1935,7 @@ export interface ActionPayloads {
forwardMessages: {
isSilent?: boolean;
scheduledAt?: number;
scheduleRepeatPeriod?: number;
} & WithTabId;
setForwardNoAuthors: {
noAuthors: boolean;

View File

@ -1,33 +1,39 @@
import { useState } from '../lib/teact/teact';
import type { RepeatedMessageMode } from '../util/scheduledMessages';
import { SCHEDULED_WHEN_ONLINE } from '../config';
import { getDayStartAt } from '../util/dates/dateFormat';
import { getRepeatModeFromSeconds, getRepeatPeriodSeconds } from '../util/scheduledMessages';
import { getServerTimeOffset } from '../util/serverTime';
import useLastCallback from './useLastCallback';
import useOldLang from './useOldLang';
import CalendarModal from '../components/common/CalendarModal.async';
type OnScheduledCallback = (scheduledAt: number) => void;
type OnScheduledCallback = (scheduledAt: number, repeatPeriod?: number) => void;
const useSchedule = (
canScheduleUntilOnline?: boolean,
onCancel?: () => void,
openAt?: number,
initialRepeatPeriod?: number,
) => {
const lang = useOldLang();
const [onScheduled, setOnScheduled] = useState<OnScheduledCallback | undefined>();
const handleMessageSchedule = useLastCallback((date: Date, isWhenOnline = false) => {
const handleMessageSchedule = useLastCallback((date: Date, repeatMode?: RepeatedMessageMode) => {
// Scheduled time can not be less than 10 seconds in future
const isWhenOnline = date.getTime() === SCHEDULED_WHEN_ONLINE * 1000;
const scheduledAt = Math.round(Math.max(date.getTime(), Date.now() + 60 * 1000) / 1000)
+ (isWhenOnline ? 0 : getServerTimeOffset());
onScheduled?.(scheduledAt);
const repeatPeriod = getRepeatPeriodSeconds(repeatMode);
onScheduled?.(scheduledAt, repeatPeriod);
setOnScheduled(undefined);
});
const handleMessageScheduleUntilOnline = useLastCallback(() => {
handleMessageSchedule(new Date(SCHEDULED_WHEN_ONLINE * 1000), true);
handleMessageSchedule(new Date(SCHEDULED_WHEN_ONLINE * 1000));
});
const handleCloseCalendar = useLastCallback(() => {
@ -46,10 +52,14 @@ const useSchedule = (
const scheduledMaxDate = new Date();
scheduledMaxDate.setFullYear(scheduledMaxDate.getFullYear() + 1);
const initialRepeatMode = getRepeatModeFromSeconds(initialRepeatPeriod);
const calendar = (
<CalendarModal
isOpen={Boolean(onScheduled)}
withTimePicker
withRepeatMode
initialRepeatMode={initialRepeatMode}
selectedAt={scheduledDefaultDate.getTime()}
maxAt={getDayStartAt(scheduledMaxDate)}
isFutureMode

View File

@ -723,6 +723,7 @@ export type SendMessageParams = {
contact?: ApiContact;
isSilent?: boolean;
scheduledAt?: number;
scheduleRepeatPeriod?: number;
groupedId?: string;
noWebPage?: boolean;
sendAs?: ApiPeer;
@ -758,6 +759,7 @@ export type ForwardMessagesParams = {
messages: ApiMessage[];
isSilent?: boolean;
scheduledAt?: number;
scheduleRepeatPeriod?: number;
sendAs?: ApiPeer;
withMyScore?: boolean;
noAuthors?: boolean;

View File

@ -1006,6 +1006,12 @@ export interface LangPair {
'WeekdayYesterday': undefined;
'User': undefined;
'SecretChat': undefined;
'MessageRepeatPeriodDaily': undefined;
'MessageRepeatPeriodWeekly': undefined;
'MessageRepeatPeriodBiweekly': undefined;
'MessageRepeatPeriodMonthly': undefined;
'MessageRepeatPeriodYearly': undefined;
'MessageScheduledRepeatPremium': undefined;
'ChatListFilterErrorEmpty': undefined;
'ChatListFilterErrorTitleEmpty': undefined;
'FilterMuted': undefined;
@ -1052,6 +1058,12 @@ export interface LangPair {
'Archive': undefined;
'WaitingForNetwork': undefined;
'ScheduleSendWhenOnline': undefined;
'ScheduleRepeatNever': undefined;
'ScheduleRepeatDaily': undefined;
'ScheduleRepeatWeekly': undefined;
'ScheduleRepeatBiweekly': undefined;
'ScheduleRepeatMonthly': undefined;
'ScheduleRepeatYearly': undefined;
'VoipIncoming': undefined;
'LiveLocationUpdatedJustNow': undefined;
'RightNow': undefined;
@ -2057,6 +2069,9 @@ export interface LangPairWithVariables<V = LangVariable> {
'count': V;
'total': V;
};
'ScheduleRepeat': {
'value': V;
};
'MessageTimerShortHours': {
'count': V;
};
@ -3182,12 +3197,24 @@ export interface LangPairPluralWithVariables<V = LangVariable> {
'LastSeenHoursAgo': {
'count': V;
};
'MessageRepeatPeriodEveryMinutes': {
'count': V;
};
'MessageRepeatPeriodEveryMonths': {
'count': V;
};
'StickerPackRemoveStickerCount': {
'count': V;
};
'StickerPackAddStickerCount': {
'count': V;
};
'ScheduleRepeatEveryMinutes': {
'count': V;
};
'ScheduleRepeatEveryMonths': {
'count': V;
};
'Years': {
'count': V;
};

View File

@ -2,6 +2,8 @@
export const MINUTE = 60;
export const HOUR = 3600;
export const DAY = 86400;
export const WEEK = 7 * DAY;
export const MONTH = 30 * DAY;
export function getMinutes(seconds: number, roundDown?: boolean) {
const roundFunc = roundDown ? Math.floor : Math.ceil;

View File

@ -0,0 +1,100 @@
import type { LangFn } from './localization';
import { DAY, MINUTE, MONTH, WEEK } from './dates/units';
export type RepeatedMessageMode = 'never' | 'everyminute' | 'every5minutes' | 'daily' | 'weekly'
| 'biweekly' | 'monthly' | 'every3months' | 'every6months' | 'yearly';
type ActualRepeatedMessageMode = Exclude<RepeatedMessageMode, 'never'>;
const MODE_TO_SECONDS: Record<ActualRepeatedMessageMode, number> = {
everyminute: MINUTE,
every5minutes: 5 * MINUTE,
daily: DAY,
weekly: WEEK,
biweekly: 2 * WEEK,
monthly: MONTH,
every3months: 91 * DAY,
every6months: 182 * DAY,
yearly: 365 * DAY,
};
export const ALL_REPEAT_MODES: RepeatedMessageMode[] = [
'never',
...Object.keys(MODE_TO_SECONDS) as ActualRepeatedMessageMode[],
];
export const TEST_SERVER_ONLY_MODES = new Set<RepeatedMessageMode>([
'everyminute',
'every5minutes',
]);
export function getRepeatPeriodSeconds(mode?: RepeatedMessageMode) {
if (!mode || mode === 'never') return undefined;
return MODE_TO_SECONDS[mode];
}
export function getRepeatModeFromSeconds(seconds?: number) {
if (!seconds) return undefined;
return (Object.keys(MODE_TO_SECONDS) as ActualRepeatedMessageMode[])
.find((mode) => MODE_TO_SECONDS[mode] === seconds);
}
export function getRepeatPeriodText(seconds: number | undefined, lang: LangFn) {
if (!seconds) return undefined;
const mode = getRepeatModeFromSeconds(seconds);
if (!mode) return undefined;
switch (mode) {
case 'everyminute':
return lang('MessageRepeatPeriodEveryMinutes', { count: 1 }, { pluralValue: 1 });
case 'every5minutes':
return lang('MessageRepeatPeriodEveryMinutes', { count: 5 }, { pluralValue: 5 });
case 'daily':
return lang('MessageRepeatPeriodDaily');
case 'weekly':
return lang('MessageRepeatPeriodWeekly');
case 'biweekly':
return lang('MessageRepeatPeriodBiweekly');
case 'monthly':
return lang('MessageRepeatPeriodMonthly');
case 'every3months':
return lang('MessageRepeatPeriodEveryMonths', { count: 3 }, { pluralValue: 3 });
case 'every6months':
return lang('MessageRepeatPeriodEveryMonths', { count: 6 }, { pluralValue: 6 });
case 'yearly':
return lang('MessageRepeatPeriodYearly');
default:
return undefined;
}
}
export function getScheduleRepeatModeText(mode: RepeatedMessageMode, lang: LangFn) {
switch (mode) {
case 'never':
return lang('ScheduleRepeatNever');
case 'everyminute':
return lang('ScheduleRepeatEveryMinutes', { count: 1 }, { pluralValue: 1 });
case 'every5minutes':
return lang('ScheduleRepeatEveryMinutes', { count: 5 }, { pluralValue: 5 });
case 'daily':
return lang('ScheduleRepeatDaily');
case 'weekly':
return lang('ScheduleRepeatWeekly');
case 'biweekly':
return lang('ScheduleRepeatBiweekly');
case 'monthly':
return lang('ScheduleRepeatMonthly');
case 'every3months':
return lang('ScheduleRepeatEveryMonths', { count: 3 }, { pluralValue: 3 });
case 'every6months':
return lang('ScheduleRepeatEveryMonths', { count: 6 }, { pluralValue: 6 });
case 'yearly':
return lang('ScheduleRepeatYearly');
default:
return lang('ScheduleRepeatNever');
}
}