/* This file is part of Telegram Desktop, the official desktop application for the Telegram messaging service. For license and copyright information please follow this link: https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "boxes/peers/edit_participant_box.h" #include "lang/lang_keys.h" #include "ui/controls/userpic_button.h" #include "ui/wrap/vertical_layout.h" #include "ui/wrap/padding_wrap.h" #include "ui/wrap/slide_wrap.h" #include "ui/widgets/checkbox.h" #include "ui/widgets/labels.h" #include "ui/widgets/buttons.h" #include "ui/widgets/fields/input_field.h" #include "ui/widgets/box_content_divider.h" #include "ui/layers/generic_box.h" #include "ui/toast/toast.h" #include "ui/text/text_utilities.h" #include "ui/text/text_options.h" #include "ui/painter.h" #include "chat_helpers/emoji_suggestions_widget.h" #include "settings/settings_privacy_security.h" #include "ui/boxes/choose_date_time.h" #include "ui/boxes/confirm_box.h" #include "boxes/passcode_box.h" #include "boxes/peers/add_bot_to_chat_box.h" #include "boxes/peers/edit_peer_permissions_box.h" #include "boxes/peers/edit_peer_info_box.h" #include "data/data_peer_values.h" #include "data/data_channel.h" #include "data/data_chat.h" #include "data/data_user.h" #include "core/core_cloud_password.h" #include "base/unixtime.h" #include "apiwrap.h" #include "api/api_cloud_password.h" #include "main/main_session.h" #include "styles/style_layers.h" #include "styles/style_boxes.h" #include "styles/style_info.h" namespace { constexpr auto kMaxRestrictDelayDays = 366; constexpr auto kSecondsInDay = 24 * 60 * 60; constexpr auto kSecondsInWeek = 7 * kSecondsInDay; constexpr auto kAdminRoleLimit = 16; } // namespace class EditParticipantBox::Inner : public Ui::RpWidget { public: Inner( QWidget *parent, not_null peer, not_null user, bool hasAdminRights); template Widget *addControl(object_ptr widget, QMargins margin); protected: int resizeGetHeight(int newWidth) override; void paintEvent(QPaintEvent *e) override; private: not_null _peer; not_null _user; object_ptr _userPhoto; Ui::Text::String _userName; bool _hasAdminRights = false; object_ptr _rows; }; EditParticipantBox::Inner::Inner( QWidget *parent, not_null peer, not_null user, bool hasAdminRights) : RpWidget(parent) , _peer(peer) , _user(user) , _userPhoto(this, _user, st::rightsPhotoButton) , _hasAdminRights(hasAdminRights) , _rows(this) { _rows->heightValue( ) | rpl::start_with_next([=] { resizeToWidth(width()); }, lifetime()); _userPhoto->setAttribute(Qt::WA_TransparentForMouseEvents); _userName.setText( st::rightsNameStyle, _user->name(), Ui::NameTextOptions()); } template Widget *EditParticipantBox::Inner::addControl( object_ptr widget, QMargins margin) { return _rows->add(std::move(widget), margin); } int EditParticipantBox::Inner::resizeGetHeight(int newWidth) { _userPhoto->moveToLeft( st::rightsPhotoMargin.left(), st::rightsPhotoMargin.top()); const auto rowsTop = st::rightsPhotoMargin.top() + st::rightsPhotoButton.size.height() + st::rightsPhotoMargin.bottom(); _rows->resizeToWidth(newWidth); _rows->moveToLeft(0, rowsTop, newWidth); return rowsTop + _rows->heightNoMargins(); } void EditParticipantBox::Inner::paintEvent(QPaintEvent *e) { Painter p(this); p.fillRect(e->rect(), st::boxBg); p.setPen(st::contactsNameFg); auto namex = st::rightsPhotoMargin.left() + st::rightsPhotoButton.size .width() + st::rightsPhotoMargin.right(); auto namew = width() - namex - st::rightsPhotoMargin.right(); _userName.drawLeftElided( p, namex, st::rightsPhotoMargin.top() + st::rightsNameTop, namew, width()); const auto statusText = [&] { if (_user->isBot()) { const auto seesAllMessages = _user->botInfo->readsAllHistory || _hasAdminRights; return (seesAllMessages ? tr::lng_status_bot_reads_all : tr::lng_status_bot_not_reads_all)(tr::now); } return Data::OnlineText(_user->lastseen(), base::unixtime::now()); }(); p.setFont(st::contactsStatusFont); p.setPen(st::contactsStatusFg); p.drawTextLeft( namex, st::rightsPhotoMargin.top() + st::rightsStatusTop, width(), statusText); } EditParticipantBox::EditParticipantBox( QWidget*, not_null peer, not_null user, bool hasAdminRights) : _peer(peer) , _user(user) , _hasAdminRights(hasAdminRights) { } void EditParticipantBox::prepare() { _inner = setInnerWidget(object_ptr( this, _peer, _user, hasAdminRights())); setDimensionsToContent(st::boxWideWidth, _inner); } template Widget *EditParticipantBox::addControl( object_ptr widget, QMargins margin) { Expects(_inner != nullptr); return _inner->addControl(std::move(widget), margin); } bool EditParticipantBox::amCreator() const { if (const auto chat = _peer->asChat()) { return chat->amCreator(); } else if (const auto channel = _peer->asChannel()) { return channel->amCreator(); } Unexpected("Peer type in EditParticipantBox::Inner::amCreator."); } EditAdminBox::EditAdminBox( QWidget*, not_null peer, not_null user, ChatAdminRightsInfo rights, const QString &rank, std::optional addingBot) : EditParticipantBox( nullptr, peer, user, (rights.flags != 0)) , _oldRights(rights) , _oldRank(rank) , _addingBot(std::move(addingBot)) { } ChatAdminRightsInfo EditAdminBox::defaultRights() const { using Flag = ChatAdminRight; return peer()->isChat() ? peer()->asChat()->defaultAdminRights(user()) : peer()->isMegagroup() ? ChatAdminRightsInfo{ (Flag::ChangeInfo | Flag::DeleteMessages | Flag::BanUsers | Flag::InviteByLinkOrAdd | Flag::ManageTopics | Flag::PinMessages | Flag::ManageCall) } : ChatAdminRightsInfo{ (Flag::ChangeInfo | Flag::PostMessages | Flag::EditMessages | Flag::DeleteMessages | Flag::InviteByLinkOrAdd | Flag::ManageCall) }; } void EditAdminBox::prepare() { using namespace rpl::mappers; using Flag = ChatAdminRight; using Flags = ChatAdminRights; EditParticipantBox::prepare(); setTitle(_addingBot ? (_addingBot->existing ? tr::lng_rights_edit_admin() : tr::lng_bot_add_title()) : _oldRights.flags ? tr::lng_rights_edit_admin() : tr::lng_channel_add_admin()); if (_addingBot && !_addingBot->existing && !peer()->isBroadcast() && _saveCallback) { addControl( object_ptr(this), st::rightsDividerMargin / 2); _addAsAdmin = addControl( object_ptr( this, tr::lng_bot_as_admin_check(tr::now), st::rightsCheckbox, std::make_unique( st::rightsToggle, true)), st::rightsToggleMargin + (st::rightsDividerMargin / 2)); _addAsAdmin->checkedChanges( ) | rpl::start_with_next([=](bool checked) { _adminControlsWrap->toggle(checked, anim::type::normal); refreshButtons(); }, _addAsAdmin->lifetime()); } _adminControlsWrap = addControl( object_ptr>( this, object_ptr(this))); const auto inner = _adminControlsWrap->entity(); inner->add( object_ptr(inner), st::rightsDividerMargin); const auto chat = peer()->asChat(); const auto channel = peer()->asChannel(); const auto prepareRights = _addingBot ? ChatAdminRightsInfo(_oldRights.flags | _addingBot->existing) : _oldRights.flags ? _oldRights : defaultRights(); const auto disabledByDefaults = (channel && !channel->isMegagroup()) ? ChatAdminRights() : DisabledByDefaultRestrictions(peer()); const auto filterByMyRights = canSave() && !_oldRights.flags && channel && !channel->amCreator(); const auto prepareFlags = disabledByDefaults | (prepareRights.flags & (filterByMyRights ? channel->adminRights() : ~Flag(0))); const auto disabledMessages = [&] { auto result = base::flat_map(); if (!canSave()) { result.emplace( ~Flags(0), tr::lng_rights_about_admin_cant_edit(tr::now)); } else { result.emplace( disabledByDefaults, tr::lng_rights_permission_for_all(tr::now)); if (amCreator() && user()->isSelf()) { result.emplace( ~Flag::Anonymous, tr::lng_rights_permission_cant_edit(tr::now)); } else if (const auto channel = peer()->asChannel()) { if (!channel->amCreator()) { result.emplace( ~channel->adminRights(), tr::lng_rights_permission_cant_edit(tr::now)); } } } return result; }(); const auto isGroup = chat || channel->isMegagroup(); const auto anyoneCanAddMembers = chat ? chat->anyoneCanAddMembers() : channel->anyoneCanAddMembers(); const auto options = Data::AdminRightsSetOptions{ .isGroup = isGroup, .isForum = peer()->isForum(), .anyoneCanAddMembers = anyoneCanAddMembers, }; auto [checkboxes, getChecked, changes] = CreateEditAdminRights( inner, tr::lng_rights_edit_admin_header(), prepareFlags, disabledMessages, options); inner->add(std::move(checkboxes), QMargins()); auto selectedFlags = rpl::single( getChecked() ) | rpl::then(std::move( changes )); _aboutAddAdmins = inner->add( object_ptr(inner, st::boxDividerLabel), st::rightsAboutMargin); rpl::duplicate( selectedFlags ) | rpl::map( (_1 & Flag::AddAdmins) != 0 ) | rpl::distinct_until_changed( ) | rpl::start_with_next([=](bool checked) { refreshAboutAddAdminsText(checked); }, lifetime()); if (canTransferOwnership()) { const auto allFlags = AdminRightsForOwnershipTransfer(options); setupTransferButton( inner, isGroup )->toggleOn(rpl::duplicate( selectedFlags ) | rpl::map( ((_1 & allFlags) == allFlags) ))->setDuration(0); } if (canSave()) { _rank = (chat || channel->isMegagroup()) ? addRankInput(inner).get() : nullptr; _finishSave = [=, value = getChecked] { const auto newFlags = (value() | ChatAdminRight::Other) & ((!channel || channel->amCreator()) ? ~Flags(0) : channel->adminRights()); _saveCallback( _oldRights, ChatAdminRightsInfo(newFlags), _rank ? _rank->getLastText().trimmed() : QString()); }; _save = [=] { if (!_saveCallback) { return; } else if (_addAsAdmin && !_addAsAdmin->checked()) { const auto weak = Ui::MakeWeak(this); AddBotToGroup(user(), peer(), _addingBot->token); if (const auto strong = weak.data()) { strong->closeBox(); } return; } else if (_addingBot && !_addingBot->existing) { const auto phrase = peer()->isBroadcast() ? tr::lng_bot_sure_add_text_channel : tr::lng_bot_sure_add_text_group; _confirmBox = getDelegate()->show(Ui::MakeConfirmBox({ phrase( tr::now, lt_group, Ui::Text::Bold(peer()->name()), Ui::Text::WithEntities), crl::guard(this, [=] { finishAddAdmin(); }) })); } else { _finishSave(); } }; } refreshButtons(); } void EditAdminBox::finishAddAdmin() { _finishSave(); if (_confirmBox) { _confirmBox->closeBox(); } } void EditAdminBox::refreshButtons() { clearButtons(); if (canSave()) { addButton((!_addingBot || _addingBot->existing) ? tr::lng_settings_save() : _adminControlsWrap->toggled() ? tr::lng_bot_add_as_admin() : tr::lng_bot_add_as_member(), _save); addButton(tr::lng_cancel(), [=] { closeBox(); }); } else { addButton(tr::lng_box_ok(), [=] { closeBox(); }); } } not_null EditAdminBox::addRankInput( not_null container) { container->add( object_ptr(container), st::rightsRankMargin); container->add( object_ptr( container, tr::lng_rights_edit_admin_rank_name(), st::rightsHeaderLabel), st::rightsHeaderMargin); const auto isOwner = [&] { if (user()->isSelf() && amCreator()) { return true; } else if (const auto chat = peer()->asChat()) { return chat->creator == peerToUser(user()->id); } else if (const auto channel = peer()->asChannel()) { return channel->mgInfo && channel->mgInfo->creator == user(); } Unexpected("Peer type in EditAdminBox::addRankInput."); }(); const auto result = container->add( object_ptr( container, st::customBadgeField, (isOwner ? tr::lng_owner_badge : tr::lng_admin_badge)(), TextUtilities::RemoveEmoji(_oldRank)), st::rightsAboutMargin); result->setMaxLength(kAdminRoleLimit); result->setInstantReplaces(Ui::InstantReplaces::TextOnly()); result->changes( ) | rpl::start_with_next([=] { const auto text = result->getLastText(); const auto removed = TextUtilities::RemoveEmoji(text); if (removed != text) { result->setText(removed); } }, result->lifetime()); container->add( object_ptr( container, tr::lng_rights_edit_admin_rank_about( lt_title, (isOwner ? tr::lng_owner_badge : tr::lng_admin_badge)()), st::boxDividerLabel), st::rightsAboutMargin); return result; } bool EditAdminBox::canTransferOwnership() const { if (user()->isInaccessible() || user()->isBot() || user()->isSelf()) { return false; } else if (const auto chat = peer()->asChat()) { return chat->amCreator(); } else if (const auto channel = peer()->asChannel()) { return channel->amCreator(); } Unexpected("Chat type in EditAdminBox::canTransferOwnership."); } not_null*> EditAdminBox::setupTransferButton( not_null container, bool isGroup) { const auto wrap = container->add( object_ptr>( container, object_ptr(container))); const auto inner = wrap->entity(); inner->add( object_ptr(inner), { 0, st::infoProfileSkip, 0, st::infoProfileSkip }); inner->add(EditPeerInfoBox::CreateButton( inner, (isGroup ? tr::lng_rights_transfer_group : tr::lng_rights_transfer_channel)(), rpl::single(QString()), [=] { transferOwnership(); }, st::peerPermissionsButton, {})); return wrap; } void EditAdminBox::transferOwnership() { if (_checkTransferRequestId) { return; } const auto channel = peer()->isChannel() ? peer()->asChannel()->inputChannel : MTP_inputChannelEmpty(); const auto api = &peer()->session().api(); api->cloudPassword().reload(); _checkTransferRequestId = api->request(MTPchannels_EditCreator( channel, MTP_inputUserEmpty(), MTP_inputCheckPasswordEmpty() )).fail([=](const MTP::Error &error) { _checkTransferRequestId = 0; if (!handleTransferPasswordError(error.type())) { const auto callback = crl::guard(this, [=](Fn &&close) { transferOwnershipChecked(); close(); }); getDelegate()->show(Ui::MakeConfirmBox({ .text = tr::lng_rights_transfer_about( tr::now, lt_group, Ui::Text::Bold(peer()->name()), lt_user, Ui::Text::Bold(user()->shortName()), Ui::Text::RichLangValue), .confirmed = callback, .confirmText = tr::lng_rights_transfer_sure(), })); } }).send(); } bool EditAdminBox::handleTransferPasswordError(const QString &error) { const auto session = &user()->session(); auto about = tr::lng_rights_transfer_check_about( tr::now, lt_user, Ui::Text::Bold(user()->shortName()), Ui::Text::WithEntities); if (auto box = PrePasswordErrorBox(error, session, std::move(about))) { getDelegate()->show(std::move(box)); return true; } return false; } void EditAdminBox::transferOwnershipChecked() { if (const auto chat = peer()->asChatNotMigrated()) { peer()->session().api().migrateChat(chat, crl::guard(this, [=]( not_null channel) { requestTransferPassword(channel); })); } else if (const auto channel = peer()->asChannelOrMigrated()) { requestTransferPassword(channel); } else { Unexpected("Peer in SaveAdminCallback."); } } void EditAdminBox::requestTransferPassword(not_null channel) { peer()->session().api().cloudPassword().state( ) | rpl::take( 1 ) | rpl::start_with_next([=](const Core::CloudPasswordState &state) { const auto box = std::make_shared>(); auto fields = PasscodeBox::CloudFields::From(state); fields.customTitle = tr::lng_rights_transfer_password_title(); fields.customDescription = tr::lng_rights_transfer_password_description(tr::now); fields.customSubmitButton = tr::lng_passcode_submit(); fields.customCheckCallback = crl::guard(this, [=]( const Core::CloudPasswordResult &result) { sendTransferRequestFrom(*box, channel, result); }); *box = getDelegate()->show(Box( &channel->session(), fields)); }, lifetime()); } void EditAdminBox::sendTransferRequestFrom( QPointer box, not_null channel, const Core::CloudPasswordResult &result) { if (_transferRequestId) { return; } const auto weak = Ui::MakeWeak(this); const auto user = this->user(); const auto api = &channel->session().api(); _transferRequestId = api->request(MTPchannels_EditCreator( channel->inputChannel, user->inputUser, result.result )).done([=](const MTPUpdates &result) { api->applyUpdates(result); if (!box && !weak) { return; } const auto show = box ? box->uiShow() : weak->uiShow(); show->showToast( (channel->isBroadcast() ? tr::lng_rights_transfer_done_channel : tr::lng_rights_transfer_done_group)( tr::now, lt_user, user->shortName())); show->hideLayer(); }).fail(crl::guard(this, [=](const MTP::Error &error) { if (weak) { _transferRequestId = 0; } if (box && box->handleCustomCheckError(error)) { return; } const auto &type = error.type(); const auto problem = [&] { if (type == u"CHANNELS_ADMIN_PUBLIC_TOO_MUCH"_q) { return tr::lng_channels_too_much_public_other(tr::now); } else if (type == u"CHANNELS_ADMIN_LOCATED_TOO_MUCH"_q) { return tr::lng_channels_too_much_located_other(tr::now); } else if (type == u"ADMINS_TOO_MUCH"_q) { return (channel->isBroadcast() ? tr::lng_error_admin_limit_channel : tr::lng_error_admin_limit)(tr::now); } else if (type == u"CHANNEL_INVALID"_q) { return (channel->isBroadcast() ? tr::lng_channel_not_accessible : tr::lng_group_not_accessible)(tr::now); } return Lang::Hard::ServerError(); }(); const auto recoverable = [&] { return (type == u"PASSWORD_MISSING"_q) || (type == u"PASSWORD_TOO_FRESH_XXX"_q) || (type == u"SESSION_TOO_FRESH_XXX"_q); }(); const auto weak = Ui::MakeWeak(this); getDelegate()->show(Ui::MakeInformBox(problem)); if (box) { box->closeBox(); } if (weak && !recoverable) { closeBox(); } })).handleFloodErrors().send(); } void EditAdminBox::refreshAboutAddAdminsText(bool canAddAdmins) { _aboutAddAdmins->setText([&] { if (amCreator() && user()->isSelf()) { return QString(); } else if (!canSave()) { return tr::lng_rights_about_admin_cant_edit(tr::now); } else if (canAddAdmins) { return tr::lng_rights_about_add_admins_yes(tr::now); } return tr::lng_rights_about_add_admins_no(tr::now); }()); } EditRestrictedBox::EditRestrictedBox( QWidget*, not_null peer, not_null user, bool hasAdminRights, ChatRestrictionsInfo rights) : EditParticipantBox(nullptr, peer, user, hasAdminRights) , _oldRights(rights) { } void EditRestrictedBox::prepare() { using Flag = ChatRestriction; using Flags = ChatRestrictions; EditParticipantBox::prepare(); setTitle(tr::lng_rights_user_restrictions()); addControl( object_ptr(this), st::rightsDividerMargin); const auto chat = peer()->asChat(); const auto channel = peer()->asChannel(); const auto defaultRestrictions = chat ? chat->defaultRestrictions() : channel->defaultRestrictions(); const auto prepareRights = _oldRights.flags ? _oldRights : defaultRights(); const auto prepareFlags = FixDependentRestrictions( prepareRights.flags | defaultRestrictions | ((channel && channel->isPublic()) ? (Flag::ChangeInfo | Flag::PinMessages) : Flags(0))); const auto disabledMessages = [&] { auto result = base::flat_map(); if (!canSave()) { result.emplace( ~Flags(0), tr::lng_rights_about_restriction_cant_edit(tr::now)); } else { const auto disabled = FixDependentRestrictions( defaultRestrictions | ((channel && channel->isPublic()) ? (Flag::ChangeInfo | Flag::PinMessages) : Flags(0))); result.emplace( disabled, tr::lng_rights_restriction_for_all(tr::now)); } return result; }(); auto [checkboxes, getRestrictions, changes] = CreateEditRestrictions( this, tr::lng_rights_user_restrictions_header(), prepareFlags, disabledMessages, { .isForum = peer()->isForum() }); addControl(std::move(checkboxes), QMargins()); _until = prepareRights.until; addControl(object_ptr(this), st::rightsUntilMargin); addControl( object_ptr( this, tr::lng_rights_chat_banned_until_header(tr::now), st::rightsHeaderLabel), st::rightsHeaderMargin); setRestrictUntil(_until); //addControl( // object_ptr( // this, // tr::lng_rights_chat_banned_block(tr::now), // st::boxLinkButton)); if (canSave()) { const auto save = [=, value = getRestrictions] { if (!_saveCallback) { return; } _saveCallback( _oldRights, ChatRestrictionsInfo{ value(), getRealUntilValue() }); }; addButton(tr::lng_settings_save(), save); addButton(tr::lng_cancel(), [=] { closeBox(); }); } else { addButton(tr::lng_box_ok(), [=] { closeBox(); }); } } ChatRestrictionsInfo EditRestrictedBox::defaultRights() const { return ChatRestrictionsInfo(); } void EditRestrictedBox::showRestrictUntil() { uiShow()->showBox(Box([=](not_null box) { const auto save = [=](TimeId result) { if (!result) { return; } setRestrictUntil(result); box->closeBox(); }; const auto now = base::unixtime::now(); const auto time = isUntilForever() ? (now + kSecondsInDay) : getRealUntilValue(); ChooseDateTimeBox(box, { .title = tr::lng_rights_chat_banned_until_header(), .submit = tr::lng_settings_save(), .done = save, .min = [=] { return now; }, .time = time, .max = [=] { return now + kSecondsInDay * kMaxRestrictDelayDays; }, }); })); } void EditRestrictedBox::setRestrictUntil(TimeId until) { _until = until; _untilVariants.clear(); createUntilGroup(); createUntilVariants(); } bool EditRestrictedBox::isUntilForever() const { return ChannelData::IsRestrictedForever(_until); } void EditRestrictedBox::createUntilGroup() { _untilGroup = std::make_shared( isUntilForever() ? 0 : _until); _untilGroup->setChangedCallback([this](int value) { if (value == kUntilCustom) { _untilGroup->setValue(_until); showRestrictUntil(); } else if (_until != value) { _until = value; } }); } void EditRestrictedBox::createUntilVariants() { auto addVariant = [&](int value, const QString &text) { if (!canSave() && _untilGroup->value() != value) { return; } _untilVariants.emplace_back( addControl( object_ptr( this, _untilGroup, value, text, st::defaultCheckbox), st::rightsToggleMargin)); if (!canSave()) { _untilVariants.back()->setDisabled(true); } }; auto addCustomVariant = [&](TimeId until, TimeId from, TimeId to) { if (!ChannelData::IsRestrictedForever(until) && until > from && until <= to) { addVariant( until, tr::lng_rights_chat_banned_custom_date( tr::now, lt_date, langDateTime(base::unixtime::parse(until)))); } }; auto addCurrentVariant = [&](TimeId from, TimeId to) { auto oldUntil = _oldRights.until; if (oldUntil < _until) { addCustomVariant(oldUntil, from, to); } addCustomVariant(_until, from, to); if (oldUntil > _until) { addCustomVariant(oldUntil, from, to); } }; addVariant(0, tr::lng_rights_chat_banned_forever(tr::now)); auto now = base::unixtime::now(); auto nextDay = now + kSecondsInDay; auto nextWeek = now + kSecondsInWeek; addCurrentVariant(0, nextDay); addVariant(kUntilOneDay, tr::lng_rights_chat_banned_day(tr::now, lt_count, 1)); addCurrentVariant(nextDay, nextWeek); addVariant(kUntilOneWeek, tr::lng_rights_chat_banned_week(tr::now, lt_count, 1)); addCurrentVariant(nextWeek, INT_MAX); addVariant(kUntilCustom, tr::lng_rights_chat_banned_custom(tr::now)); } TimeId EditRestrictedBox::getRealUntilValue() const { Expects(_until != kUntilCustom); if (_until == kUntilOneDay) { return base::unixtime::now() + kSecondsInDay; } else if (_until == kUntilOneWeek) { return base::unixtime::now() + kSecondsInWeek; } Assert(_until >= 0); return _until; }