/* 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 "settings/business/settings_chat_links.h" #include "api/api_chat_links.h" #include "apiwrap.h" #include "base/event_filter.h" #include "boxes/peers/edit_peer_invite_link.h" #include "boxes/peers/edit_peer_invite_links.h" #include "boxes/premium_preview_box.h" #include "boxes/peer_list_box.h" #include "chat_helpers/emoji_suggestions_widget.h" #include "chat_helpers/message_field.h" #include "chat_helpers/tabbed_panel.h" #include "chat_helpers/tabbed_selector.h" #include "core/application.h" #include "core/ui_integration.h" #include "core/core_settings.h" #include "data/stickers/data_custom_emoji.h" #include "data/data_document.h" #include "data/data_user.h" #include "lang/lang_keys.h" #include "main/main_app_config.h" #include "main/main_session.h" #include "settings/business/settings_recipients_helper.h" #include "ui/boxes/confirm_box.h" #include "ui/controls/emoji_button.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/wrap/slide_wrap.h" #include "ui/wrap/vertical_layout.h" #include "ui/painter.h" #include "ui/rect.h" #include "ui/vertical_list.h" #include "window/window_session_controller.h" #include "styles/style_chat.h" #include "styles/style_chat_helpers.h" #include "styles/style_info.h" #include "styles/style_layers.h" #include "styles/style_menu_icons.h" #include "styles/style_settings.h" #include namespace Settings { namespace { constexpr auto kChangesDebounceTimeout = crl::time(1000); using ChatLinkData = Api::ChatLink; class ChatLinks final : public BusinessSection { public: ChatLinks( QWidget *parent, not_null controller); ~ChatLinks(); [[nodiscard]] rpl::producer title() override; const Ui::RoundRect *bottomSkipRounding() const override { return &_bottomSkipRounding; } private: void setupContent(not_null controller); Ui::RoundRect _bottomSkipRounding; }; struct ChatLinkAction { enum class Type { Copy, Share, Rename, Delete, }; QString link; Type type = Type::Copy; }; class Row; class RowDelegate { public: virtual not_null rowSession() = 0; virtual void rowUpdateRow(not_null row) = 0; virtual void rowPaintIcon( QPainter &p, int x, int y, int size) = 0; }; class Row final : public PeerListRow { public: Row(not_null delegate, const ChatLinkData &data); void update(const ChatLinkData &data); [[nodiscard]] ChatLinkData data() const; QString generateName() override; QString generateShortName() override; PaintRoundImageCallback generatePaintUserpicCallback( bool forceRound) override; QSize rightActionSize() const override; QMargins rightActionMargins() const override; void rightActionPaint( Painter &p, int x, int y, int outerWidth, bool selected, bool actionSelected) override; bool rightActionDisabled() const override { return true; } void paintStatusText( Painter &p, const style::PeerListItem &st, int x, int y, int availableWidth, int outerWidth, bool selected) override; private: void updateStatus(const ChatLinkData &data); const not_null _delegate; ChatLinkData _data; Ui::Text::String _status; Ui::Text::String _clicks; }; [[nodiscard]] uint64 ComputeRowId(const ChatLinkData &data) { return UniqueRowIdFromString(data.link); } [[nodiscard]] QString ComputeClicks(const ChatLinkData &link) { return link.clicks ? tr::lng_chat_links_clicks(tr::now, lt_count, link.clicks) : tr::lng_chat_links_no_clicks(tr::now); } Row::Row(not_null delegate, const ChatLinkData &data) : PeerListRow(ComputeRowId(data)) , _delegate(delegate) , _data(data) { setCustomStatus(QString()); updateStatus(data); } void Row::updateStatus(const ChatLinkData &data) { const auto context = Core::MarkedTextContext{ .session = _delegate->rowSession(), .customEmojiRepaint = [=] { _delegate->rowUpdateRow(this); }, }; _status.setMarkedText( st::messageTextStyle, data.message, kMarkupTextOptions, context); _clicks.setText(st::messageTextStyle, ComputeClicks(data)); } void Row::update(const ChatLinkData &data) { _data = data; updateStatus(data); refreshName(st::inviteLinkList.item); _delegate->rowUpdateRow(this); } ChatLinkData Row::data() const { return _data; } QString Row::generateName() { if (!_data.title.isEmpty()) { return _data.title; } auto result = _data.link; return result.replace( u"https://"_q, QString() ); } QString Row::generateShortName() { return generateName(); } PaintRoundImageCallback Row::generatePaintUserpicCallback(bool forceRound) { return [=]( QPainter &p, int x, int y, int outerWidth, int size) { _delegate->rowPaintIcon(p, x, y, size); }; } QSize Row::rightActionSize() const { return QSize( _clicks.maxWidth(), st::inviteLinkThreeDotsIcon.height()); } QMargins Row::rightActionMargins() const { return QMargins( 0, (st::inviteLinkList.item.height - rightActionSize().height()) / 2, st::inviteLinkThreeDotsSkip, 0); } void Row::rightActionPaint( Painter &p, int x, int y, int outerWidth, bool selected, bool actionSelected) { p.setPen(selected ? st::windowSubTextFgOver : st::windowSubTextFg); _clicks.draw(p, x, y, outerWidth); } void Row::paintStatusText( Painter &p, const style::PeerListItem &st, int x, int y, int availableWidth, int outerWidth, bool selected) { p.setPen(selected ? st.statusFgOver : st.statusFg); _status.draw(p, { .position = { x, y }, .outerWidth = outerWidth, .availableWidth = availableWidth, .palette = &st::defaultTextPalette, .spoiler = Ui::Text::DefaultSpoilerCache(), .now = crl::now(), .elisionLines = 1, }); } class LinksController final : public PeerListController , public RowDelegate , public base::has_weak_ptr { public: explicit LinksController(not_null window); [[nodiscard]] rpl::producer fullCountValue() const { return _count.value(); } void prepare() override; void rowClicked(not_null row) override; void rowRightActionClicked(not_null row) override; base::unique_qptr rowContextMenu( QWidget *parent, not_null row) override; Main::Session &session() const override; not_null rowSession() override; void rowUpdateRow(not_null row) override; void rowPaintIcon( QPainter &p, int x, int y, int size) override; private: void appendRow(const ChatLinkData &data); void prependRow(const ChatLinkData &data); void updateRow(const ChatLinkData &data); bool removeRow(const QString &link); void showRowMenu( not_null row, bool highlightRow); [[nodiscard]] base::unique_qptr createRowContextMenu( QWidget *parent, not_null row); const not_null _window; const not_null _session; rpl::variable _count; base::unique_qptr _menu; QImage _icon; rpl::lifetime _lifetime; }; struct LinksList { not_null widget; not_null controller; }; LinksList AddLinksList( not_null window, not_null container) { auto &lifetime = container->lifetime(); const auto delegate = lifetime.make_state( window->uiShow()); const auto controller = lifetime.make_state(window); controller->setStyleOverrides(&st::inviteLinkList); const auto content = container->add(object_ptr( container, controller)); delegate->setContent(content); controller->setDelegate(delegate); return { content, controller }; } void EditChatLinkBox( not_null box, not_null controller, ChatLinkData data, Fn close)> submit) { box->setTitle(data.link.isEmpty() ? tr::lng_chat_link_new_title() : tr::lng_chat_link_edit_title()); box->setWidth(st::boxWideWidth); Ui::AddDividerText( box->verticalLayout(), tr::lng_chat_link_description()); const auto peer = controller->session().user(); const auto outer = box->getDelegate()->outerContainer(); const auto field = box->addRow( object_ptr( box.get(), st::settingsChatLinkField, Ui::InputField::Mode::MultiLine, tr::lng_chat_link_placeholder())); box->setFocusCallback([=] { field->setFocusFast(); }); Ui::AddDivider(box->verticalLayout()); Ui::AddSkip(box->verticalLayout()); const auto title = box->addRow(object_ptr( box.get(), st::defaultInputField, tr::lng_chat_link_name(), data.title)); const auto emojiToggle = Ui::CreateChild( field->parentWidget(), st::defaultComposeFiles.emoji); using Selector = ChatHelpers::TabbedSelector; auto &lifetime = box->lifetime(); const auto emojiPanel = lifetime.make_state( outer, controller, object_ptr( nullptr, controller->uiShow(), Window::GifPauseReason::Layer, Selector::Mode::EmojiOnly)); emojiPanel->setDesiredHeightValues( 1., st::emojiPanMinHeight / 2, st::emojiPanMinHeight); emojiPanel->hide(); emojiPanel->selector()->setCurrentPeer(peer); emojiPanel->selector()->emojiChosen( ) | rpl::start_with_next([=](ChatHelpers::EmojiChosen data) { Ui::InsertEmojiAtCursor(field->textCursor(), data.emoji); }, field->lifetime()); emojiPanel->selector()->customEmojiChosen( ) | rpl::start_with_next([=](ChatHelpers::FileChosen data) { Data::InsertCustomEmoji(field, data.document); }, field->lifetime()); emojiToggle->installEventFilter(emojiPanel); emojiToggle->addClickHandler([=] { emojiPanel->toggleAnimated(); }); const auto allow = [](not_null) { return true; }; InitMessageFieldHandlers( controller, field, Window::GifPauseReason::Layer, allow); Ui::Emoji::SuggestionsController::Init( outer, field, &controller->session(), { .suggestCustomEmoji = true, .allowCustomWithoutPremium = allow }); field->setSubmitSettings(Core::App().settings().sendSubmitWay()); field->setMaxHeight(st::defaultComposeFiles.caption.heightMax); const auto save = [=] { auto copy = data; copy.title = title->getLastText().trimmed(); auto textWithTags = field->getTextWithAppliedMarkdown(); copy.message = TextWithEntities{ textWithTags.text, TextUtilities::ConvertTextTagsToEntities(textWithTags.tags) }; submit(copy, crl::guard(box, [=] { box->closeBox(); })); }; const auto updateEmojiPanelGeometry = [=] { const auto parent = emojiPanel->parentWidget(); const auto global = emojiToggle->mapToGlobal({ 0, 0 }); const auto local = parent->mapFromGlobal(global); emojiPanel->moveBottomRight( local.y(), local.x() + emojiToggle->width() * 3); }; const auto filterCallback = [=](not_null event) { const auto type = event->type(); if (type == QEvent::Move || type == QEvent::Resize) { // updateEmojiPanelGeometry uses not only container geometry, but // also container children geometries that will be updated later. crl::on_main(emojiPanel, updateEmojiPanelGeometry); } return base::EventFilterResult::Continue; }; base::install_event_filter(emojiPanel, outer, filterCallback); field->submits( ) | rpl::start_with_next([=] { title->setFocus(); }, field->lifetime()); field->cancelled( ) | rpl::start_with_next([=] { box->closeBox(); }, field->lifetime()); title->submits( ) | rpl::start_with_next(save, title->lifetime()); rpl::combine( box->sizeValue(), field->geometryValue() ) | rpl::start_with_next([=](QSize outer, QRect inner) { emojiToggle->moveToLeft( inner.x() + inner.width() - emojiToggle->width(), inner.y() + st::settingsChatLinkEmojiTop); emojiToggle->update(); crl::on_main(emojiPanel, updateEmojiPanelGeometry); }, emojiToggle->lifetime()); const auto initial = TextWithTags{ data.message.text, TextUtilities::ConvertEntitiesToTextTags(data.message.entities) }; field->setTextWithTags(initial, Ui::InputField::HistoryAction::Clear); auto cursor = field->textCursor(); cursor.movePosition(QTextCursor::End); field->setTextCursor(cursor); const auto checkChangedTimer = lifetime.make_state([=] { if (field->getTextWithAppliedMarkdown() == initial) { box->setCloseByOutsideClick(true); } }); field->changes( ) | rpl::start_with_next([=] { checkChangedTimer->callOnce(kChangesDebounceTimeout); box->setCloseByOutsideClick(false); }, field->lifetime()); box->addButton(tr::lng_settings_save(), save); box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); } void EditChatLink( not_null window, not_null session, ChatLinkData data) { const auto submitting = std::make_shared(); const auto submit = [=](ChatLinkData data, Fn close) { if (std::exchange(*submitting, true)) { return; } const auto done = crl::guard(window, [=](const auto&) { window->showToast(tr::lng_chat_link_saved(tr::now)); close(); }); session->api().chatLinks().edit( data.link, data.title, data.message, done); }; window->show(Box( EditChatLinkBox, window, data, crl::guard(window, submit))); } LinksController::LinksController( not_null window) : _window(window) , _session(&window->session()) { style::PaletteChanged( ) | rpl::start_with_next([=] { _icon = QImage(); }, _lifetime); _session->api().chatLinks().updates( ) | rpl::start_with_next([=](const Api::ChatLinkUpdate &update) { if (!update.now) { if (removeRow(update.was)) { delegate()->peerListRefreshRows(); } } else if (update.was.isEmpty()) { prependRow(*update.now); delegate()->peerListRefreshRows(); } else { updateRow(*update.now); } }, _lifetime); } void LinksController::prepare() { auto &&list = _session->api().chatLinks().list() | ranges::views::reverse; for (const auto &link : list) { appendRow(link); } delegate()->peerListRefreshRows(); } void LinksController::rowClicked(not_null row) { showRowMenu(row, true); } void LinksController::showRowMenu( not_null row, bool highlightRow) { delegate()->peerListShowRowMenu(row, highlightRow); } void LinksController::rowRightActionClicked(not_null row) { delegate()->peerListShowRowMenu(row, true); } base::unique_qptr LinksController::rowContextMenu( QWidget *parent, not_null row) { auto result = createRowContextMenu(parent, row); if (result) { // First clear _menu value, so that we don't check row positions yet. base::take(_menu); // Here unique_qptr is used like a shared pointer, where // not the last destroyed pointer destroys the object, but the first. _menu = base::unique_qptr(result.get()); } return result; } base::unique_qptr LinksController::createRowContextMenu( QWidget *parent, not_null row) { const auto real = static_cast(row.get()); const auto data = real->data(); const auto link = data.link; auto result = base::make_unique_q( parent, st::popupMenuWithIcons); result->addAction(tr::lng_group_invite_context_copy(tr::now), [=] { QGuiApplication::clipboard()->setText(link); delegate()->peerListUiShow()->showToast( tr::lng_chat_link_copied(tr::now)); }, &st::menuIconCopy); result->addAction(tr::lng_group_invite_context_share(tr::now), [=] { delegate()->peerListUiShow()->showBox(ShareInviteLinkBox( _session, link, tr::lng_chat_link_copied(tr::now))); }, &st::menuIconShare); result->addAction(tr::lng_group_invite_context_qr(tr::now), [=] { delegate()->peerListUiShow()->showBox(InviteLinkQrBox( nullptr, link, tr::lng_chat_link_qr_title(), tr::lng_chat_link_qr_about())); }, &st::menuIconQrCode); result->addAction(tr::lng_group_invite_context_edit(tr::now), [=] { EditChatLink(_window, _session, data); }, &st::menuIconEdit); result->addAction(tr::lng_group_invite_context_delete(tr::now), [=] { const auto sure = [=](Fn &&close) { _window->session().api().chatLinks().destroy(link, close); }; _window->show(Ui::MakeConfirmBox({ .text = tr::lng_chat_link_delete_sure(tr::now), .confirmed = sure, .confirmText = tr::lng_box_delete(tr::now), })); }, &st::menuIconDelete); return result; } Main::Session &LinksController::session() const { return *_session; } void LinksController::appendRow(const ChatLinkData &data) { delegate()->peerListAppendRow(std::make_unique(this, data)); _count = _count.current() + 1; } void LinksController::prependRow(const ChatLinkData &data) { delegate()->peerListPrependRow(std::make_unique(this, data)); _count = _count.current() + 1; } void LinksController::updateRow(const ChatLinkData &data) { if (const auto row = delegate()->peerListFindRow(ComputeRowId(data))) { const auto real = static_cast(row); real->update(data); delegate()->peerListUpdateRow(row); } } bool LinksController::removeRow(const QString &link) { const auto id = UniqueRowIdFromString(link); if (const auto row = delegate()->peerListFindRow(id)) { delegate()->peerListRemoveRow(row); _count = std::max(_count.current() - 1, 0); return true; } return false; } not_null LinksController::rowSession() { return _session; } void LinksController::rowUpdateRow(not_null row) { delegate()->peerListUpdateRow(row); } void LinksController::rowPaintIcon( QPainter &p, int x, int y, int size) { const auto skip = st::inviteLinkIconSkip; const auto inner = size - 2 * skip; const auto bg = &st::msgFile1Bg; if (_icon.isNull()) { _icon = QImage( QSize(inner, inner) * style::DevicePixelRatio(), QImage::Format_ARGB32_Premultiplied); _icon.fill(Qt::transparent); _icon.setDevicePixelRatio(style::DevicePixelRatio()); auto p = QPainter(&_icon); p.setPen(Qt::NoPen); p.setBrush(*bg); { auto hq = PainterHighQualityEnabler(p); auto rect = QRect(0, 0, inner, inner); p.drawEllipse(rect); } st::inviteLinkIcon.paintInCenter(p, Rect(Size(inner))); } p.drawImage(x + skip, y + skip, _icon); } ChatLinks::ChatLinks( QWidget *parent, not_null controller) : BusinessSection(parent, controller) , _bottomSkipRounding(st::boxRadius, st::boxDividerBg) { setupContent(controller); } ChatLinks::~ChatLinks() = default; rpl::producer ChatLinks::title() { return tr::lng_chat_links_title(); } void ChatLinks::setupContent( not_null controller) { using namespace rpl::mappers; const auto content = Ui::CreateChild(this); AddDividerTextWithLottie(content, { .lottie = u"chat_link"_q, .lottieSize = st::settingsCloudPasswordIconSize, .lottieMargins = st::peerAppearanceIconPadding, .showFinished = showFinishes() | rpl::take(1), .about = tr::lng_chat_links_about(Ui::Text::WithEntities), .aboutMargins = st::peerAppearanceCoverLabelMargin, }); Ui::AddSkip(content); const auto limit = controller->session().appConfig().get( u"business_chat_links_limit"_q, 100); const auto add = content->add( object_ptr>( content, MakeCreateLinkButton( content, tr::lng_chat_links_create_link())) )->setDuration(0); const auto list = AddLinksList(controller, content); add->toggleOn(list.controller->fullCountValue() | rpl::map(_1 < limit)); add->finishAnimating(); add->entity()->setClickedCallback([=] { if (!controller->session().premium()) { ShowPremiumPreviewToBuy( controller, PremiumFeature::ChatLinks); return; } const auto submitting = std::make_shared(); const auto submit = [=](ChatLinkData data, Fn close) { if (std::exchange(*submitting, true)) { return; } const auto done = [=](const auto&) { controller->showToast(tr::lng_chat_link_saved(tr::now)); close(); }; controller->session().api().chatLinks().create( data.title, data.message, done); }; controller->show(Box( EditChatLinkBox, controller, ChatLinkData(), crl::guard(this, submit))); }); Ui::AddSkip(content); const auto self = controller->session().user(); const auto username = self->username(); const auto make = [&](std::vector links) { Expects(!links.empty()); for (auto &link : links) { link = controller->session().createInternalLink(link); } return (links.size() > 1) ? tr::lng_chat_links_footer_both( tr::now, lt_username, Ui::Text::Link(links[0], "https://" + links[0]), lt_link, Ui::Text::Link(links[1], "https://" + links[1]), Ui::Text::WithEntities) : Ui::Text::Link(links[0], "https://" + links[0]); }; auto links = !username.isEmpty() ? make({ username, '+' + self->phone() }) : make({ '+' + self->phone() }); auto label = object_ptr( content, tr::lng_chat_links_footer( lt_links, rpl::single(std::move(links)), Ui::Text::WithEntities), st::boxDividerLabel); label->setClickHandlerFilter([=](ClickHandlerPtr handler, auto) { QGuiApplication::clipboard()->setText(handler->url()); controller->showToast(tr::lng_chat_link_copied(tr::now)); return false; }); content->add(object_ptr( content, std::move(label), st::settingsChatbotsBottomTextMargin, RectPart::Top)); Ui::ResizeFitChild(this, content); } } // namespace Type ChatLinksId() { return ChatLinks::Id(); } } // namespace Settings