/* 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 "history/view/controls/history_view_draft_options.h" #include "base/timer_rpl.h" #include "base/unixtime.h" #include "boxes/peer_list_box.h" #include "boxes/peer_list_controllers.h" #include "chat_helpers/compose/compose_show.h" #include "data/data_changes.h" #include "data/data_drafts.h" #include "data/data_file_origin.h" #include "data/data_session.h" #include "data/data_thread.h" #include "data/data_user.h" #include "data/data_web_page.h" #include "history/view/controls/history_view_webpage_processor.h" #include "history/view/history_view_element.h" #include "history/view/history_view_cursor_state.h" #include "history/history.h" #include "history/history_item.h" #include "history/history_item_components.h" #include "lang/lang_keys.h" #include "main/main_session.h" #include "settings/settings_common.h" #include "ui/chat/chat_style.h" #include "ui/chat/chat_theme.h" #include "ui/effects/path_shift_gradient.h" #include "ui/layers/generic_box.h" #include "ui/widgets/buttons.h" #include "ui/widgets/discrete_sliders.h" #include "ui/painter.h" #include "window/themes/window_theme.h" #include "window/section_widget.h" #include "window/window_session_controller.h" #include "styles/style_chat.h" #include "styles/style_layers.h" #include "styles/style_menu_icons.h" #include "styles/style_settings.h" #include #include namespace HistoryView::Controls { namespace { enum class Section { Reply, Link, }; class PreviewDelegate final : public DefaultElementDelegate { public: PreviewDelegate( not_null parent, not_null st, Fn update); bool elementAnimationsPaused() override; not_null elementPathShiftGradient() override; Context elementContext() override; private: const not_null _parent; const std::unique_ptr _pathGradient; }; [[nodiscard]] TextWithEntities HighlightParsedLinks( TextWithEntities text, const std::vector &links) { auto i = text.entities.begin(); for (const auto &range : links) { if (range.custom.isEmpty()) { while (i != text.entities.end()) { if (i->offset() > range.start) { break; } ++i; } i = text.entities.insert( i, EntityInText(EntityType::Url, range.start, range.length)); ++i; } } return text; } class PreviewWrap final : public Ui::RpWidget { public: PreviewWrap( not_null box, not_null history); ~PreviewWrap(); [[nodiscard]] rpl::producer showQuoteSelector( const SelectedQuote "e); [[nodiscard]] rpl::producer showLinkSelector( const TextWithTags &message, Data::WebPageDraft webpage, const std::vector &links, const QString &usedLink); private: void paintEvent(QPaintEvent *e) override; void leaveEventHook(QEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; void mousePressEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; void mouseDoubleClickEvent(QMouseEvent *e) override; void initElement(); void highlightUsedLink( const TextWithTags &message, const QString &usedLink, const std::vector &links); void startSelection(TextSelectType type); [[nodiscard]] TextSelection resolveNewSelection() const; const not_null _box; const not_null _history; const std::unique_ptr _theme; const std::unique_ptr _style; const std::unique_ptr _delegate; Section _section = Section::Reply; HistoryItem *_draftItem = nullptr; std::unique_ptr _element; rpl::variable _selection; rpl::event_stream _chosenUrl; Ui::PeerUserpicView _userpic; rpl::lifetime _elementLifetime; QPoint _position; base::Timer _trippleClickTimer; ClickHandlerPtr _link; ClickHandlerPtr _pressedLink; TextSelectType _selectType = TextSelectType::Letters; uint16 _symbol = 0; uint16 _selectionStartSymbol = 0; bool _onlyMessageText = false; bool _afterSymbol = false; bool _selectionStartAfterSymbol = false; bool _over = false; bool _textCursor = false; bool _linkCursor = false; bool _selecting = false; }; PreviewWrap::PreviewWrap( not_null box, not_null history) : RpWidget(box) , _box(box) , _history(history) , _theme(Window::Theme::DefaultChatThemeOn(lifetime())) , _style(std::make_unique( history->session().colorIndicesValue())) , _delegate(std::make_unique( box, _style.get(), [=] { update(); })) , _position(0, st::msgMargin.bottom()) { _style->apply(_theme.get()); const auto session = &_history->session(); session->data().viewRepaintRequest( ) | rpl::start_with_next([=](not_null view) { if (view == _element.get()) { update(); } }, lifetime()); _selection.changes() | rpl::start_with_next([=] { update(); }, lifetime()); _box->setAttribute(Qt::WA_OpaquePaintEvent, false); _box->paintRequest() | rpl::start_with_next([=](QRect clip) { const auto geometry = Ui::MapFrom(_box, this, rect()); const auto fill = geometry.intersected(clip); if (!fill.isEmpty()) { auto p = QPainter(_box); p.setClipRect(fill); Window::SectionWidget::PaintBackground( p, _theme.get(), QSize(_box->width(), _box->window()->height()), fill); } }, lifetime()); setMouseTracking(true); } PreviewWrap::~PreviewWrap() { _selection.reset(TextSelection()); _elementLifetime.destroy(); _element = nullptr; if (_draftItem) { _draftItem->destroy(); } } rpl::producer PreviewWrap::showQuoteSelector( const SelectedQuote "e) { _selection.reset(TextSelection()); const auto item = quote.item; const auto group = item->history()->owner().groups().find(item); const auto leader = group ? group->items.front().get() : item; _element = leader->createView(_delegate.get()); _link = _pressedLink = nullptr; if (const auto was = base::take(_draftItem)) { was->destroy(); } const auto media = item->media(); _onlyMessageText = media && (media->webpage() || media->game() || (!media->photo() && !media->document())); _section = Section::Reply; initElement(); _selection = _element->selectionFromQuote(item, quote.text); return _selection.value( ) | rpl::map([=](TextSelection selection) { if (const auto result = _element->selectedQuote(selection)) { return result; } return SelectedQuote{ item }; }); } rpl::producer PreviewWrap::showLinkSelector( const TextWithTags &message, Data::WebPageDraft webpage, const std::vector &links, const QString &usedLink) { _selection.reset(TextSelection()); _element = nullptr; if (const auto was = base::take(_draftItem)) { was->destroy(); } using Flag = MTPDmessageMediaWebPage::Flag; _draftItem = _history->addNewLocalMessage( _history->nextNonHistoryEntryId(), (MessageFlag::FakeHistoryItem | MessageFlag::Outgoing | MessageFlag::HasFromId | (webpage.invert ? MessageFlag::InvertMedia : MessageFlag())), UserId(), // via FullReplyTo(), base::unixtime::now(), // date _history->session().userPeerId(), QString(), // postAuthor HighlightParsedLinks({ message.text, TextUtilities::ConvertTextTagsToEntities(message.tags), }, links), MTP_messageMediaWebPage( MTP_flags(Flag() | (webpage.forceLargeMedia ? Flag::f_force_large_media : Flag()) | (webpage.forceSmallMedia ? Flag::f_force_small_media : Flag())), MTP_webPagePending( MTP_flags(webpage.url.isEmpty() ? MTPDwebPagePending::Flag() : MTPDwebPagePending::Flag::f_url), MTP_long(webpage.id), MTP_string(webpage.url), MTP_int(0))), HistoryMessageMarkupData(), uint64(0)); // groupedId _element = _draftItem->createView(_delegate.get()); _selectType = TextSelectType::Letters; _symbol = _selectionStartSymbol = 0; _afterSymbol = _selectionStartAfterSymbol = false; _section = Section::Link; initElement(); highlightUsedLink(message, usedLink, links); return _chosenUrl.events(); } void PreviewWrap::highlightUsedLink( const TextWithTags &message, const QString &usedLink, const std::vector &links) { auto selection = TextSelection(); const auto view = QStringView(message.text); for (const auto &range : links) { auto text = view.mid(range.start, range.length); if (range.custom == usedLink || (range.custom.isEmpty() && range.length == usedLink.size() && text == usedLink)) { selection = { uint16(range.start), uint16(range.start + range.length), }; const auto skip = [](QChar ch) { return ch.isSpace() || Ui::Text::IsNewline(ch); }; while (!text.isEmpty() && skip(text.front())) { text = text.mid(1); ++selection.from; } while (!text.isEmpty() && skip(text.back())) { text = text.mid(0, text.size() - 1); --selection.to; } const auto basic = _element->textState(QPoint(0, 0), { .flags = Ui::Text::StateRequest::Flag::LookupSymbol, .onlyMessageText = true, }); if (basic.symbol > 0) { selection.from += basic.symbol; selection.to += basic.symbol; } break; } } _selection = selection; } void PreviewWrap::paintEvent(QPaintEvent *e) { if (!_element) { return; } auto p = Painter(this); auto context = _theme->preparePaintContext( _style.get(), rect(), e->rect(), !window()->isActiveWindow()); context.outbg = _element->hasOutLayout(); context.selection = _selecting ? resolveNewSelection() : _selection.current(); p.translate(_position); _element->draw(p, context); if (_element->displayFromPhoto()) { auto userpicBottom = height() - _element->marginBottom() - _element->marginTop(); const auto item = _element->data(); const auto userpicTop = userpicBottom - st::msgPhotoSize; if (const auto from = item->displayFrom()) { from->paintUserpicLeft( p, _userpic, st::historyPhotoLeft, userpicTop, width(), st::msgPhotoSize); } else if (const auto info = item->hiddenSenderInfo()) { if (info->customUserpic.empty()) { info->emptyUserpic.paintCircle( p, st::historyPhotoLeft, userpicTop, width(), st::msgPhotoSize); } else { const auto valid = info->paintCustomUserpic( p, _userpic, st::historyPhotoLeft, userpicTop, width(), st::msgPhotoSize); if (!valid) { info->customUserpic.load( &item->history()->session(), item->fullId()); } } } else { Unexpected("Corrupt forwarded information in message."); } } } void PreviewWrap::leaveEventHook(QEvent *e) { if (!_element || !_over) { return; } _over = false; _textCursor = false; _linkCursor = false; if (!_selecting) { setCursor(style::cur_default); } } void PreviewWrap::mouseMoveEvent(QMouseEvent *e) { if (!_element) { return; } using Flag = Ui::Text::StateRequest::Flag; auto request = StateRequest{ .flags = (_section == Section::Reply ? Flag::LookupSymbol : Flag::LookupLink), .onlyMessageText = (_section == Section::Link || _onlyMessageText), }; auto resolved = _element->textState( e->pos() - _position, request); _over = true; const auto text = (_section == Section::Reply) && (resolved.cursor == CursorState::Text); _link = (_section == Section::Link && resolved.overMessageText) ? resolved.link : nullptr; const auto link = (_link != nullptr) || (_pressedLink != nullptr); if (_textCursor != text || _linkCursor != link) { _textCursor = text; _linkCursor = link; setCursor((text || _selecting) ? style::cur_text : link ? style::cur_pointer : style::cur_default); } if (_symbol != resolved.symbol || _afterSymbol != resolved.afterSymbol) { _symbol = resolved.symbol; _afterSymbol = resolved.afterSymbol; if (_selecting) { update(); } } } void PreviewWrap::mousePressEvent(QMouseEvent *e) { if (!_over) { return; } else if (_section == Section::Reply) { startSelection(_trippleClickTimer.isActive() ? TextSelectType::Paragraphs : TextSelectType::Letters); } else { _pressedLink = _link; } } void PreviewWrap::mouseReleaseEvent(QMouseEvent *e) { if (_section == Section::Reply) { if (!_selecting) { return; } const auto result = resolveNewSelection(); _selecting = false; _selectType = TextSelectType::Letters; if (!_textCursor) { setCursor(style::cur_default); } _selection = result; } else if (base::take(_pressedLink) == _link && _link) { if (const auto url = _link->url(); !url.isEmpty()) { _chosenUrl.fire_copy(url); } } else if (!_link) { setCursor(style::cur_default); } } void PreviewWrap::mouseDoubleClickEvent(QMouseEvent *e) { if (!_over) { return; } else if (_section == Section::Reply) { startSelection(TextSelectType::Words); _trippleClickTimer.callOnce(QApplication::doubleClickInterval()); } } void PreviewWrap::initElement() { _elementLifetime.destroy(); if (!_element) { return; } _element->initDimensions(); widthValue( ) | rpl::filter([=](int width) { return width > st::msgMinWidth; }) | rpl::start_with_next([=](int width) { const auto height = _position.y() + _element->resizeGetHeight(width) + st::msgMargin.top(); resize(width, height); }, _elementLifetime); } TextSelection PreviewWrap::resolveNewSelection() const { if (_section != Section::Reply) { return TextSelection(); } const auto make = [](uint16 symbol, bool afterSymbol) { return uint16(symbol + (afterSymbol ? 1 : 0)); }; const auto first = make(_symbol, _afterSymbol); const auto second = make( _selectionStartSymbol, _selectionStartAfterSymbol); const auto result = (first <= second) ? TextSelection{ first, second } : TextSelection{ second, first }; return _element->adjustSelection(result, _selectType); } void PreviewWrap::startSelection(TextSelectType type) { if (_selecting && _selectType >= type) { return; } _selecting = true; _selectType = type; _selectionStartSymbol = _symbol; _selectionStartAfterSymbol = _afterSymbol; if (!_textCursor) { setCursor(style::cur_text); } update(); } PreviewDelegate::PreviewDelegate( not_null parent, not_null st, Fn update) : _parent(parent) , _pathGradient(MakePathShiftGradient(st, update)) { } bool PreviewDelegate::elementAnimationsPaused() { return _parent->window()->isActiveWindow(); } auto PreviewDelegate::elementPathShiftGradient() -> not_null { return _pathGradient.get(); } Context PreviewDelegate::elementContext() { return Context::Replies; } void AddFilledSkip(not_null container) { const auto skip = container->add(object_ptr( container, st::settingsPrivacySkipTop)); skip->paintRequest() | rpl::start_with_next([=](QRect clip) { QPainter(skip).fillRect(clip, st::boxBg); }, skip->lifetime()); }; void DraftOptionsBox( not_null box, EditDraftOptionsArgs &&args, HistoryItem *replyItem, WebPageData *previewData) { box->setWidth(st::boxWideWidth); const auto &draft = args.draft; struct State { rpl::variable
shown; rpl::lifetime shownLifetime; rpl::variable quote; Data::WebPageDraft webpage; WebPageData *preview = nullptr; QString link; Ui::SettingsSlider *tabs = nullptr; PreviewWrap *wrap = nullptr; Fn performSwitch; Fn requestAndSwitch; rpl::lifetime resolveLifetime; }; const auto state = box->lifetime().make_state(); state->quote = SelectedQuote{ replyItem, draft.reply.quote }; state->webpage = draft.webpage; state->preview = previewData; state->shown = previewData ? Section::Link : Section::Reply; if (replyItem && previewData) { box->setNoContentMargin(true); state->tabs = box->setPinnedToTopContent( object_ptr( box.get(), st::defaultTabsSlider)); state->tabs->resizeToWidth(st::boxWideWidth); state->tabs->move(0, 0); state->tabs->setRippleTopRoundRadius(st::boxRadius); state->tabs->setSections({ tr::lng_reply_header_short(tr::now), tr::lng_link_header_short(tr::now), }); state->tabs->setActiveSectionFast(1); state->tabs->sectionActivated( ) | rpl::start_with_next([=](int section) { state->shown = section ? Section::Link : Section::Reply; }, box->lifetime()); } else { box->setTitle(previewData ? tr::lng_link_options_header() : draft.reply.quote.empty() ? tr::lng_reply_options_header() : tr::lng_reply_options_quote()); } const auto bottom = box->setPinnedToBottomContent( object_ptr(box)); const auto &done = args.done; const auto &show = args.show; const auto &highlight = args.highlight; const auto &clearOldDraft = args.clearOldDraft; const auto resolveReply = [=] { const auto current = state->quote.current(); auto result = draft.reply; result.messageId = current.item->fullId(); result.quote = current.text; return result; }; const auto finish = [=]( FullReplyTo result, Data::WebPageDraft webpage) { const auto weak = Ui::MakeWeak(box); done(std::move(result), std::move(webpage)); if (const auto strong = weak.data()) { strong->closeBox(); } }; const auto setupReplyActions = [=] { AddFilledSkip(bottom); const auto item = state->quote.current().item; if (item->allowsForward()) { Settings::AddButton( bottom, tr::lng_reply_in_another_chat(), st::settingsButton, { &st::menuIconReplace } )->setClickedCallback([=] { ShowReplyToChatBox(show, resolveReply(), clearOldDraft); }); } Settings::AddButton( bottom, tr::lng_reply_show_in_chat(), st::settingsButton, { &st::menuIconShowInChat } )->setClickedCallback([=] { highlight(resolveReply()); }); Settings::AddButton( bottom, tr::lng_reply_remove(), st::settingsAttentionButtonWithIcon, { &st::menuIconDeleteAttention } )->setClickedCallback([=] { finish({}, state->webpage); }); if (!item->originalText().empty()) { AddFilledSkip(bottom); Settings::AddDividerText( bottom, tr::lng_reply_about_quote()); } }; const auto setupLinkActions = [=] { AddFilledSkip(bottom); if (!draft.textWithTags.empty()) { Settings::AddButton( bottom, (state->webpage.invert ? tr::lng_link_move_down() : tr::lng_link_move_up()), st::settingsButton, { state->webpage.invert ? &st::menuIconBelow : &st::menuIconAbove } )->setClickedCallback([=] { state->webpage.invert = !state->webpage.invert; state->webpage.manual = true; state->shown.force_assign(Section::Link); }); } if (state->preview->hasLargeMedia) { const auto small = state->webpage.forceSmallMedia || (!state->webpage.forceLargeMedia && state->preview->computeDefaultSmallMedia()); Settings::AddButton( bottom, (small ? tr::lng_link_enlarge_photo() : tr::lng_link_shrink_photo()), st::settingsButton, { small ? &st::menuIconEnlarge : &st::menuIconShrink } )->setClickedCallback([=] { if (small) { state->webpage.forceSmallMedia = false; state->webpage.forceLargeMedia = true; } else { state->webpage.forceLargeMedia = false; state->webpage.forceSmallMedia = true; } state->webpage.manual = true; state->shown.force_assign(Section::Link); }); } Settings::AddButton( bottom, tr::lng_link_remove(), st::settingsAttentionButtonWithIcon, { &st::menuIconDeleteAttention } )->setClickedCallback([=] { finish(resolveReply(), { .removed = true }); }); if (args.links.size() > 1) { AddFilledSkip(bottom); Settings::AddDividerText( bottom, tr::lng_link_about_choose()); } }; const auto &resolver = args.resolver; state->performSwitch = [=](const QString &link, WebPageData *page) { const auto now = base::unixtime::now(); if (!page || (page->pendingTill > 0 && page->pendingTill < now)) { show->showToast(tr::lng_preview_cant(tr::now)); } else if (page->pendingTill > 0) { const auto delay = std::max(page->pendingTill - now, TimeId()); base::timer_once( (delay + 1) * crl::time(1000) ) | rpl::start_with_next([=] { state->requestAndSwitch(link, true); }, state->resolveLifetime); page->owner().webPageUpdates( ) | rpl::start_with_next([=](not_null updated) { if (updated == page && !updated->pendingTill) { state->resolveLifetime.destroy(); state->performSwitch(link, page); } }, state->resolveLifetime); } else { state->preview = page; state->webpage.id = page->id; state->webpage.url = page->url; state->webpage.manual = true; state->link = link; state->shown.force_assign(Section::Link); } }; state->requestAndSwitch = [=](const QString &link, bool force) { resolver->request(link, force); state->resolveLifetime = resolver->resolved( ) | rpl::start_with_next([=](const QString &resolved) { if (resolved == link) { state->resolveLifetime.destroy(); state->performSwitch( link, resolver->lookup(link).value_or(nullptr)); } }); }; const auto switchTo = [=](const QString &link) { if (link == state->link) { return; } else if (const auto value = resolver->lookup(link)) { state->performSwitch(link, *value); } else { state->requestAndSwitch(link, false); } }; state->wrap = box->addRow( object_ptr(box, args.history), {}); const auto &linkRanges = args.links; state->shown.value() | rpl::start_with_next([=](Section shown) { bottom->clear(); state->shownLifetime.destroy(); if (shown == Section::Reply) { state->quote = state->wrap->showQuoteSelector( state->quote.current()); setupReplyActions(); } else { state->wrap->showLinkSelector( draft.textWithTags, state->webpage, linkRanges, state->link ) | rpl::start_with_next([=](QString link) { switchTo(link); }, state->shownLifetime); setupLinkActions(); } }, box->lifetime()); auto save = rpl::combine( state->quote.value(), state->shown.value() ) | rpl::map([=](const SelectedQuote "e, Section shown) { return (quote.text.empty() || shown != Section::Reply) ? tr::lng_settings_save() : tr::lng_reply_quote_selected(); }) | rpl::flatten_latest(); box->addButton(std::move(save), [=] { finish(resolveReply(), state->webpage); }); box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); if (replyItem) { args.show->session().data().itemRemoved( ) | rpl::filter([=](not_null removed) { const auto current = state->quote.current().item; if ((removed == replyItem) || (removed == current)) { return true; } const auto group = current->history()->owner().groups().find( current); return (group && ranges::contains(group->items, removed)); }) | rpl::start_with_next([=] { if (previewData) { state->tabs = nullptr; box->setPinnedToTopContent( object_ptr(nullptr)); box->setNoContentMargin(false); box->setTitle(state->quote.current().text.empty() ? tr::lng_reply_options_header() : tr::lng_reply_options_quote()); state->shown = Section::Link; } else { box->closeBox(); } }, box->lifetime()); } } } // namespace void ShowReplyToChatBox( std::shared_ptr show, FullReplyTo reply, Fn clearOldDraft) { class Controller final : public ChooseRecipientBoxController { public: using Chosen = not_null; Controller(not_null session) : ChooseRecipientBoxController( session, [=](Chosen thread) mutable { _singleChosen.fire_copy(thread); }, nullptr) { } void rowClicked(not_null row) override final { ChooseRecipientBoxController::rowClicked(row); } [[nodiscard]] rpl::producer singleChosen() const { return _singleChosen.events(); } QString savedMessagesChatStatus() const override { return tr::lng_saved_quote_here(tr::now); } private: void prepareViewHook() override { delegate()->peerListSetTitle(tr::lng_reply_in_another_title()); } rpl::event_stream _singleChosen; }; struct State { not_null box; not_null controller; base::unique_qptr menu; }; const auto session = &show->session(); const auto state = [&] { auto controller = std::make_unique(session); const auto controllerRaw = controller.get(); auto box = Box(std::move(controller), [=]( not_null box) { box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); }); const auto boxRaw = box.data(); show->show(std::move(box)); auto state = State{ boxRaw, controllerRaw }; return boxRaw->lifetime().make_state(std::move(state)); }(); auto chosen = [=](not_null thread) mutable { const auto history = thread->owningHistory(); const auto topicRootId = thread->topicRootId(); const auto draft = history->localDraft(topicRootId); const auto textWithTags = draft ? draft->textWithTags : TextWithTags(); const auto cursor = draft ? draft->cursor : MessageCursor(); reply.topicRootId = topicRootId; history->setLocalDraft(std::make_unique( textWithTags, reply, cursor, Data::WebPageDraft())); history->clearLocalEditDraft(topicRootId); history->session().changes().entryUpdated( thread, Data::EntryUpdate::Flag::LocalDraftSet); if (clearOldDraft) { crl::on_main(&history->session(), clearOldDraft); } return true; }; auto callback = [=, chosen = std::move(chosen)]( Controller::Chosen thread) mutable { const auto weak = Ui::MakeWeak(state->box); if (!chosen(thread)) { return; } else if (const auto strong = weak.data()) { strong->closeBox(); } }; state->controller->singleChosen( ) | rpl::start_with_next(std::move(callback), state->box->lifetime()); } void EditDraftOptions(EditDraftOptionsArgs &&args) { const auto &draft = args.draft; const auto session = &args.show->session(); const auto replyItem = session->data().message(draft.reply.messageId); const auto previewDataRaw = draft.webpage.id ? session->data().webpage(draft.webpage.id).get() : nullptr; const auto previewData = (previewDataRaw && !previewDataRaw->pendingTill && !previewDataRaw->failed) ? previewDataRaw : nullptr; if (!replyItem && !previewData) { return; } args.show->show( Box(DraftOptionsBox, std::move(args), replyItem, previewData)); } } // namespace HistoryView::Controls