Repeated scheduled messages (reminders).

This commit is contained in:
John Preston
2025-10-07 12:43:41 +04:00
parent c4d5d52b96
commit 332b70a27d
14 changed files with 242 additions and 6 deletions
+10
View File
@@ -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...";
+1
View File
@@ -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),
+8
View File
@@ -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);
+28 -1
View File
@@ -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<HistoryMessageSchedulePeriod>();
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<HistoryMessageSchedulePeriod>()) {
AddComponents(HistoryMessageSchedulePeriod::Bit());
}
const auto period = Get<HistoryMessageSchedulePeriod>();
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<HistoryMessageSaved>()) {
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<HistoryMessageSchedulePeriod>()) {
period->schedulePeriod = config.scheduleRepeatPeriod;
}
if (const auto reply = Get<HistoryMessageReply>()) {
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());
@@ -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;
@@ -869,3 +869,8 @@ private:
mutable int _seekingCurrent = 0;
};
struct HistoryMessageSchedulePeriod
: RuntimeComponent<HistoryMessageSchedulePeriod, HistoryItem> {
TimeId schedulePeriod = 0;
};
@@ -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()),
@@ -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;
@@ -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(
@@ -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<void(Api::SendOptions)> done,
TimeId time,
ScheduleBoxStyleArgs style) {
const auto repeat = (details.type == SendMenu::Type::Reminder)
? std::make_shared<TimeId>(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
@@ -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());
+1 -1
View File
@@ -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); },
@@ -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<QDate> date;
rpl::variable<int> width;
not_null<InputField*> day;
not_null<TimeInput*> time;
not_null<FlatLabel*> at;
@@ -64,7 +67,8 @@ ChooseDateTimeBoxDescriptor ChooseDateTimeBox(
box->setWidth(st::boxWideWidth);
const auto content = box->addRow(
object_ptr<FixedHeightWidget>(box, st::scheduleHeight));
object_ptr<FixedHeightWidget>(box, st::scheduleHeight),
style::al_top);
if (args.description) {
box->addRow(object_ptr<FlatLabel>(
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<Ui::RpWidget> ChooseRepeatPeriod(
not_null<Ui::RpWidget*> parent,
ChooseRepeatPeriodArgs &&args) {
auto result = object_ptr<Ui::RpWidget>(parent.get());
const auto raw = result.data();
struct Entry {
TimeId value = 0;
QString text;
};
auto map = std::vector<Entry>{
{ 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<Ui::FlatLabel>(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<TimeId> value;
rpl::variable<bool> locked;
std::unique_ptr<Ui::PopupMenu> menu;
};
const auto state = raw->lifetime().make_state<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<Ui::PopupMenu>(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
@@ -23,6 +23,7 @@ struct ChooseDateTimeBoxDescriptor {
QPointer<RoundButton> submit;
Fn<TimeId()> collect;
rpl::producer<TimeId> values;
rpl::producer<int> width;
};
struct ChooseDateTimeStyleArgs {
@@ -50,4 +51,16 @@ ChooseDateTimeBoxDescriptor ChooseDateTimeBox(
not_null<GenericBox*> box,
ChooseDateTimeBoxArgs &&args);
struct ChooseRepeatPeriodArgs {
TimeId value = 0;
rpl::variable<bool> locked;
Fn<bool()> filter;
Fn<void(TimeId)> changed;
bool test = false;
};
[[nodiscard]] object_ptr<Ui::RpWidget> ChooseRepeatPeriod(
not_null<Ui::RpWidget*> parent,
ChooseRepeatPeriodArgs &&args);
} // namespace Ui