diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 0d0bb2891..2432a7e10 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -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', diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index c6e658678..5d1d185b6 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -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) }), }), { diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index d4d7a1242..2602c3123 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -648,6 +648,7 @@ export interface ApiMessage { viaBusinessBotId?: string; postAuthorTitle?: string; isScheduled?: boolean; + scheduleRepeatPeriod?: number; shouldHideKeyboardButtons?: boolean; isHideKeyboardSelective?: boolean; isFromScheduled?: boolean; diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 7874040c5..bdbb9b111 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -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"; diff --git a/src/components/common/CalendarModal.scss b/src/components/common/CalendarModal.scss index 1ce1caa2e..7f9725fe9 100644 --- a/src/components/common/CalendarModal.scss +++ b/src/components/common/CalendarModal.scss @@ -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; diff --git a/src/components/common/CalendarModal.tsx b/src/components/common/CalendarModal.tsx index ed1e16bd9..f8d202895 100644 --- a/src/components/common/CalendarModal.tsx +++ b/src/components/common/CalendarModal.tsx @@ -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 = ({ +const CalendarModal: FC = ({ selectedAt, minAt, maxAt, @@ -57,17 +73,39 @@ const CalendarModal: FC = ({ isPastMode, isOpen, withTimePicker, + withRepeatMode, + initialRepeatMode, submitButtonLabel, secondButtonLabel, description, + isTestServer, + isCurrentUserPremium, onClose, onSubmit, onDateChange, onSecondButtonClick, }) => { - const lang = useOldLang(); + const { showNotification } = getActions(); + + const menuRef = useRef(); + const dialogRef = useRef(); + + 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 = ({ const [selectedMinutes, setSelectedMinutes] = useState(() => ( formatInputTime(passedSelectedDate.getMinutes()) )); + const [repeatedMessageMode, setRepeatedMessageMode] = useState( + initialRepeatMode || 'never', + ); const selectedDay = formatDay(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate()); const currentYear = currentMonthAndYear.getFullYear(); @@ -99,6 +140,16 @@ const CalendarModal: FC = ({ 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 = ({ ), [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) => { + 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 = ({ } 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) => { const value = e.target.value.replace(/[^\d]+/g, ''); @@ -227,6 +294,18 @@ const CalendarModal: FC = ({ e.target.value = minutesStr; }, [selectedDate, onDateChange]); + const renderRepeatMenuItem = useCallback((mode: RepeatedMessageMode) => { + return ( + setRepeatedMessageMode(mode)}> + {getScheduleRepeatModeText(mode, lang)} + + ); + }, [lang]); + + const availableRepeatModes = useMemo(() => { + return ALL_REPEAT_MODES.filter((mode) => isTestServer || !TEST_SERVER_ONLY_MODES.has(mode)); + }, [isTestServer]); + function renderTimePicker() { return (
@@ -251,12 +330,46 @@ const CalendarModal: FC = ({ ); } + function renderRepeatMode() { + const dropDownIconClass = buildClassName('drop-down-icon', !isRepeatMenuOpen && 'expanded-icon'); + + return ( +
+ + + {availableRepeatModes.map(renderRepeatMenuItem)} + +
+ ); + } + return (
@@ -269,7 +382,7 @@ const CalendarModal: FC = ({ />

- {lang(`lng_month${currentMonth + 1}`)} + {oldLang(`lng_month${currentMonth + 1}`)} {' '} {currentYear}

@@ -298,7 +411,7 @@ const CalendarModal: FC = ({
{WEEKDAY_LETTERS.map((day) => (
- {lang(day)} + {oldLang(day)}
))} {prevMonthGrid.map((gridDate) => ( @@ -332,6 +445,7 @@ const CalendarModal: FC = ({
{withTimePicker && renderTimePicker()} + {withRepeatMode && isOpen && renderRepeatMode()}
{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( + (global): Complete => { + const isCurrentUserPremium = selectIsCurrentUserPremium(global); + + return { + isTestServer: global.config?.isTestServer, + isCurrentUserPremium, + }; + }, +)(CalendarModal)); diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index 5de433aa0..56e6cdad9 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -1079,6 +1079,7 @@ const Composer: FC = ({ sendGrouped = attachmentSettings.shouldSendGrouped, isSilent, scheduledAt, + scheduleRepeatPeriod, isInvertedMedia, }: { attachments: ApiAttachment[]; @@ -1086,6 +1087,7 @@ const Composer: FC = ({ sendGrouped?: boolean; isSilent?: boolean; scheduledAt?: number; + scheduleRepeatPeriod?: number; isInvertedMedia?: true; }) => { if (!currentMessageList && !storyId) { @@ -1110,6 +1112,7 @@ const Composer: FC = ({ text, entities, scheduledAt, + scheduleRepeatPeriod, isSilent, shouldUpdateStickerSetOrder, attachments: prepareAttachmentsToSend(attachmentsToSend, sendCompressed), @@ -1159,6 +1162,7 @@ const Composer: FC = ({ isSilent?: boolean, scheduledAt?: number, isInvertedMedia?: true, + scheduleRepeatPeriod?: number, ) => { if (canSendAttachments(attachments)) { sendAttachments({ @@ -1167,13 +1171,19 @@ const Composer: FC = ({ 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 = ({ sendAttachments({ attachments: currentAttachments, scheduledAt, + scheduleRepeatPeriod, isSilent, }); } @@ -1209,6 +1220,7 @@ const Composer: FC = ({ text, entities, scheduledAt, + scheduleRepeatPeriod, isSilent, shouldUpdateStickerSetOrder, isInvertedMedia, @@ -1235,7 +1247,11 @@ const Composer: FC = ({ }, ); - 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 = ({ } } - 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 = ({ }); 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ isSilent = isSilent || isSilentPosting; if (isInScheduledList || isScheduleRequested) { - requestCalendar((scheduledAt) => { + requestCalendar((scheduledAt, scheduleRepeatPeriod) => { handleActionWithPaymentConfirmation( handleMessageSchedule, { @@ -1476,6 +1512,7 @@ const Composer: FC = ({ isSilent, }, scheduledAt, + scheduleRepeatPeriod, currentMessageList!, ); }); @@ -1516,11 +1553,12 @@ const Composer: FC = ({ } if (isInScheduledList) { - requestCalendar((scheduledAt) => { + requestCalendar((scheduledAt, scheduleRepeatPeriod) => { handleActionWithPaymentConfirmation( handleMessageSchedule, { poll }, scheduledAt, + scheduleRepeatPeriod, currentMessageList, ); }); @@ -1540,11 +1578,12 @@ const Composer: FC = ({ } if (isInScheduledList) { - requestCalendar((scheduledAt) => { + requestCalendar((scheduledAt, scheduleRepeatPeriod) => { handleActionWithPaymentConfirmation( handleMessageSchedule, { todo }, scheduledAt, + scheduleRepeatPeriod, currentMessageList, ); }); @@ -1558,8 +1597,13 @@ const Composer: FC = ({ 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 = ({ 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 = ({ }); const handleSendScheduled = useLastCallback(() => { - requestCalendar((scheduledAt) => { - handleMessageSchedule({}, scheduledAt, currentMessageList!); + requestCalendar((scheduledAt, scheduleRepeatPeriod) => { + handleMessageSchedule({}, scheduledAt, scheduleRepeatPeriod, currentMessageList!, undefined); }); }); @@ -1881,18 +1925,20 @@ const Composer: FC = ({ 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, ); }); }, diff --git a/src/components/common/StickerSetModal.tsx b/src/components/common/StickerSetModal.tsx index f31e0243c..2541642bc 100644 --- a/src/components/common/StickerSetModal.tsx +++ b/src/components/common/StickerSetModal.tsx @@ -125,9 +125,9 @@ const StickerSetModal: FC = ({ }; if (shouldSchedule || isScheduleRequested) { - requestCalendar((scheduledAt) => { + requestCalendar((scheduledAt, scheduleRepeatPeriod) => { sendMessage({ - messageList: currentMessageList, sticker, isSilent, scheduledAt, + messageList: currentMessageList, sticker, isSilent, scheduledAt, scheduleRepeatPeriod, }); onClose(); }); diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index 38aed0938..7f99184d2 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -287,7 +287,12 @@ const ContextMenuContainer: FC = ({ 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 = ({ closeMenu(); }); - const handleRescheduleMessage = useLastCallback((scheduledAt: number) => { + const handleRescheduleMessage = useLastCallback(( + scheduledAt: number, + scheduleRepeatPeriod?: number, + ) => { rescheduleMessage({ chatId: message.chatId, messageId: message.id, scheduledAt, + scheduleRepeatPeriod, }); onClose(); }); diff --git a/src/components/middle/message/MessageMeta.tsx b/src/components/middle/message/MessageMeta.tsx index 4e34818ac..8dab032f8 100644 --- a/src/components/middle/message/MessageMeta.tsx +++ b/src/components/middle/message/MessageMeta.tsx @@ -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 = ({ 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 = ({ 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', diff --git a/src/components/right/GifSearch.tsx b/src/components/right/GifSearch.tsx index 05e78bbc4..36ddd5f6f 100644 --- a/src/components/right/GifSearch.tsx +++ b/src/components/right/GifSearch.tsx @@ -87,11 +87,12 @@ const GifSearch: FC = ({ } if (shouldSchedule) { - requestCalendar((scheduledAt) => { + requestCalendar((scheduledAt, scheduleRepeatPeriod) => { sendMessage({ messageList: currentMessageList, gif, scheduledAt, + scheduleRepeatPeriod, isSilent, }); }); diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index 313b7bd8c..5f37d3e59 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -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 { 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, }; diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 2696a61e3..fa46513a5 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -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; diff --git a/src/hooks/useSchedule.tsx b/src/hooks/useSchedule.tsx index 005dd7a08..6ce1880f6 100644 --- a/src/hooks/useSchedule.tsx +++ b/src/hooks/useSchedule.tsx @@ -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(); - 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 = ( { 'count': V; 'total': V; }; + 'ScheduleRepeat': { + 'value': V; + }; 'MessageTimerShortHours': { 'count': V; }; @@ -3182,12 +3197,24 @@ export interface LangPairPluralWithVariables { 'LastSeenHoursAgo': { 'count': V; }; + 'MessageRepeatPeriodEveryMinutes': { + 'count': V; + }; + 'MessageRepeatPeriodEveryMonths': { + 'count': V; + }; 'StickerPackRemoveStickerCount': { 'count': V; }; 'StickerPackAddStickerCount': { 'count': V; }; + 'ScheduleRepeatEveryMinutes': { + 'count': V; + }; + 'ScheduleRepeatEveryMonths': { + 'count': V; + }; 'Years': { 'count': V; }; diff --git a/src/util/dates/units.ts b/src/util/dates/units.ts index b70d0b4db..e1418fc02 100644 --- a/src/util/dates/units.ts +++ b/src/util/dates/units.ts @@ -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; diff --git a/src/util/scheduledMessages.ts b/src/util/scheduledMessages.ts new file mode 100644 index 000000000..2c7bfae9c --- /dev/null +++ b/src/util/scheduledMessages.ts @@ -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; + +const MODE_TO_SECONDS: Record = { + 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([ + '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'); + } +}