diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 84dff065e1..2845c8bd93 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -478,6 +478,8 @@ PRIVATE calls/calls_panel.h calls/calls_panel_background.cpp calls/calls_panel_background.h + calls/calls_rate_call.cpp + calls/calls_rate_call.h calls/calls_signal_bars.cpp calls/calls_signal_bars.h calls/calls_top_bar.cpp diff --git a/Telegram/Resources/animations/call_rate.tgs b/Telegram/Resources/animations/call_rate.tgs new file mode 100644 index 0000000000..eeffb8baa0 Binary files /dev/null and b/Telegram/Resources/animations/call_rate.tgs differ diff --git a/Telegram/Resources/icons/calls/call_ended.png b/Telegram/Resources/icons/calls/call_ended.png new file mode 100644 index 0000000000..e0f5646fc3 Binary files /dev/null and b/Telegram/Resources/icons/calls/call_ended.png differ diff --git a/Telegram/Resources/icons/calls/call_ended@2x.png b/Telegram/Resources/icons/calls/call_ended@2x.png new file mode 100644 index 0000000000..9caa18a4a4 Binary files /dev/null and b/Telegram/Resources/icons/calls/call_ended@2x.png differ diff --git a/Telegram/Resources/icons/calls/call_ended@3x.png b/Telegram/Resources/icons/calls/call_ended@3x.png new file mode 100644 index 0000000000..d8a0e74b15 Binary files /dev/null and b/Telegram/Resources/icons/calls/call_ended@3x.png differ diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index c2bd934604..de4808aa96 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -6440,6 +6440,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_call_rate_label" = "Please rate the quality of your call"; "lng_call_rate_comment" = "Comment (optional)"; +"lng_call_rate_title" = "Rate this call"; +"lng_call_rate_description" = "Please rate the quality of this call."; +"lng_call_ended_title" = "Call ended"; "lng_call_start" = "Start Call"; "lng_call_start_video" = "Start Video"; diff --git a/Telegram/Resources/qrc/telegram/animations.qrc b/Telegram/Resources/qrc/telegram/animations.qrc index f916e5f387..4fc095e288 100644 --- a/Telegram/Resources/qrc/telegram/animations.qrc +++ b/Telegram/Resources/qrc/telegram/animations.qrc @@ -29,6 +29,7 @@ ../../animations/writing.tgs ../../animations/hours.tgs ../../animations/phone.tgs + ../../animations/call_rate.tgs ../../animations/chat_link.tgs ../../animations/diamond.tgs ../../animations/collectible_username.tgs diff --git a/Telegram/SourceFiles/calls/calls.style b/Telegram/SourceFiles/calls/calls.style index 6240fa71a5..5ff93a9517 100644 --- a/Telegram/SourceFiles/calls/calls.style +++ b/Telegram/SourceFiles/calls/calls.style @@ -455,6 +455,46 @@ callRatingComment: InputField(defaultInputField) { } callRatingCommentTop: 8px; +callRateWidth: 300px; +callRateHeight: 152px; +callRateRadius: 28px; +callRateSkip: 16px; +callRateTitleTop: 24px; +callRateDescriptionTop: 50px; +callRateStarsTop: 90px; +callRateStarsHeight: 100px; +callRateStarSize: 37px; +callRateStarSkip: 4px; +callRateBurstSize: 133px; +callRateShowShift: 24px; +callRateStarShowShift: 30px; +callRateTitle: FlatLabel(defaultFlatLabel) { + minWidth: 40px; + textFg: callNameFg; + align: align(top); + style: TextStyle(defaultTextStyle) { + font: font(15px semibold); + } +} +callRateDescription: FlatLabel(callRateTitle) { + style: TextStyle(defaultTextStyle) { + font: font(15px); + } +} +callRateStarRipple: RippleAnimation(defaultRippleAnimation) { + color: callMuteRipple; +} +callRateEndedIcon: icon {{ "calls/call_ended", callNameFg }}; +callRateEndedSkip: 4px; +callRateCloseWidth: 358px; +callRateCloseHeight: 52px; +callRateCloseRadius: 8px; +callRateCloseSkip: 24px; +callRateCloseFont: font(18px semibold); +callRateCloseRipple: RippleAnimation(defaultRippleAnimation) { + color: callIconActiveRipple; +} + callDebugPadding: margins(24px, 0px, 24px, 0px); callDebugLabel: FlatLabel(defaultFlatLabel) { margin: callDebugPadding; diff --git a/Telegram/SourceFiles/calls/calls_call.cpp b/Telegram/SourceFiles/calls/calls_call.cpp index 4edd70beda..a67723f582 100644 --- a/Telegram/SourceFiles/calls/calls_call.cpp +++ b/Telegram/SourceFiles/calls/calls_call.cpp @@ -622,7 +622,78 @@ crl::time Call::getDurationMs() const { return _startTime ? (crl::now() - _startTime) : 0; } +void Call::takeRatingToPanel() { + Expects(_ratingRequested); + + _ratingInPanel = true; +} + +void Call::setRating(int rating) { + _rating = rating; +} + +void Call::finishRating() { + if (!_ratingInPanel) { + return; + } + _ratingInPanel = false; + _ratingRequested = false; + if (_rating > 0 && _id && _accessHash) { + const auto session = &_user->session(); + session->api().request(MTPphone_SetCallRating( + MTP_flags(0), + MTP_inputPhoneCall( + MTP_long(_id), + MTP_long(_accessHash)), + MTP_int(_rating), + MTP_string() + )).done([=](const MTPUpdates &updates) { + session->api().applyUpdates(updates); + }).send(); + } + _delegate->callFinished(this); +} + +void Call::showRatingBox() { + Expects(_ratingRequested); + + _ratingRequested = false; + + const auto window = Core::App().windowFor(::Window::SeparateId(_user)); + const auto session = &_user->session(); + const auto callId = _id; + const auto callAccessHash = _accessHash; + auto owned = Box(Core::App().settings().sendSubmitWay()); + const auto box = window + ? window->show(std::move(owned)) + : Ui::show(std::move(owned)); + const auto sender = box->lifetime().make_state( + &session->mtp()); + box->sends( + ) | rpl::take( + 1 // Instead of keeping requestId. + ) | rpl::on_next([=](const Ui::RateCallBox::Result &r) { + sender->request(MTPphone_SetCallRating( + MTP_flags(0), + MTP_inputPhoneCall( + MTP_long(callId), + MTP_long(callAccessHash)), + MTP_int(r.rating), + MTP_string(r.comment) + )).done([=](const MTPUpdates &updates) { + session->api().applyUpdates(updates); + box->closeBox(); + }).fail([=] { + box->closeBox(); + }).send(); + }, box->lifetime()); +} + void Call::hangup(Data::GroupCall *migrateCall, const QString &migrateSlug) { + if (_ratingInPanel) { + finishRating(); + return; + } const auto state = _state.current(); if (state == State::Busy || state == State::MigrationHangingUp) { @@ -807,36 +878,10 @@ bool Call::handleUpdate(const MTPPhoneCall &call) { } } if (data.is_need_rating() && _id && _accessHash) { - const auto window = Core::App().windowFor( - ::Window::SeparateId(_user)); - const auto session = &_user->session(); - const auto callId = _id; - const auto callAccessHash = _accessHash; - auto owned = Box( - Core::App().settings().sendSubmitWay()); - const auto box = window - ? window->show(std::move(owned)) - : Ui::show(std::move(owned)); - const auto sender = box->lifetime().make_state( - &session->mtp()); - box->sends( - ) | rpl::take( - 1 // Instead of keeping requestId. - ) | rpl::on_next([=](const Ui::RateCallBox::Result &r) { - sender->request(MTPphone_SetCallRating( - MTP_flags(0), - MTP_inputPhoneCall( - MTP_long(callId), - MTP_long(callAccessHash)), - MTP_int(r.rating), - MTP_string(r.comment) - )).done([=](const MTPUpdates &updates) { - session->api().applyUpdates(updates); - box->closeBox(); - }).fail([=] { - box->closeBox(); - }).send(); - }, box->lifetime()); + _ratingRequested = true; + } + if (const auto duration = data.vduration()) { + _discardedDuration = duration->v; } const auto reason = data.vreason(); if (reason @@ -854,6 +899,9 @@ bool Call::handleUpdate(const MTPPhoneCall &call) { } else { setState(State::EndedByOtherDevice); } + if (_ratingRequested && !_ratingInPanel) { + showRatingBox(); + } } return true; case mtpc_phoneCallAccepted: { diff --git a/Telegram/SourceFiles/calls/calls_call.h b/Telegram/SourceFiles/calls/calls_call.h index d40d0794c4..73baeb3b50 100644 --- a/Telegram/SourceFiles/calls/calls_call.h +++ b/Telegram/SourceFiles/calls/calls_call.h @@ -228,6 +228,19 @@ public: crl::time getDurationMs() const; float64 getWaitingSoundPeakValue() const; + [[nodiscard]] bool ratingRequested() const { + return _ratingRequested; + } + [[nodiscard]] bool ratingInPanel() const { + return _ratingInPanel; + } + [[nodiscard]] int discardedDuration() const { + return _discardedDuration; + } + void takeRatingToPanel(); + void setRating(int rating); + void finishRating(); + void applyUserConfirmation(); void answer(); void hangup( @@ -290,6 +303,7 @@ private: = MTP_phoneCallDiscardReasonDisconnect(), Data::GroupCall *migrateCall = nullptr); void finishByMigration(const QString &slug); + void showRatingBox(); void startOutgoing(); void startIncoming(); void startWaitingTrack(); @@ -362,6 +376,10 @@ private: CallId _id = 0; uint64 _accessHash = 0; + int _rating = 0; + int _discardedDuration = 0; + bool _ratingRequested = false; + bool _ratingInPanel = false; uint64 _keyFingerprint = 0; CallId _conferenceId = 0; diff --git a/Telegram/SourceFiles/calls/calls_instance.cpp b/Telegram/SourceFiles/calls/calls_instance.cpp index 9af91fa6ea..581b4f10c9 100644 --- a/Telegram/SourceFiles/calls/calls_instance.cpp +++ b/Telegram/SourceFiles/calls/calls_instance.cpp @@ -99,6 +99,9 @@ DhConfig Instance::Delegate::getDhConfig() const { void Instance::Delegate::callFinished(not_null call) { crl::on_main(call, [=] { + if (call->ratingInPanel()) { + return; + } _instance->destroyCall(call); }); } diff --git a/Telegram/SourceFiles/calls/calls_panel.cpp b/Telegram/SourceFiles/calls/calls_panel.cpp index b61d66114b..b5b1388be4 100644 --- a/Telegram/SourceFiles/calls/calls_panel.cpp +++ b/Telegram/SourceFiles/calls/calls_panel.cpp @@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/peers/replace_boost_box.h" // CreateUserpicsWithMoreBadge #include "calls/calls_panel_background.h" +#include "calls/calls_rate_call.h" #include "data/data_photo.h" #include "data/data_session.h" #include "data/data_user.h" @@ -79,6 +80,7 @@ namespace { constexpr auto kHideControlsTimeout = 5 * crl::time(1000); constexpr auto kHideControlsQuickTimeout = 2 * crl::time(1000); +constexpr auto kNameFadeDuration = crl::time(70); [[nodiscard]] QByteArray BatterySvg( const QSize &s, @@ -332,6 +334,8 @@ void Panel::initWindow() { _answerHangupRedial->y(), buttonsWidth, _answerHangupRedial->height()).contains(widgetPoint) + || (_rateCall && _rateCall->geometry().contains(widgetPoint)) + || (_rateClose && _rateClose->geometry().contains(widgetPoint)) || (!_outgoingPreviewInBody && _outgoingVideoBubble->geometry().contains(widgetPoint)); if (inControls) { @@ -651,6 +655,16 @@ void Panel::refreshIncomingGeometry() { void Panel::reinitWithCall(Call *call) { _callLifetime.destroy(); + _rateNameFade.stop(); + _rateCall = nullptr; + _rateClose = nullptr; + _rateNameSwapped = false; + _endedDuration = 0; + _rateEndedIconPosition = QPoint(); + _name->setOpacity(1.); + _bodySt = _outgoingPreviewInBody + ? &st::callBodyWithPreview + : &st::callBodyLayout; _call = call; const auto guard = gsl::finally([&] { updateControlsShown(); @@ -1017,10 +1031,14 @@ void Panel::showControls() { widget()->showChildren(); _pinOnTop->setVisible(!_fullScreenOrMaximized.current()); - _decline->setVisible(_decline->toggled()); - _cancel->setVisible(_cancel->toggled()); - _screencast->setVisible(_screencast->toggled()); - _addPeople->setVisible(_addPeople->toggled()); + if (_rateCall) { + hideControlsForRating(); + } else { + _decline->setVisible(_decline->toggled()); + _cancel->setVisible(_cancel->toggled()); + _screencast->setVisible(_screencast->toggled()); + _addPeople->setVisible(_addPeople->toggled()); + } const auto shown = !_incomingFrameSize.isEmpty(); _incoming->widget()->setVisible(shown); @@ -1129,6 +1147,97 @@ void Panel::refreshOutgoingPreviewInBody(State state) { updateControlsGeometry(); } +void Panel::showRateCall() { + Expects(_call != nullptr); + Expects(_rateCall == nullptr); + + _call->takeRatingToPanel(); + const auto local = int(_call->getDurationMs() / 1000); + _endedDuration = local ? local : _call->discardedDuration(); + _bodySt = &st::callBodyWithPreview; + + _rateCall = base::make_unique_q(widget()); + _rateCall->ratingValue( + ) | rpl::on_next([=](int rating) { + if (_call) { + _call->setRating(rating); + } + }, _rateCall->lifetime()); + _rateClose = base::make_unique_q(widget()); + + hideControlsForRating(); + updateControlsGeometry(); + updateTextColors(); + updateStatusText(_call->state()); + + _rateCall->show(); + _rateCall->showAnimated(); + _rateClose->switchToClose(hangupCircleRect(), [=] { finishRateCall(); }); + + _rateNameFade.start([=] { + const auto value = _rateNameFade.value(1.); + if (!_rateNameSwapped && value >= 0.5) { + _rateNameSwapped = true; + _name->setText(tr::lng_call_ended_title(tr::now)); + updateControlsGeometry(); + } + _name->setOpacity(std::abs(2. * value - 1.)); + }, 0., 1., 2 * kNameFadeDuration); + + widget()->update(); +} + +void Panel::finishRateCall() { + if (_call) { + _call->finishRating(); + } +} + +void Panel::hideControlsForRating() { + _answerHangupRedial->hide(); + _camera->hide(); + if (_startVideo) { + _startVideo->hide(); + } + _decline->hide(anim::type::instant); + _cancel->hide(anim::type::instant); + _screencast->hide(anim::type::instant); + _mute->hide(anim::type::instant); + _addPeople->hide(anim::type::instant); + if (_fingerprint) { + _fingerprint->hide(); + } + if (_remoteAudioMute) { + _remoteAudioMute->hide(); + } + if (_remoteLowBattery) { + _remoteLowBattery->hide(); + } + hideButtonTooltip(); +} + +QRect Panel::hangupCircleRect() const { + const auto &st = st::callHangup; + return QRect( + _answerHangupRedial->x() + st.bgPosition.x(), + _answerHangupRedial->y() + st.bgPosition.y(), + st.bgSize, + st.bgSize); +} + +QRect Panel::rateCloseRect() const { + const auto circle = hangupCircleRect(); + const auto width = std::min( + st::callRateCloseWidth, + widget()->width() - 2 * st::callRateCloseSkip); + const auto height = st::callRateCloseHeight; + return QRect( + (widget()->width() - width) / 2, + circle.y() + (circle.height() - height) / 2, + width, + height); +} + void Panel::createPinOnTop() { const auto wrap = _window->controlsWrap(); _pinOnTop = base::make_unique_q( @@ -1225,6 +1334,7 @@ void Panel::updateControlsShown() { const auto shown = !_incoming || _incoming->widget()->isHidden() || _controlsShownForce + || _rateCall || !_hideControlsRequested; if (_controlsShown != shown) { _controlsShown = shown; @@ -1322,7 +1432,11 @@ void Panel::updateControlsGeometry() { st::callOutgoingPreviewMax.height(), bodyPreviewHeightMax, })); + const auto rateHeight = _rateCall + ? (st::callRateSkip + st::callRateHeight) + : 0; const auto contentHeight = bodyContentHeight + + rateHeight + (_outgoingPreviewInBody ? bodyPreviewSize.height() : 0); const auto remainingHeight = std::max(available - contentHeight, 0); const auto skipHeight = remainingHeight @@ -1351,6 +1465,14 @@ void Panel::updateControlsGeometry() { _bodyTop + _bodySt->nameTop); updateStatusGeometry(); + if (_rateCall) { + _rateCall->setGeometry( + (widget()->width() - st::callRateWidth) / 2, + _bodyTop + bodyContentHeight + st::callRateSkip, + st::callRateWidth, + RateCall::Height()); + } + if (_remoteAudioMute) { _remoteAudioMute->moveToLeft( (widget()->width() - _remoteAudioMute->width()) / 2, @@ -1383,6 +1505,9 @@ void Panel::updateControlsGeometry() { } updateHangupGeometry(); + if (_rateClose) { + _rateClose->setGeometry(rateCloseRect()); + } updateButtonTooltipGeometry(); } @@ -1430,11 +1555,19 @@ void Panel::updateStatusGeometry() { if (widget()->size().isEmpty()) { return; } + const auto &icon = st::callRateEndedIcon; + const auto showIcon = (_rateCall && _endedDuration > 0); + const auto skip = showIcon + ? (icon.width() + st::callRateEndedSkip) + : 0; _status->resizeToNaturalWidth( - widget()->width() - 2 * st::callInnerPadding); - _status->moveToLeft( - (widget()->width() - _status->width()) / 2, - _bodyTop + _bodySt->statusTop); + widget()->width() - 2 * st::callInnerPadding - skip); + const auto top = _bodyTop + _bodySt->statusTop; + const auto left = (widget()->width() - _status->width() - skip) / 2; + _status->moveToLeft(left + skip, top); + _rateEndedIconPosition = showIcon + ? QPoint(left, top + (_status->height() - icon.height()) / 2) + : QPoint(); } auto Panel::bottomButtons() const @@ -1585,6 +1718,18 @@ void Panel::paint(QRect clip) { } } + if (_rateCall && !_rateEndedIconPosition.isNull()) { + const auto fg = st::callName.textFg; + const auto color = (_background + ? _background->textColorOverride(fg) + : std::optional()).value_or(fg->c); + st::callRateEndedIcon.paint( + p, + _rateEndedIconPosition, + widget()->width(), + color); + } + if (_incoming && _incoming->widget()->isHidden()) { _call->videoIncoming()->markFrameShown(); } @@ -1592,7 +1737,9 @@ void Panel::paint(QRect clip) { bool Panel::handleClose() const { if (_call) { - if (_call->state() == Call::State::WaitingUserConfirmation + if (_call->ratingInPanel()) { + _call->finishRating(); + } else if (_call->state() == Call::State::WaitingUserConfirmation || _call->state() == Call::State::Busy || _call->state() == Call::State::Starting || _call->state() == Call::State::WaitingIncoming) { @@ -1631,6 +1778,13 @@ void Panel::stateChanged(State state) { const auto isWaitingUser = (state == State::WaitingUserConfirmation); _window->togglePowerSaveBlocker(!isBusy && !isWaitingUser); + if (!_rateCall + && _call->ratingRequested() + && !window()->isHidden() + && (state == State::Ended || state == State::EndedByOtherDevice)) { + showRateCall(); + } + if ((state != State::HangingUp) && (state != State::MigrationHangingUp) && (state != State::Ended) @@ -1720,6 +1874,9 @@ void Panel::refreshAnswerHangupRedialLabel() { void Panel::updateStatusText(State state) { auto statusText = [this, state]() -> QString { + if (_rateCall && _endedDuration > 0) { + return Ui::FormatDurationText(_endedDuration); + } switch (state) { case State::Starting: case State::WaitingInit: @@ -1757,15 +1914,31 @@ void Panel::updateStatusText(State state) { } void Panel::updateTextColors() { + const auto statusFg = _rateCall + ? st::callName.textFg + : st::callStatus.textFg; if (!_background) { _name->setTextColorOverride(std::nullopt); - _status->setTextColorOverride(std::nullopt); + _status->setTextColorOverride(_rateCall + ? std::make_optional(statusFg->c) + : std::nullopt); + if (_rateCall) { + _rateCall->setTextColorOverride(std::nullopt); + } return; } _name->setTextColorOverride( _background->textColorOverride(st::callName.textFg)); - _status->setTextColorOverride( - _background->textColorOverride(st::callStatus.textFg)); + auto status = _background->textColorOverride(statusFg); + if (_rateCall && !status) { + status = statusFg->c; + } + _status->setTextColorOverride(status); + if (_rateCall) { + _rateCall->setTextColorOverride( + _background->textColorOverride(st::callRateTitle.textFg)); + _rateCall->setCardOverlay(_background->cardOverlay()); + } } void Panel::startDurationUpdateTimer(crl::time currentDuration) { diff --git a/Telegram/SourceFiles/calls/calls_panel.h b/Telegram/SourceFiles/calls/calls_panel.h index 7ccd480d9f..01ff853a57 100644 --- a/Telegram/SourceFiles/calls/calls_panel.h +++ b/Telegram/SourceFiles/calls/calls_panel.h @@ -67,6 +67,8 @@ class Userpic; class SignalBars; class VideoBubble; class PanelBackground; +class RateCall; +class EndCloseButton; struct DeviceSelection; struct ConferencePanelMigration; @@ -158,6 +160,11 @@ private: void refreshIncomingGeometry(); void refreshOutgoingPreviewInBody(State state); + void showRateCall(); + void finishRateCall(); + void hideControlsForRating(); + [[nodiscard]] QRect hangupCircleRect() const; + [[nodiscard]] QRect rateCloseRect() const; void createPinOnTop(); [[nodiscard]] QRect pinOnTopRect() const; void toggleFullScreen(bool fullscreen); @@ -206,6 +213,12 @@ private: bool _buttonLabelsShown = true; base::unique_qptr _name; base::unique_qptr _status; + base::unique_qptr _rateCall; + base::unique_qptr _rateClose; + Ui::Animations::Simple _rateNameFade; + QPoint _rateEndedIconPosition; + int _endedDuration = 0; + bool _rateNameSwapped = false; base::unique_qptr _conferenceParticipants; base::unique_qptr _fingerprint; base::unique_qptr> _remoteAudioMute; diff --git a/Telegram/SourceFiles/calls/calls_panel_background.cpp b/Telegram/SourceFiles/calls/calls_panel_background.cpp index 2c374850f8..b8cfbf3892 100644 --- a/Telegram/SourceFiles/calls/calls_panel_background.cpp +++ b/Telegram/SourceFiles/calls/calls_panel_background.cpp @@ -20,6 +20,12 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/top_background_gradient.h" namespace Calls { +namespace { + +constexpr auto kDarkOverlayOpacity = 0.14; +constexpr auto kDarkenAboveLightness = 0.3; + +} // namespace PanelBackground::PanelBackground( not_null peer, @@ -217,6 +223,13 @@ std::optional PanelBackground::textColorOverride( return std::nullopt; } +QColor PanelBackground::cardOverlay() const { + const auto base = edgeColor().value_or(st::callBgOpaque->c); + return (base.lightnessF() > kDarkenAboveLightness) + ? QColor(0, 0, 0, anim::interpolate(0, 255, kDarkOverlayOpacity)) + : st::callIconBg->c; +} + rpl::lifetime &PanelBackground::lifetime() { return _lifetime; } diff --git a/Telegram/SourceFiles/calls/calls_panel_background.h b/Telegram/SourceFiles/calls/calls_panel_background.h index e4c00770ea..91937e5ee1 100644 --- a/Telegram/SourceFiles/calls/calls_panel_background.h +++ b/Telegram/SourceFiles/calls/calls_panel_background.h @@ -42,6 +42,8 @@ public: [[nodiscard]] std::optional textColorOverride( const style::color &defaultColor) const; + [[nodiscard]] QColor cardOverlay() const; + private: void updateBrush( QSize widgetSize, diff --git a/Telegram/SourceFiles/calls/calls_rate_call.cpp b/Telegram/SourceFiles/calls/calls_rate_call.cpp new file mode 100644 index 0000000000..e60bcfe7af --- /dev/null +++ b/Telegram/SourceFiles/calls/calls_rate_call.cpp @@ -0,0 +1,528 @@ +/* +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 "calls/calls_rate_call.h" + +#include "lang/lang_keys.h" +#include "lottie/lottie_icon.h" +#include "ui/effects/ripple_animation.h" +#include "ui/painter.h" +#include "ui/rect.h" +#include "styles/style_calls.h" + +#include + +namespace Calls { +namespace { + +constexpr auto kBurstMinRating = 4; +constexpr auto kShowDuration = crl::time(250); +constexpr auto kStarShowDelay = crl::time(16); +constexpr auto kSelectDuration = crl::time(250); +constexpr auto kCardShowScaleFrom = 0.7; +constexpr auto kStarShowScaleFrom = 0.3; +constexpr auto kStarPressedScale = 0.8; +constexpr auto kDarkOpacity = 0.14; +constexpr auto kRippleOpacity = 0.3; +constexpr auto kPathSize = 96.; +constexpr auto kPathStroke = 6.; +constexpr auto kPathMiterLimit = 4.; +constexpr auto kCloseTextShadeOpacity = 0.15; +constexpr auto kCloseRippleOpacity = 0.12; +constexpr auto kMaxBursts = 5; + +struct StarPoint { + float64 x = 0.; + float64 y = 0.; + float64 inX = 0.; + float64 inY = 0.; + float64 outX = 0.; + float64 outY = 0.; +}; + +const StarPoint kStarPath[] = { + { 46.080, 69.940, 0.855, -0.588, 0.000, 0.000 }, + { 32.727, 79.118, 0.000, 0.000, -2.274, 1.563 }, + { 25.770, 77.844, 1.568, 2.267, -0.860, -1.243 }, + { 25.094, 73.591, -0.432, 1.447, 0.000, 0.000 }, + { 29.725, 58.097, 0.000, 0.000, 0.296, -0.992 }, + { 28.848, 55.405, 0.825, 0.629, 0.000, 0.000 }, + { 15.963, 45.583, 0.000, 0.000, -2.194, -1.673 }, + { 15.029, 38.595, -1.678, 2.187, 0.920, -1.199 }, + { 18.879, 36.640, -1.514, 0.037, 0.000, 0.000 }, + { 35.095, 36.242, 0.000, 0.000, 1.038, -0.025 }, + { 37.392, 34.578, -0.345, 0.976, 0.000, 0.000 }, + { 42.783, 19.330, 0.000, 0.000, 0.918, -2.597 }, + { 49.162, 16.285, -2.605, -0.915, 1.429, 0.502 }, + { 52.218, 19.330, -0.503, -1.424, 0.000, 0.000 }, + { 57.608, 34.578, 0.000, 0.000, 0.345, 0.976 }, + { 59.905, 36.242, -1.038, -0.025, 0.000, 0.000 }, + { 76.121, 36.640, 0.000, 0.000, 2.762, 0.068 }, + { 80.998, 41.746, 0.068, -2.752, -0.037, 1.509 }, + { 79.037, 45.583, 1.203, -0.917, 0.000, 0.000 }, + { 66.152, 55.405, 0.000, 0.000, -0.825, 0.629 }, + { 65.275, 58.097, -0.296, -0.992, 0.000, 0.000 }, + { 69.906, 73.591, 0.000, 0.000, 0.789, 2.639 }, + { 66.541, 79.792, 2.647, -0.786, -1.452, 0.431 }, + { 62.273, 79.118, 1.247, 0.857, 0.000, 0.000 }, + { 48.920, 69.940, 0.000, 0.000, -0.855, -0.588 }, +}; + +[[nodiscard]] QPainterPath StarPath(int size) { + const auto scale = size / kPathSize; + const auto map = [&](float64 x, float64 y) { + return QPointF(x * scale, y * scale); + }; + const auto count = int(std::size(kStarPath)); + auto result = QPainterPath(); + result.moveTo(map(kStarPath[0].x, kStarPath[0].y)); + for (auto i = 0; i != count; ++i) { + const auto &from = kStarPath[i]; + const auto &to = kStarPath[(i + 1) % count]; + result.cubicTo( + map(from.x + from.outX, from.y + from.outY), + map(to.x + to.inX, to.y + to.inY), + map(to.x, to.y)); + } + result.closeSubpath(); + return result; +} + +[[nodiscard]] QImage StarImage(int size, QColor color, bool filled) { + const auto ratio = style::DevicePixelRatio(); + auto result = QImage( + QSize(size, size) * ratio, + QImage::Format_ARGB32_Premultiplied); + result.setDevicePixelRatio(ratio); + result.fill(Qt::transparent); + + auto p = QPainter(&result); + auto hq = PainterHighQualityEnabler(p); + const auto path = StarPath(size); + p.setPen(Qt::NoPen); + p.setBrush(color); + if (filled) { + p.drawPath(path); + } else { + auto stroker = QPainterPathStroker(); + stroker.setWidth(kPathStroke * size / kPathSize); + stroker.setJoinStyle(Qt::MiterJoin); + stroker.setMiterLimit(kPathMiterLimit); + p.drawPath(stroker.createStroke(path).intersected(path)); + } + p.end(); + + return result; +} + +} // namespace + +RateCall::RateCall(QWidget *parent) +: RpWidget(parent) +, _title(st::callRateTitle.minWidth) +, _description(st::callRateDescription.minWidth) { + _title.setText(st::callRateTitle.style, tr::lng_call_rate_title(tr::now)); + _description.setText( + st::callRateDescription.style, + tr::lng_call_rate_description(tr::now)); + _cardOverlay = QColor(0, 0, 0, anim::interpolate(0, 255, kDarkOpacity)); + setMouseTracking(true); + resize(st::callRateWidth, Height()); +} + +RateCall::~RateCall() = default; + +int RateCall::Height() { + return st::callRateStarsTop + st::callRateStarsHeight; +} + +void RateCall::showAnimated() { + _showAnimation.stop(); + _showAnimation = {}; + _showAnimation.start( + [=] { update(); }, + 0., + 1., + kShowDuration + (kStarsCount - 1) * kStarShowDelay, + anim::linear); + update(); +} + +void RateCall::setCardOverlay(QColor color) { + if (_cardOverlay == color) { + return; + } + _cardOverlay = color; + update(); +} + +void RateCall::setTextColorOverride(std::optional color) { + if (_textColorOverride == color) { + return; + } + _textColorOverride = color; + _starOutline = _starFilled = QImage(); + update(); +} + +int RateCall::rating() const { + return _rating.current(); +} + +rpl::producer RateCall::ratingValue() const { + return _rating.value(); +} + +QColor RateCall::textColor() const { + return _textColorOverride.value_or(st::callRateTitle.textFg->c); +} + +QRect RateCall::cardRect() const { + return QRect( + (width() - st::callRateWidth) / 2, + 0, + st::callRateWidth, + st::callRateHeight); +} + +QRect RateCall::starRect(int index) const { + const auto single = st::callRateStarSize; + const auto full = kStarsCount * single + + (kStarsCount - 1) * st::callRateStarSkip; + const auto left = (width() - full) / 2 + + index * (single + st::callRateStarSkip); + return QRect(left, st::callRateStarsTop, single, single); +} + +int RateCall::starByPosition(QPoint position) const { + for (auto i = 0; i != kStarsCount; ++i) { + if (starRect(i).contains(position)) { + return i; + } + } + return -1; +} + +float64 RateCall::cardShown() const { + const auto total = kShowDuration + (kStarsCount - 1) * kStarShowDelay; + const auto passed = _showAnimation.value(1.) * total; + return anim::sineInOut(1., std::clamp(passed / kShowDuration, 0., 1.)); +} + +float64 RateCall::starShown(int index) const { + const auto total = kShowDuration + (kStarsCount - 1) * kStarShowDelay; + const auto passed = _showAnimation.value(1.) * total + - index * kStarShowDelay; + return anim::sineInOut(1., std::clamp(passed / kShowDuration, 0., 1.)); +} + +const QImage &RateCall::starImage(bool filled) const { + const auto color = textColor(); + if (_starColor != color) { + _starColor = color; + _starOutline = _starFilled = QImage(); + } + auto &image = filled ? _starFilled : _starOutline; + if (image.isNull()) { + image = StarImage(st::callRateStarSize, color, filled); + } + return image; +} + +void RateCall::paintEvent(QPaintEvent *e) { + auto p = QPainter(this); + auto hq = PainterHighQualityEnabler(p); + + paintCard(p, cardShown()); + for (auto i = 0; i != kStarsCount; ++i) { + paintStar(p, i, starShown(i)); + } + paintBurst(p); +} + +void RateCall::paintCard(QPainter &p, float64 shown) { + if (shown <= 0.) { + return; + } + const auto card = cardRect(); + const auto scale = kCardShowScaleFrom + + (1. - kCardShowScaleFrom) * shown; + + p.save(); + p.setOpacity(shown); + p.translate(0, anim::interpolate(st::callRateShowShift, 0, shown)); + p.translate(card.center()); + p.scale(scale, scale); + p.translate(-card.center()); + + p.setPen(Qt::NoPen); + p.setBrush(_cardOverlay); + p.drawRoundedRect(card, st::callRateRadius, st::callRateRadius); + + p.setPen(textColor()); + _title.draw(p, { + .position = QPoint(card.x(), card.y() + st::callRateTitleTop), + .outerWidth = width(), + .availableWidth = card.width(), + .align = style::al_top, + }); + _description.draw(p, { + .position = QPoint(card.x(), card.y() + st::callRateDescriptionTop), + .outerWidth = width(), + .availableWidth = card.width(), + .align = style::al_top, + }); + p.restore(); +} + +void RateCall::paintStar(QPainter &p, int index, float64 shown) { + if (shown <= 0.) { + return; + } + auto &star = _stars[index]; + const auto rect = starRect(index); + if (star.ripple && !star.ripple->empty()) { + auto color = textColor(); + color.setAlphaF(color.alphaF() * kRippleOpacity); + p.setOpacity(shown); + star.ripple->paint(p, rect.x(), rect.y(), width(), &color); + p.setOpacity(1.); + } + const auto filled = star.filled.value(star.filledTo); + const auto scale = star.pressed.value(star.pressedTo) + * (kStarShowScaleFrom + (1. - kStarShowScaleFrom) * shown); + + p.save(); + p.translate(0, anim::interpolate(st::callRateStarShowShift, 0, shown)); + p.translate(rect.center()); + p.scale(scale, scale); + p.translate(-rect.center()); + if (filled < 1.) { + p.setOpacity(shown * (1. - filled)); + p.drawImage(rect.topLeft(), starImage(false)); + } + if (filled > 0.) { + p.setOpacity(shown * filled); + p.drawImage(rect.topLeft(), starImage(true)); + } + p.restore(); + p.setOpacity(1.); +} + +void RateCall::paintBurst(QPainter &p) { + const auto size = st::callRateBurstSize; + for (const auto &burst : _bursts) { + if (!burst.icon || !burst.icon->animating()) { + continue; + } + burst.icon->paint( + p, + burst.center.x() - size / 2, + burst.center.y() - size / 2); + } +} + +void RateCall::setOver(int index) { + if (_over == index) { + return; + } + _over = index; + setCursor((_over >= 0) ? style::cur_pointer : style::cur_default); +} + +void RateCall::applyPreview(int preview) { + if (_preview == preview) { + return; + } + _preview = preview; + const auto shown = _preview ? _preview : _rating.current(); + for (auto i = 0; i != kStarsCount; ++i) { + auto &star = _stars[i]; + const auto filled = (i < shown) ? 1. : 0.; + const auto pressed = (_preview && (i < _preview)) + ? kStarPressedScale + : 1.; + if (star.filledTo != filled) { + star.filled.start( + [=] { update(); }, + star.filled.value(star.filledTo), + filled, + kSelectDuration); + star.filledTo = filled; + } + if (star.pressedTo != pressed) { + star.pressed.start( + [=] { update(); }, + star.pressed.value(star.pressedTo), + pressed, + kSelectDuration); + star.pressedTo = pressed; + } + } + update(); +} + +void RateCall::commit(int index) { + _rating = index + 1; + applyPreview(0); + if (_rating.current() >= kBurstMinRating) { + startBurst(index); + } +} + +void RateCall::startBurst(int index) { + const auto free = ranges::find_if(_bursts, [](const Burst &burst) { + return !burst.icon || !burst.icon->animating(); + }); + const auto burst = (free != end(_bursts)) + ? &*free + : (int(_bursts.size()) < kMaxBursts) + ? &_bursts.emplace_back() + : &*ranges::min_element(_bursts, ranges::less(), &Burst::started); + if (!burst->icon) { + burst->icon = Lottie::MakeIcon({ + .name = u"call_rate"_q, + .sizeOverride = QSize( + st::callRateBurstSize, + st::callRateBurstSize), + }); + if (!burst->icon->valid()) { + burst->icon = nullptr; + return; + } + } + burst->center = starRect(index).center(); + burst->started = crl::now(); + const auto last = burst->icon->framesCount() - 1; + burst->icon->animate([=] { update(); }, 0, last); + update(); +} + +void RateCall::mouseMoveEvent(QMouseEvent *e) { + setOver(starByPosition(e->pos())); +} + +void RateCall::mousePressEvent(QMouseEvent *e) { + if (e->button() != Qt::LeftButton) { + return; + } + setOver(starByPosition(e->pos())); + if (_over < 0) { + return; + } + _pressed = _over; + auto &star = _stars[_pressed]; + const auto rect = starRect(_pressed); + if (!star.ripple) { + star.ripple = std::make_unique( + st::callRateStarRipple, + Ui::RippleAnimation::EllipseMask(rect.size()), + [=] { update(); }); + } + star.ripple->add(e->pos() - rect.topLeft()); + applyPreview(_pressed + 1); +} + +void RateCall::mouseReleaseEvent(QMouseEvent *e) { + if (e->button() != Qt::LeftButton) { + return; + } + const auto pressed = std::exchange(_pressed, -1); + if (pressed < 0) { + return; + } else if (const auto &ripple = _stars[pressed].ripple) { + ripple->lastStop(); + } + setOver(starByPosition(e->pos())); + if (_over == pressed) { + commit(pressed); + } else { + applyPreview(0); + } +} + +void RateCall::leaveEventHook(QEvent *e) { + setOver(-1); +} + +EndCloseButton::EndCloseButton(QWidget *parent) +: RippleButton(parent, st::callRateCloseRipple) +, _text(tr::lng_close(tr::now)) { + setDisabled(true); + hide(); +} + +void EndCloseButton::switchToClose(QRect hangupCircle, Fn close) { + _from = hangupCircle; + setClickedCallback(std::move(close)); + _animation.start([=] { update(); }, 0., 1., st::callPanelDuration); + _animation.setFinishedCallback([=] { setDisabled(false); }); + show(); + update(); +} + +void EndCloseButton::prepareFrame() { + const auto ratio = style::DevicePixelRatio(); + if (_frame.size() != size() * ratio) { + _frame = QImage(size() * ratio, QImage::Format_ARGB32_Premultiplied); + _frame.setDevicePixelRatio(ratio); + } + _frame.fill(Qt::transparent); + + auto p = QPainter(&_frame); + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(st::callIconBgActive); + p.drawRoundedRect( + rect(), + st::callRateCloseRadius, + st::callRateCloseRadius); + + auto ripple = st::callIconFgActive->c; + ripple.setAlphaF(ripple.alphaF() * kCloseRippleOpacity); + paintRipple(p, 0, 0, &ripple); + + p.setFont(st::callRateCloseFont); + p.setCompositionMode(QPainter::CompositionMode_DestinationOut); + p.setPen(QColor(0, 0, 0, 255)); + p.drawText(rect(), Qt::AlignCenter, _text); + p.setCompositionMode(QPainter::CompositionMode_SourceOver); + auto shade = st::callIconFgActive->c; + shade.setAlphaF(shade.alphaF() * kCloseTextShadeOpacity); + p.setPen(shade); + p.drawText(rect(), Qt::AlignCenter, _text); +} + +void EndCloseButton::paintEvent(QPaintEvent *e) { + auto p = QPainter(this); + + const auto shown = _animation.value(1.); + if (shown < 1.) { + auto hq = PainterHighQualityEnabler(p); + const auto circle = _from.translated(-pos()); + p.setOpacity(1. - shown); + p.setPen(Qt::NoPen); + p.setBrush(st::callHangupBg); + p.drawEllipse(circle); + st::callHangup.button.icon.paintInCenter( + p, + circle, + st::callIconFg->c); + } + + prepareFrame(); + p.setOpacity(shown); + p.drawImage(0, 0, _frame); +} + +QImage EndCloseButton::prepareRippleMask() const { + return Ui::RippleAnimation::RoundRectMask( + size(), + st::callRateCloseRadius); +} + +} // namespace Calls diff --git a/Telegram/SourceFiles/calls/calls_rate_call.h b/Telegram/SourceFiles/calls/calls_rate_call.h new file mode 100644 index 0000000000..a13b57bb12 --- /dev/null +++ b/Telegram/SourceFiles/calls/calls_rate_call.h @@ -0,0 +1,116 @@ +/* +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 +*/ +#pragma once + +#include "ui/effects/animations.h" +#include "ui/rp_widget.h" +#include "ui/text/text.h" +#include "ui/widgets/buttons.h" + +namespace Lottie { +class Icon; +} // namespace Lottie + +namespace Ui { +class RippleAnimation; +} // namespace Ui + +namespace Calls { + +class RateCall final : public Ui::RpWidget { +public: + explicit RateCall(QWidget *parent); + ~RateCall(); + + void showAnimated(); + void setTextColorOverride(std::optional color); + void setCardOverlay(QColor color); + + [[nodiscard]] int rating() const; + [[nodiscard]] rpl::producer ratingValue() const; + + [[nodiscard]] static int Height(); + +private: + static constexpr auto kStarsCount = 5; + + struct Burst { + std::unique_ptr icon; + QPoint center; + crl::time started = 0; + }; + + struct Star { + std::unique_ptr ripple; + Ui::Animations::Simple filled; + Ui::Animations::Simple pressed; + float64 filledTo = 0.; + float64 pressedTo = 1.; + }; + + void paintEvent(QPaintEvent *e) override; + void mouseMoveEvent(QMouseEvent *e) override; + void mousePressEvent(QMouseEvent *e) override; + void mouseReleaseEvent(QMouseEvent *e) override; + void leaveEventHook(QEvent *e) override; + + void paintCard(QPainter &p, float64 shown); + void paintStar(QPainter &p, int index, float64 shown); + void paintBurst(QPainter &p); + + void setOver(int index); + void applyPreview(int preview); + void commit(int index); + void startBurst(int index); + + [[nodiscard]] QRect cardRect() const; + [[nodiscard]] QRect starRect(int index) const; + [[nodiscard]] int starByPosition(QPoint position) const; + [[nodiscard]] QColor textColor() const; + [[nodiscard]] float64 cardShown() const; + [[nodiscard]] float64 starShown(int index) const; + [[nodiscard]] const QImage &starImage(bool filled) const; + + Ui::Text::String _title; + Ui::Text::String _description; + std::array _stars; + std::vector _bursts; + Ui::Animations::Simple _showAnimation; + std::optional _textColorOverride; + QColor _cardOverlay; + mutable QImage _starOutline; + mutable QImage _starFilled; + mutable QColor _starColor; + rpl::variable _rating = 0; + int _preview = 0; + int _over = -1; + int _pressed = -1; + +}; + +class EndCloseButton final : public Ui::RippleButton { +public: + explicit EndCloseButton(QWidget *parent); + + void switchToClose(QRect hangupCircle, Fn close); + +private: + void paintEvent(QPaintEvent *e) override; + + QImage prepareRippleMask() const override; + + void prepareFrame(); + + const QString _text; + QRect _from; + Ui::Animations::Simple _animation; + QImage _frame; + +}; + +} // namespace Calls