From 332b70a27df437477e8ee3e187414b9896c3065a Mon Sep 17 00:00:00 2001 From: John Preston Date: Tue, 7 Oct 2025 12:43:41 +0400 Subject: [PATCH] Repeated scheduled messages (reminders). --- Telegram/Resources/langs/lang.strings | 10 ++ Telegram/SourceFiles/apiwrap.cpp | 1 + Telegram/SourceFiles/boxes/boxes.style | 8 ++ Telegram/SourceFiles/history/history_item.cpp | 29 ++++- Telegram/SourceFiles/history/history_item.h | 2 + .../history/history_item_components.h | 5 + .../history/history_item_edition.cpp | 1 + .../history/history_item_edition.h | 3 +- .../view/history_view_context_menu.cpp | 3 +- .../view/history_view_schedule_box.cpp | 45 +++++++ .../view/history_view_scheduled_section.cpp | 4 +- Telegram/SourceFiles/menu/menu_send.cpp | 2 +- .../SourceFiles/ui/boxes/choose_date_time.cpp | 122 +++++++++++++++++- .../SourceFiles/ui/boxes/choose_date_time.h | 13 ++ 14 files changed, 242 insertions(+), 6 deletions(-) diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 0baa9ac0c5..6715eb03de 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -4137,6 +4137,16 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_reminder_message" = "Set a reminder"; "lng_schedule_title" = "Send this message on..."; "lng_remind_title" = "Remind me on..."; +"lng_schedule_repeat_label" = "Repeat:"; +"lng_schedule_repeat_never" = "Never"; +"lng_schedule_repeat_daily" = "Daily"; +"lng_schedule_repeat_weekly" = "Weekly"; +"lng_schedule_repeat_monthly" = "Monthly"; +"lng_schedule_repeat_every_month#one" = "Every {count} month"; +"lng_schedule_repeat_every_month#other" = "Every {count} month"; +"lng_schedule_repeat_yearly" = "Yearly"; +"lng_schedule_repeat_promo" = "Subscribe to {link} to schedule repeating messages."; +"lng_schedule_repeat_promo_link" = "Telegram Premium"; "lng_schedule_at" = "at"; "lng_message_ph" = "Write a message..."; "lng_broadcast_ph" = "Broadcast a message..."; diff --git a/Telegram/SourceFiles/apiwrap.cpp b/Telegram/SourceFiles/apiwrap.cpp index 81a84b77cb..30592eb60f 100644 --- a/Telegram/SourceFiles/apiwrap.cpp +++ b/Telegram/SourceFiles/apiwrap.cpp @@ -4100,6 +4100,7 @@ void ApiWrap::sendMessage( .from = NewMessageFromId(action), .replyTo = action.replyTo, .date = NewMessageDate(action.options), + .scheduleRepeatPeriod = action.options.scheduleRepeatPeriod, .shortcutId = action.options.shortcutId, .starsPaid = starsPaid, .postAuthor = NewMessagePostAuthor(action), diff --git a/Telegram/SourceFiles/boxes/boxes.style b/Telegram/SourceFiles/boxes/boxes.style index 8e91828fbd..883e99a414 100644 --- a/Telegram/SourceFiles/boxes/boxes.style +++ b/Telegram/SourceFiles/boxes/boxes.style @@ -929,6 +929,14 @@ scheduleTimeSeparator: FlatLabel(defaultFlatLabel) { } } scheduleTimeSeparatorPadding: margins(2px, 0px, 2px, 0px); +scheduleRepeatDropdownLock: IconEmoji { + icon: icon {{ "emoji/premium_lock", windowActiveTextFg }}; + padding: margins(-2px, 1px, 0px, 0px); +} +scheduleRepeatDropdownArrow: IconEmoji { + icon: icon {{ "intro_country_dropdown", windowActiveTextFg }}; + padding: margins(3px, 6px, 3px, 0px); +} muteBoxTimeField: InputField(scheduleDateField) { textMargins: margins(0px, 0px, 0px, 0px); diff --git a/Telegram/SourceFiles/history/history_item.cpp b/Telegram/SourceFiles/history/history_item.cpp index 58fec04dd2..ee980ad864 100644 --- a/Telegram/SourceFiles/history/history_item.cpp +++ b/Telegram/SourceFiles/history/history_item.cpp @@ -187,6 +187,7 @@ struct HistoryItem::CreateConfig { bool savedFromOutgoing = false; TimeId editDate = 0; + TimeId scheduleRepeatPeriod = 0; HistoryMessageMarkupData markup; HistoryMessageRepliesData replies; HistoryMessageSuggestInfo suggest; @@ -404,6 +405,7 @@ HistoryItem::HistoryItem( .flags = FlagsFromMTP(id, data.vflags().v, localFlags), .from = data.vfrom_id() ? peerFromMTP(*data.vfrom_id()) : PeerId(0), .date = data.vdate().v, + .scheduleRepeatPeriod = data.vschedule_repeat_period().value_or_empty(), .shortcutId = data.vquick_reply_shortcut_id().value_or_empty(), .starsPaid = int(data.vpaid_message_stars().value_or_empty()), .effectId = data.veffect().value_or_empty(), @@ -1847,6 +1849,11 @@ bool HistoryItem::isScheduled() const { && (_flags & MessageFlag::IsOrWasScheduled); } +TimeId HistoryItem::scheduleRepeatPeriod() const { + const auto period = Get(); + return period ? period->schedulePeriod : TimeId(); +} + bool HistoryItem::isSponsored() const { return _flags & MessageFlag::Sponsored; } @@ -2042,6 +2049,16 @@ void HistoryItem::applyEdition(HistoryMessageEdition &&edition) { } } + if (edition.repeatPeriod) { + if (!Has()) { + AddComponents(HistoryMessageSchedulePeriod::Bit()); + } + const auto period = Get(); + period->schedulePeriod = edition.repeatPeriod; + } else { + RemoveComponents(HistoryMessageSchedulePeriod::Bit()); + } + applyTTL(edition.ttl); setFactcheck(FromMTP(this, edition.mtpFactcheck)); @@ -3813,7 +3830,7 @@ bool HistoryItem::isEmpty() const { } Data::SavedSublist *HistoryItem::savedSublist() const { - if (isBusinessShortcut()) { + if (isBusinessShortcut() || isScheduled()) { return nullptr; } else if (const auto saved = Get()) { if (saved->savedMessagesSublist) { @@ -4039,8 +4056,12 @@ void HistoryItem::createComponents(CreateConfig &&config) { } else if (config.inlineMarkup) { mask |= HistoryMessageReplyMarkup::Bit(); } + if (config.scheduleRepeatPeriod) { + mask |= HistoryMessageSchedulePeriod::Bit(); + } const auto requiresMonoforumPeer = _history->peer->amMonoforumAdmin(); if (!isBusinessShortcut() + && !isScheduled() && (_history->peer->isSelf() || config.savedSublistPeer || requiresMonoforumPeer)) { @@ -4088,6 +4109,9 @@ void HistoryItem::createComponents(CreateConfig &&config) { } } + if (const auto period = Get()) { + period->schedulePeriod = config.scheduleRepeatPeriod; + } if (const auto reply = Get()) { reply->set(std::move(config.reply)); reply->updateData(this); @@ -4305,6 +4329,7 @@ void HistoryItem::createComponentsHelper(HistoryItemCommonFields &&fields) { const auto &replyTo = fields.replyTo; auto config = CreateConfig(); config.viaBotId = fields.viaBotId; + config.scheduleRepeatPeriod = fields.scheduleRepeatPeriod; if (fields.flags & MessageFlag::HasReplyInfo) { config.reply.messageId = replyTo.messageId.msg; config.reply.storyId = replyTo.storyId.story; @@ -4482,6 +4507,8 @@ void HistoryItem::createComponents(const MTPDmessage &data) { : HistoryMessageRepliesData(data.vreplies()); config.markup = HistoryMessageMarkupData(data.vreply_markup()); config.editDate = data.vedit_date().value_or_empty(); + config.scheduleRepeatPeriod + = data.vschedule_repeat_period().value_or_empty(); config.postAuthor = qs(data.vpost_author().value_or_empty()); config.restrictions = Data::UnavailableReason::Extract( data.vrestriction_reason()); diff --git a/Telegram/SourceFiles/history/history_item.h b/Telegram/SourceFiles/history/history_item.h index 30da3fcd43..a5aa1d4788 100644 --- a/Telegram/SourceFiles/history/history_item.h +++ b/Telegram/SourceFiles/history/history_item.h @@ -81,6 +81,7 @@ struct HistoryItemCommonFields { PeerId from = 0; FullReplyTo replyTo; TimeId date = 0; + TimeId scheduleRepeatPeriod = 0; BusinessShortcutId shortcutId = 0; int starsPaid = 0; UserId viaBotId = 0; @@ -193,6 +194,7 @@ public: [[nodiscard]] bool isAdminLogEntry() const; [[nodiscard]] bool isFromScheduled() const; [[nodiscard]] bool isScheduled() const; + [[nodiscard]] TimeId scheduleRepeatPeriod() const; [[nodiscard]] bool isSponsored() const; [[nodiscard]] bool canLookupMessageAuthor() const; [[nodiscard]] bool skipNotification() const; diff --git a/Telegram/SourceFiles/history/history_item_components.h b/Telegram/SourceFiles/history/history_item_components.h index 6b1cfbb495..354c8e3c96 100644 --- a/Telegram/SourceFiles/history/history_item_components.h +++ b/Telegram/SourceFiles/history/history_item_components.h @@ -869,3 +869,8 @@ private: mutable int _seekingCurrent = 0; }; + +struct HistoryMessageSchedulePeriod +: RuntimeComponent { + TimeId schedulePeriod = 0; +}; diff --git a/Telegram/SourceFiles/history/history_item_edition.cpp b/Telegram/SourceFiles/history/history_item_edition.cpp index 968a429c76..ef67bf603e 100644 --- a/Telegram/SourceFiles/history/history_item_edition.cpp +++ b/Telegram/SourceFiles/history/history_item_edition.cpp @@ -16,6 +16,7 @@ HistoryMessageEdition::HistoryMessageEdition( : suggest(HistoryMessageSuggestInfo(message.vsuggested_post())) { isEditHide = message.is_edit_hide(); isMediaUnread = message.is_media_unread(); + repeatPeriod = message.vschedule_repeat_period().value_or_empty(); editDate = message.vedit_date().value_or(-1); textWithEntities = TextWithEntities{ qs(message.vmessage()), diff --git a/Telegram/SourceFiles/history/history_item_edition.h b/Telegram/SourceFiles/history/history_item_edition.h index 8d0cd03aa2..49efa90848 100644 --- a/Telegram/SourceFiles/history/history_item_edition.h +++ b/Telegram/SourceFiles/history/history_item_edition.h @@ -21,7 +21,8 @@ struct HistoryMessageEdition { bool isEditHide = false; bool isMediaUnread = false; - int editDate = 0; + TimeId repeatPeriod = 0; + TimeId editDate = 0; int views = -1; int forwards = -1; int ttl = 0; diff --git a/Telegram/SourceFiles/history/view/history_view_context_menu.cpp b/Telegram/SourceFiles/history/view/history_view_context_menu.cpp index e0f78be3d0..36b0b6ca6a 100644 --- a/Telegram/SourceFiles/history/view/history_view_context_menu.cpp +++ b/Telegram/SourceFiles/history/view/history_view_context_menu.cpp @@ -574,6 +574,7 @@ bool AddRescheduleAction( const auto date = (itemDate == Api::kScheduledUntilOnlineTimestamp) ? HistoryView::DefaultScheduleTime() : itemDate + (firstItem->isScheduled() ? 0 : crl::time(600)); + const auto repeatPeriod = firstItem->scheduleRepeatPeriod(); const auto box = request.navigation->parentController()->show( HistoryView::PrepareScheduleBox( @@ -581,7 +582,7 @@ bool AddRescheduleAction( request.navigation->uiShow(), { .type = sendMenuType, .effectAllowed = false }, callback, - {}, // initial options + { .scheduleRepeatPeriod = repeatPeriod }, date)); owner->itemRemoved( diff --git a/Telegram/SourceFiles/history/view/history_view_schedule_box.cpp b/Telegram/SourceFiles/history/view/history_view_schedule_box.cpp index 1dc8a8b642..6acb06dd38 100644 --- a/Telegram/SourceFiles/history/view/history_view_schedule_box.cpp +++ b/Telegram/SourceFiles/history/view/history_view_schedule_box.cpp @@ -8,18 +8,23 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/view/history_view_schedule_box.h" #include "api/api_common.h" +#include "chat_helpers/compose/compose_show.h" #include "data/data_peer.h" +#include "data/data_peer_values.h" #include "data/data_user.h" #include "lang/lang_keys.h" #include "base/event_filter.h" #include "base/qt/qt_key_modifiers.h" #include "base/unixtime.h" +#include "ui/text/text_utilities.h" #include "ui/widgets/fields/input_field.h" #include "ui/widgets/labels.h" #include "ui/widgets/buttons.h" #include "ui/widgets/popup_menu.h" #include "ui/wrap/padding_wrap.h" +#include "main/main_session.h" #include "menu/menu_send.h" +#include "settings/settings_premium.h" #include "styles/style_info.h" #include "styles/style_layers.h" #include "styles/style_chat.h" @@ -76,6 +81,9 @@ void ScheduleBox( Fn done, TimeId time, ScheduleBoxStyleArgs style) { + const auto repeat = (details.type == SendMenu::Type::Reminder) + ? std::make_shared(initialOptions.scheduleRepeatPeriod) + : nullptr; const auto submit = [=](Api::SendOptions options) { if (!options.scheduled) { return; @@ -84,6 +92,9 @@ void ScheduleBox( if (base::IsCtrlPressed()) { options.silent = true; } + if (repeat) { + options.scheduleRepeatPeriod = *repeat; + } const auto copy = done; box->closeBox(); copy(options); @@ -103,6 +114,40 @@ void ScheduleBox( .style = style.chooseDateTimeArgs, }); + if (repeat) { + const auto showPremiumPromo = [=] { + if (show->session().premium()) { + return false; + } + Settings::ShowPremiumPromoToast( + show, + tr::lng_schedule_repeat_promo( + tr::now, + lt_link, + Ui::Text::Link( + Ui::Text::Bold( + tr::lng_schedule_repeat_promo_link(tr::now))), + Ui::Text::RichLangValue), + u"schedule_repeat"_q); + return true; + }; + auto locked = Data::AmPremiumValue( + &show->session() + ) | rpl::map([=](bool premium) { + return !premium; + }); + const auto row = box->addRow(Ui::ChooseRepeatPeriod(box, { + .value = show->session().premium() ? *repeat : TimeId(), + .locked = std::move(locked), + .filter = showPremiumPromo, + .changed = [=](TimeId value) { *repeat = value; }, + .test = show->session().isTestMode(), + }), style::al_top); + std::move(descriptor.width) | rpl::start_with_next([=](int width) { + row->setNaturalWidth(width); + }, row->lifetime()); + } + using namespace SendMenu; const auto childType = (details.type == Type::Disabled) ? Type::Disabled diff --git a/Telegram/SourceFiles/history/view/history_view_scheduled_section.cpp b/Telegram/SourceFiles/history/view/history_view_scheduled_section.cpp index 27c8d2f975..398e78c6d8 100644 --- a/Telegram/SourceFiles/history/view/history_view_scheduled_section.cpp +++ b/Telegram/SourceFiles/history/view/history_view_scheduled_section.cpp @@ -384,7 +384,9 @@ void ScheduledWidget::setupComposeControls() { if (const auto item = session().data().message(data.fullId)) { if (item->isScheduled()) { const auto spoiler = data.spoilered; - edit(item, data.options, saveEditMsgRequestId, spoiler); + auto &options = data.options; + options.scheduleRepeatPeriod = item->scheduleRepeatPeriod(); + edit(item, options, saveEditMsgRequestId, spoiler); } } }, lifetime()); diff --git a/Telegram/SourceFiles/menu/menu_send.cpp b/Telegram/SourceFiles/menu/menu_send.cpp index 86518f725f..c69aea17e8 100644 --- a/Telegram/SourceFiles/menu/menu_send.cpp +++ b/Telegram/SourceFiles/menu/menu_send.cpp @@ -698,7 +698,7 @@ FillMenuResult FillSendMenu( } if (sending && type != Type::SilentOnly) { menu->addAction( - (type == Type::Reminder + ((type == Type::Reminder) ? tr::lng_reminder_message(tr::now) : tr::lng_schedule_message(tr::now)), [=] { action({ .type = ActionType::Schedule }, details); }, diff --git a/Telegram/SourceFiles/ui/boxes/choose_date_time.cpp b/Telegram/SourceFiles/ui/boxes/choose_date_time.cpp index 0dfc516056..ed233fe09d 100644 --- a/Telegram/SourceFiles/ui/boxes/choose_date_time.cpp +++ b/Telegram/SourceFiles/ui/boxes/choose_date_time.cpp @@ -10,8 +10,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/unixtime.h" #include "base/event_filter.h" #include "ui/boxes/calendar_box.h" +#include "ui/text/text_utilities.h" #include "ui/widgets/buttons.h" #include "ui/widgets/fields/input_field.h" +#include "ui/widgets/popup_menu.h" #include "ui/widgets/time_input.h" #include "ui/ui_utility.h" #include "lang/lang_keys.h" @@ -56,6 +58,7 @@ ChooseDateTimeBoxDescriptor ChooseDateTimeBox( ChooseDateTimeBoxArgs &&args) { struct State { rpl::variable date; + rpl::variable width; not_null day; not_null time; not_null at; @@ -64,7 +67,8 @@ ChooseDateTimeBoxDescriptor ChooseDateTimeBox( box->setWidth(st::boxWideWidth); const auto content = box->addRow( - object_ptr(box, st::scheduleHeight)); + object_ptr(box, st::scheduleHeight), + style::al_top); if (args.description) { box->addRow(object_ptr( box, @@ -125,6 +129,16 @@ ChooseDateTimeBoxDescriptor ChooseDateTimeBox( return base::EventFilterResult::Continue; }); + state->at->widthValue() | rpl::start_with_next([=](int width) { + const auto full = st::scheduleDateWidth + + st::scheduleAtSkip + + width + + st::scheduleAtSkip + + st::scheduleTimeWidth; + content->setNaturalWidth(full); + state->width = full; + }, state->at->lifetime()); + content->widthValue( ) | rpl::start_with_next([=](int width) { const auto paddings = width @@ -200,6 +214,7 @@ ChooseDateTimeBoxDescriptor ChooseDateTimeBox( auto result = ChooseDateTimeBoxDescriptor(); box->setFocusCallback([=] { state->time->setFocusFast(); }); + result.width = state->width.value(); result.submit = box->addButton(std::move(args.submit), save); result.collect = [=] { if (const auto result = collect()) { @@ -217,4 +232,109 @@ ChooseDateTimeBoxDescriptor ChooseDateTimeBox( return result; } +object_ptr ChooseRepeatPeriod( + not_null parent, + ChooseRepeatPeriodArgs &&args) { + auto result = object_ptr(parent.get()); + const auto raw = result.data(); + + struct Entry { + TimeId value = 0; + QString text; + }; + auto map = std::vector{ + { 0, tr::lng_schedule_repeat_never(tr::now) }, + { 24 * 60 * 60, tr::lng_schedule_repeat_daily(tr::now) }, + { 7 * 24 * 60 * 60, tr::lng_schedule_repeat_weekly(tr::now) }, + { 30 * 24 * 60 * 60, tr::lng_schedule_repeat_monthly(tr::now) }, + { + 91 * 24 * 60 * 60, + tr::lng_schedule_repeat_every_month(tr::now, lt_count, 3) + }, + { + 182 * 24 * 60 * 60, + tr::lng_schedule_repeat_every_month(tr::now, lt_count, 6) + }, + { 365 * 24 * 60 * 60, tr::lng_schedule_repeat_yearly(tr::now) }, + }; + if (args.test) { + map.insert(begin(map) + 1, Entry{ 300, u"Every 5 minutes"_q }); + map.insert(begin(map) + 1, Entry{ 60, u"Every minute"_q }); + } + + const auto label = Ui::CreateChild(raw, QString()); + rpl::combine( + raw->widthValue(), + label->naturalWidthValue() + ) | rpl::start_with_next([=](int outer, int natural) { + label->resizeToWidth(std::min(outer, natural)); + }, raw->lifetime()); + label->heightValue() | rpl::start_with_next([=](int height) { + raw->resize(raw->width(), height); + }, label->lifetime()); + + struct State { + rpl::variable value; + rpl::variable locked; + std::unique_ptr menu; + }; + const auto state = raw->lifetime().make_state(State{ + .value = args.value, + .locked = std::move(args.locked), + }); + + rpl::combine( + state->value.value(), + state->locked.value() + ) | rpl::start_with_next([=](TimeId value, bool locked) { + auto result = tr::lng_schedule_repeat_label( + tr::now, + Ui::Text::WithEntities); + + const auto text = [&] { + const auto i = ranges::lower_bound( + map, + value, + ranges::less{}, + &Entry::value); + return (i != end(map)) ? i->text : map.back().text; + }(); + + label->setMarkedText(result.append(' ').append(Ui::Text::Link( + Ui::Text::Bold(text).append( + Ui::Text::IconEmoji(locked + ? &st::scheduleRepeatDropdownLock + : &st::scheduleRepeatDropdownArrow)) + ))); + return result; + }, label->lifetime()); + + label->setClickHandlerFilter([=](const auto &...) { + if (args.filter && args.filter()) { + return false; + } + const auto changed = args.changed; + + state->menu = std::make_unique(label); + const auto menu = state->menu.get(); + + menu->setDestroyedCallback(crl::guard(label, [=] { + if (state->menu.get() == menu) { + state->menu.release(); + } + })); + for (const auto &entry : map) { + const auto value = entry.value; + menu->addAction(entry.text, [=] { + state->value = value; + changed(value); + }); + } + menu->popup(QCursor::pos()); + return false; + }); + + return result; +} + } // namespace Ui diff --git a/Telegram/SourceFiles/ui/boxes/choose_date_time.h b/Telegram/SourceFiles/ui/boxes/choose_date_time.h index 1a12c7ffa1..da2eac33d5 100644 --- a/Telegram/SourceFiles/ui/boxes/choose_date_time.h +++ b/Telegram/SourceFiles/ui/boxes/choose_date_time.h @@ -23,6 +23,7 @@ struct ChooseDateTimeBoxDescriptor { QPointer submit; Fn collect; rpl::producer values; + rpl::producer width; }; struct ChooseDateTimeStyleArgs { @@ -50,4 +51,16 @@ ChooseDateTimeBoxDescriptor ChooseDateTimeBox( not_null box, ChooseDateTimeBoxArgs &&args); +struct ChooseRepeatPeriodArgs { + TimeId value = 0; + rpl::variable locked; + Fn filter; + Fn changed; + bool test = false; +}; + +[[nodiscard]] object_ptr ChooseRepeatPeriod( + not_null parent, + ChooseRepeatPeriodArgs &&args); + } // namespace Ui