Implement stealth mode in stories.
|
@ -997,6 +997,8 @@ PRIVATE
|
||||||
media/stories/media_stories_sibling.h
|
media/stories/media_stories_sibling.h
|
||||||
media/stories/media_stories_slider.cpp
|
media/stories/media_stories_slider.cpp
|
||||||
media/stories/media_stories_slider.h
|
media/stories/media_stories_slider.h
|
||||||
|
media/stories/media_stories_stealth.cpp
|
||||||
|
media/stories/media_stories_stealth.h
|
||||||
media/stories/media_stories_view.cpp
|
media/stories/media_stories_view.cpp
|
||||||
media/stories/media_stories_view.h
|
media/stories/media_stories_view.h
|
||||||
media/streaming/media_streaming_audio_track.cpp
|
media/streaming/media_streaming_audio_track.cpp
|
||||||
|
|
BIN
Telegram/Resources/icons/mediaview/download_locked.png
Normal file
After Width: | Height: | Size: 508 B |
BIN
Telegram/Resources/icons/mediaview/download_locked@2x.png
Normal file
After Width: | Height: | Size: 915 B |
BIN
Telegram/Resources/icons/mediaview/download_locked@3x.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
Telegram/Resources/icons/menu/download_locked.png
Normal file
After Width: | Height: | Size: 516 B |
BIN
Telegram/Resources/icons/menu/download_locked@2x.png
Normal file
After Width: | Height: | Size: 870 B |
BIN
Telegram/Resources/icons/menu/download_locked@3x.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
Telegram/Resources/icons/menu/stealth.png
Normal file
After Width: | Height: | Size: 635 B |
BIN
Telegram/Resources/icons/menu/stealth@2x.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
Telegram/Resources/icons/menu/stealth@3x.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
Telegram/Resources/icons/menu/stealth_locked.png
Normal file
After Width: | Height: | Size: 726 B |
BIN
Telegram/Resources/icons/menu/stealth_locked@2x.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
Telegram/Resources/icons/menu/stealth_locked@3x.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 438 B After Width: | Height: | Size: 438 B |
Before Width: | Height: | Size: 806 B After Width: | Height: | Size: 806 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
BIN
Telegram/Resources/icons/stories/stealth_25m.png
Normal file
After Width: | Height: | Size: 885 B |
BIN
Telegram/Resources/icons/stories/stealth_25m@2x.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
Telegram/Resources/icons/stories/stealth_25m@3x.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
Telegram/Resources/icons/stories/stealth_5m.png
Normal file
After Width: | Height: | Size: 791 B |
BIN
Telegram/Resources/icons/stories/stealth_5m@2x.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
Telegram/Resources/icons/stories/stealth_5m@3x.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
Telegram/Resources/icons/stories/stealth_logo.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
Telegram/Resources/icons/stories/stealth_logo@2x.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
Telegram/Resources/icons/stories/stealth_logo@3x.png
Normal file
After Width: | Height: | Size: 4.3 KiB |
|
@ -3877,6 +3877,24 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
||||||
"lng_stories_archive_done_many#one" = "{count} story is hidden from your profile.";
|
"lng_stories_archive_done_many#one" = "{count} story is hidden from your profile.";
|
||||||
"lng_stories_archive_done_many#other" = "{count} stories are hidden from your profile.";
|
"lng_stories_archive_done_many#other" = "{count} stories are hidden from your profile.";
|
||||||
|
|
||||||
|
"lng_stealth_mode_menu_item" = "Stealth Mode";
|
||||||
|
"lng_stealth_mode_title" = "Stealth Mode";
|
||||||
|
"lng_stealth_mode_unlock_about" = "Subscribe to Telegram Premium to hide the fact that you viewed peoples' stories from them.";
|
||||||
|
"lng_stealth_mode_about" = "Turn Stealth Mode on to hide the fact that you viewed peoples' stories from them.";
|
||||||
|
"lng_stealth_mode_past_title" = "Hide Recent Views";
|
||||||
|
"lng_stealth_mode_past_about" = "Hide my views in the last 5 minutes.";
|
||||||
|
"lng_stealth_mode_next_title" = "Hide Next Views";
|
||||||
|
"lng_stealth_mode_next_about" = "Hide my views in the next 25 minutes.";
|
||||||
|
"lng_stealth_mode_unlock" = "Unlock Stealth Mode";
|
||||||
|
"lng_stealth_mode_enable" = "Enable Stealth Mode";
|
||||||
|
"lng_stealth_mode_cooldown_in" = "Available in {left}";
|
||||||
|
"lng_stealth_mode_cooldown_tip" = "Please wait until the **Stealth Mode** is ready to use again.";
|
||||||
|
"lng_stealth_mode_enabled_tip_title" = "Stealth Mode On";
|
||||||
|
"lng_stealth_mode_enabled_tip" = "The creators of stories you viewed in the last **5 minutes** or will view in the next **25 minutes** won't see you in the viewers' lists.";
|
||||||
|
"lng_stealth_mode_countdown" = "Stealth Mode active – {left}";
|
||||||
|
"lng_stealth_mode_already_title" = "You are in Stealth Mode";
|
||||||
|
"lng_stealth_mode_already_about" = "The creators of stories you will view in the next **{left}** won't see you in the viewers' lists.";
|
||||||
|
|
||||||
"lng_stories_link_invalid" = "This link is broken or has expired.";
|
"lng_stories_link_invalid" = "This link is broken or has expired.";
|
||||||
|
|
||||||
// Wnd specific
|
// Wnd specific
|
||||||
|
|
|
@ -2528,6 +2528,11 @@ void Updates::feedUpdate(const MTPUpdate &update) {
|
||||||
_session->data().stories().apply(update.c_updateReadStories());
|
_session->data().stories().apply(update.c_updateReadStories());
|
||||||
} break;
|
} break;
|
||||||
|
|
||||||
|
case mtpc_updateStoriesStealthMode: {
|
||||||
|
const auto &data = update.c_updateStoriesStealthMode();
|
||||||
|
_session->data().stories().apply(data.vstealth_mode());
|
||||||
|
} break;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -174,6 +174,14 @@ void Stories::apply(const MTPDupdateReadStories &data) {
|
||||||
bumpReadTill(peerFromUser(data.vuser_id()), data.vmax_id().v);
|
bumpReadTill(peerFromUser(data.vuser_id()), data.vmax_id().v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Stories::apply(const MTPStoriesStealthMode &stealthMode) {
|
||||||
|
const auto &data = stealthMode.data();
|
||||||
|
_stealthMode = StealthMode{
|
||||||
|
.enabledTill = data.vactive_until_date().value_or_empty(),
|
||||||
|
.cooldownTill = data.vcooldown_until_date().value_or_empty(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
void Stories::apply(not_null<PeerData*> peer, const MTPUserStories *data) {
|
void Stories::apply(not_null<PeerData*> peer, const MTPUserStories *data) {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
applyDeletedFromSources(peer->id, StorySourcesList::NotHidden);
|
applyDeletedFromSources(peer->id, StorySourcesList::NotHidden);
|
||||||
|
@ -536,6 +544,10 @@ void Stories::loadMore(StorySourcesList list) {
|
||||||
}, [](const MTPDstories_allStoriesNotModified &) {
|
}, [](const MTPDstories_allStoriesNotModified &) {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
result.match([&](const auto &data) {
|
||||||
|
apply(data.vstealth_mode());
|
||||||
|
});
|
||||||
|
|
||||||
preloadListsMore();
|
preloadListsMore();
|
||||||
}).fail([=] {
|
}).fail([=] {
|
||||||
_loadMoreRequestId[index] = 0;
|
_loadMoreRequestId[index] = 0;
|
||||||
|
@ -719,6 +731,7 @@ void Stories::applyDeleted(FullStoryId id) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (_preloading && _preloading->id() == id) {
|
if (_preloading && _preloading->id() == id) {
|
||||||
|
_preloading = nullptr;
|
||||||
preloadFinished(id);
|
preloadFinished(id);
|
||||||
}
|
}
|
||||||
_owner->refreshStoryItemViews(id);
|
_owner->refreshStoryItemViews(id);
|
||||||
|
@ -836,6 +849,26 @@ std::shared_ptr<HistoryItem> Stories::lookupItem(not_null<Story*> story) {
|
||||||
return j->second.lock();
|
return j->second.lock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StealthMode Stories::stealthMode() const {
|
||||||
|
return _stealthMode.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
rpl::producer<StealthMode> Stories::stealthModeValue() const {
|
||||||
|
return _stealthMode.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Stories::activateStealthMode(Fn<void()> done) {
|
||||||
|
const auto api = &session().api();
|
||||||
|
using Flag = MTPstories_ActivateStealthMode::Flag;
|
||||||
|
api->request(MTPstories_ActivateStealthMode(
|
||||||
|
MTP_flags(Flag::f_past | Flag::f_future)
|
||||||
|
)).done([=](const MTPBool &result) {
|
||||||
|
if (done) done();
|
||||||
|
}).fail([=] {
|
||||||
|
if (done) done();
|
||||||
|
}).send();
|
||||||
|
}
|
||||||
|
|
||||||
std::shared_ptr<HistoryItem> Stories::resolveItem(not_null<Story*> story) {
|
std::shared_ptr<HistoryItem> Stories::resolveItem(not_null<Story*> story) {
|
||||||
auto &items = _items[story->peer()->id];
|
auto &items = _items[story->peer()->id];
|
||||||
auto i = items.find(story->id());
|
auto i = items.find(story->id());
|
||||||
|
|
|
@ -116,6 +116,14 @@ struct StoriesContext {
|
||||||
friend inline bool operator==(StoriesContext, StoriesContext) = default;
|
friend inline bool operator==(StoriesContext, StoriesContext) = default;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct StealthMode {
|
||||||
|
TimeId enabledTill = 0;
|
||||||
|
TimeId cooldownTill = 0;
|
||||||
|
|
||||||
|
friend inline auto operator<=>(StealthMode, StealthMode) = default;
|
||||||
|
friend inline bool operator==(StealthMode, StealthMode) = default;
|
||||||
|
};
|
||||||
|
|
||||||
inline constexpr auto kStorySourcesListCount = 2;
|
inline constexpr auto kStorySourcesListCount = 2;
|
||||||
|
|
||||||
class Stories final : public base::has_weak_ptr {
|
class Stories final : public base::has_weak_ptr {
|
||||||
|
@ -139,6 +147,7 @@ public:
|
||||||
void loadMore(StorySourcesList list);
|
void loadMore(StorySourcesList list);
|
||||||
void apply(const MTPDupdateStory &data);
|
void apply(const MTPDupdateStory &data);
|
||||||
void apply(const MTPDupdateReadStories &data);
|
void apply(const MTPDupdateReadStories &data);
|
||||||
|
void apply(const MTPStoriesStealthMode &stealthMode);
|
||||||
void apply(not_null<PeerData*> peer, const MTPUserStories *data);
|
void apply(not_null<PeerData*> peer, const MTPUserStories *data);
|
||||||
Story *applyFromWebpage(PeerId peerId, const MTPstoryItem &story);
|
Story *applyFromWebpage(PeerId peerId, const MTPstoryItem &story);
|
||||||
void loadAround(FullStoryId id, StoriesContext context);
|
void loadAround(FullStoryId id, StoriesContext context);
|
||||||
|
@ -227,6 +236,10 @@ public:
|
||||||
[[nodiscard]] std::shared_ptr<HistoryItem> lookupItem(
|
[[nodiscard]] std::shared_ptr<HistoryItem> lookupItem(
|
||||||
not_null<Story*> story);
|
not_null<Story*> story);
|
||||||
|
|
||||||
|
[[nodiscard]] StealthMode stealthMode() const;
|
||||||
|
[[nodiscard]] rpl::producer<StealthMode> stealthModeValue() const;
|
||||||
|
void activateStealthMode(Fn<void()> done = nullptr);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct Saved {
|
struct Saved {
|
||||||
StoriesIds ids;
|
StoriesIds ids;
|
||||||
|
@ -375,6 +388,8 @@ private:
|
||||||
base::Timer _pollingTimer;
|
base::Timer _pollingTimer;
|
||||||
base::Timer _pollingViewsTimer;
|
base::Timer _pollingViewsTimer;
|
||||||
|
|
||||||
|
rpl::variable<StealthMode> _stealthMode;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace Data
|
} // namespace Data
|
||||||
|
|
|
@ -35,6 +35,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
||||||
#include "media/stories/media_stories_recent_views.h"
|
#include "media/stories/media_stories_recent_views.h"
|
||||||
#include "media/stories/media_stories_reply.h"
|
#include "media/stories/media_stories_reply.h"
|
||||||
#include "media/stories/media_stories_share.h"
|
#include "media/stories/media_stories_share.h"
|
||||||
|
#include "media/stories/media_stories_stealth.h"
|
||||||
#include "media/stories/media_stories_view.h"
|
#include "media/stories/media_stories_view.h"
|
||||||
#include "media/audio/media_audio.h"
|
#include "media/audio/media_audio.h"
|
||||||
#include "ui/boxes/confirm_box.h"
|
#include "ui/boxes/confirm_box.h"
|
||||||
|
@ -1522,6 +1523,17 @@ void Controller::tryProcessKeyInput(not_null<QKeyEvent*> e) {
|
||||||
_replyArea->tryProcessKeyInput(e);
|
_replyArea->tryProcessKeyInput(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Controller::allowStealthMode() const {
|
||||||
|
const auto story = this->story();
|
||||||
|
return story
|
||||||
|
&& !story->peer()->isSelf()
|
||||||
|
&& story->peer()->session().premiumPossible();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Controller::setupStealthMode() {
|
||||||
|
SetupStealthMode(uiShow());
|
||||||
|
}
|
||||||
|
|
||||||
rpl::lifetime &Controller::lifetime() {
|
rpl::lifetime &Controller::lifetime() {
|
||||||
return _lifetime;
|
return _lifetime;
|
||||||
}
|
}
|
||||||
|
|
|
@ -167,6 +167,9 @@ public:
|
||||||
[[nodiscard]] bool ignoreWindowMove(QPoint position) const;
|
[[nodiscard]] bool ignoreWindowMove(QPoint position) const;
|
||||||
void tryProcessKeyInput(not_null<QKeyEvent*> e);
|
void tryProcessKeyInput(not_null<QKeyEvent*> e);
|
||||||
|
|
||||||
|
[[nodiscard]] bool allowStealthMode() const;
|
||||||
|
void setupStealthMode();
|
||||||
|
|
||||||
[[nodiscard]] rpl::lifetime &lifetime();
|
[[nodiscard]] rpl::lifetime &lifetime();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
407
Telegram/SourceFiles/media/stories/media_stories_stealth.cpp
Normal file
|
@ -0,0 +1,407 @@
|
||||||
|
/*
|
||||||
|
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 "media/stories/media_stories_stealth.h"
|
||||||
|
|
||||||
|
#include "base/timer_rpl.h"
|
||||||
|
#include "base/unixtime.h"
|
||||||
|
#include "chat_helpers/compose/compose_show.h"
|
||||||
|
#include "data/data_peer_values.h"
|
||||||
|
#include "data/data_session.h"
|
||||||
|
#include "data/data_stories.h"
|
||||||
|
#include "info/profile/info_profile_icon.h"
|
||||||
|
#include "lang/lang_keys.h"
|
||||||
|
#include "main/main_session.h"
|
||||||
|
#include "settings/settings_premium.h"
|
||||||
|
#include "ui/layers/generic_box.h"
|
||||||
|
#include "ui/text/text_utilities.h"
|
||||||
|
#include "ui/toast/toast.h"
|
||||||
|
#include "ui/widgets/buttons.h"
|
||||||
|
#include "ui/painter.h"
|
||||||
|
#include "window/window_controller.h"
|
||||||
|
#include "window/window_session_controller.h"
|
||||||
|
#include "styles/style_media_view.h"
|
||||||
|
#include "styles/style_layers.h"
|
||||||
|
|
||||||
|
namespace Media::Stories {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr auto kAlreadyToastDuration = 4 * crl::time(1000);
|
||||||
|
constexpr auto kCooldownButtonLabelOpacity = 0.5;
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
Data::StealthMode mode;
|
||||||
|
TimeId now = 0;
|
||||||
|
bool premium = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Feature {
|
||||||
|
const style::icon &icon;
|
||||||
|
QString title;
|
||||||
|
TextWithEntities about;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] QString LeftText(int left) {
|
||||||
|
Expects(left >= 0);
|
||||||
|
|
||||||
|
const auto hours = left / 3600;
|
||||||
|
const auto minutes = (left % 3600) / 60;
|
||||||
|
const auto seconds = left % 60;
|
||||||
|
const auto zero = QChar('0');
|
||||||
|
if (hours) {
|
||||||
|
return u"%1:%2:%3"_q
|
||||||
|
.arg(hours)
|
||||||
|
.arg(minutes, 2, 10, zero)
|
||||||
|
.arg(seconds, 2, 10, zero);
|
||||||
|
} else if (minutes) {
|
||||||
|
return u"%1:%2"_q.arg(minutes).arg(seconds, 2, 10, zero);
|
||||||
|
}
|
||||||
|
return u"0:%1"_q.arg(left, 2, 10, zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] Ui::Toast::Config ToastAlready(TimeId left) {
|
||||||
|
return {
|
||||||
|
.title = tr::lng_stealth_mode_already_title(tr::now),
|
||||||
|
.text = tr::lng_stealth_mode_already_about(
|
||||||
|
tr::now,
|
||||||
|
lt_left,
|
||||||
|
TextWithEntities{ LeftText(left) },
|
||||||
|
Ui::Text::RichLangValue),
|
||||||
|
.st = &st::storiesStealthToast,
|
||||||
|
.duration = kAlreadyToastDuration,
|
||||||
|
.adaptive = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] Ui::Toast::Config ToastActivated() {
|
||||||
|
return {
|
||||||
|
.title = tr::lng_stealth_mode_enabled_tip_title(tr::now),
|
||||||
|
.text = tr::lng_stealth_mode_enabled_tip(
|
||||||
|
tr::now,
|
||||||
|
Ui::Text::RichLangValue),
|
||||||
|
.st = &st::storiesStealthToast,
|
||||||
|
.duration = kAlreadyToastDuration,
|
||||||
|
.adaptive = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] Ui::Toast::Config ToastCooldown() {
|
||||||
|
return {
|
||||||
|
.text = tr::lng_stealth_mode_cooldown_tip(
|
||||||
|
tr::now,
|
||||||
|
Ui::Text::RichLangValue),
|
||||||
|
.st = &st::storiesStealthToast,
|
||||||
|
.duration = kAlreadyToastDuration,
|
||||||
|
.adaptive = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] rpl::producer<State> StateValue(
|
||||||
|
not_null<Main::Session*> session) {
|
||||||
|
return rpl::combine(
|
||||||
|
session->data().stories().stealthModeValue(),
|
||||||
|
Data::AmPremiumValue(session)
|
||||||
|
) | rpl::map([](Data::StealthMode mode, bool premium) {
|
||||||
|
return rpl::make_producer<State>([=](auto consumer) {
|
||||||
|
struct Info {
|
||||||
|
base::Timer timer;
|
||||||
|
bool firstSent = false;
|
||||||
|
bool enabledSent = false;
|
||||||
|
bool cooldownSent = false;
|
||||||
|
};
|
||||||
|
auto lifetime = rpl::lifetime();
|
||||||
|
const auto info = lifetime.make_state<Info>();
|
||||||
|
const auto check = [=] {
|
||||||
|
auto send = !info->firstSent;
|
||||||
|
const auto now = base::unixtime::now();
|
||||||
|
const auto left1 = (mode.enabledTill - now);
|
||||||
|
const auto left2 = (mode.cooldownTill - now);
|
||||||
|
info->firstSent = true;
|
||||||
|
if (!info->enabledSent && left1 <= 0) {
|
||||||
|
send = true;
|
||||||
|
info->enabledSent = true;
|
||||||
|
}
|
||||||
|
if (!info->cooldownSent && left2 <= 0) {
|
||||||
|
send = true;
|
||||||
|
info->cooldownSent = true;
|
||||||
|
}
|
||||||
|
const auto left = (left1 <= 0)
|
||||||
|
? left2
|
||||||
|
: (left2 <= 0)
|
||||||
|
? left1
|
||||||
|
: std::min(left1, left2);
|
||||||
|
if (left > 0) {
|
||||||
|
info->timer.callOnce(left * crl::time(1000));
|
||||||
|
}
|
||||||
|
if (send) {
|
||||||
|
consumer.put_next(State{ mode, now, premium });
|
||||||
|
}
|
||||||
|
if (left <= 0) {
|
||||||
|
consumer.put_done();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info->timer.setCallback(check);
|
||||||
|
check();
|
||||||
|
return lifetime;
|
||||||
|
});
|
||||||
|
}) | rpl::flatten_latest();
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] Feature FeaturePast() {
|
||||||
|
return {
|
||||||
|
.icon = st::storiesStealthFeaturePastIcon,
|
||||||
|
.title = tr::lng_stealth_mode_past_title(tr::now),
|
||||||
|
.about = tr::lng_stealth_mode_past_about(tr::now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] Feature FeatureNext() {
|
||||||
|
return {
|
||||||
|
.icon = st::storiesStealthFeatureNextIcon,
|
||||||
|
.title = tr::lng_stealth_mode_next_title(tr::now),
|
||||||
|
.about = tr::lng_stealth_mode_next_about(tr::now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] object_ptr<Ui::RpWidget> MakeLogo(QWidget *parent) {
|
||||||
|
const auto add = st::storiesStealthLogoAdd;
|
||||||
|
const auto icon = &st::storiesStealthLogoIcon;
|
||||||
|
const auto size = QSize(2 * add, 2 * add) + icon->size();
|
||||||
|
auto result = object_ptr<Ui::PaddingWrap<Ui::RpWidget>>(
|
||||||
|
parent,
|
||||||
|
object_ptr<Ui::RpWidget>(parent),
|
||||||
|
st::storiesStealthLogoMargin);
|
||||||
|
const auto inner = result->entity();
|
||||||
|
inner->resize(size);
|
||||||
|
inner->paintRequest(
|
||||||
|
) | rpl::start_with_next([=] {
|
||||||
|
auto p = QPainter(inner);
|
||||||
|
auto hq = PainterHighQualityEnabler(p);
|
||||||
|
p.setBrush(st::storiesComposeBlue);
|
||||||
|
p.setPen(Qt::NoPen);
|
||||||
|
const auto left = (inner->width() - size.width()) / 2;
|
||||||
|
const auto top = (inner->height() - size.height()) / 2;
|
||||||
|
const auto rect = QRect(QPoint(left, top), size);
|
||||||
|
p.drawEllipse(rect);
|
||||||
|
icon->paintInCenter(p, rect);
|
||||||
|
}, inner->lifetime());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] object_ptr<Ui::RpWidget> MakeTitle(QWidget *parent) {
|
||||||
|
return object_ptr<Ui::PaddingWrap<Ui::FlatLabel>>(
|
||||||
|
parent,
|
||||||
|
object_ptr<Ui::FlatLabel>(
|
||||||
|
parent,
|
||||||
|
tr::lng_stealth_mode_title(tr::now),
|
||||||
|
st::storiesStealthBox.title),
|
||||||
|
st::storiesStealthTitleMargin);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] object_ptr<Ui::RpWidget> MakeAbout(
|
||||||
|
QWidget *parent,
|
||||||
|
rpl::producer<State> state) {
|
||||||
|
auto text = std::move(state) | rpl::map([](const State &state) {
|
||||||
|
return state.premium
|
||||||
|
? tr::lng_stealth_mode_about(tr::now)
|
||||||
|
: tr::lng_stealth_mode_unlock_about(tr::now);
|
||||||
|
});
|
||||||
|
return object_ptr<Ui::PaddingWrap<Ui::FlatLabel>>(
|
||||||
|
parent,
|
||||||
|
object_ptr<Ui::FlatLabel>(
|
||||||
|
parent,
|
||||||
|
std::move(text),
|
||||||
|
st::storiesStealthAbout),
|
||||||
|
st::storiesStealthAboutMargin);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] object_ptr<Ui::RpWidget> MakeFeature(
|
||||||
|
QWidget *parent,
|
||||||
|
Feature feature) {
|
||||||
|
auto result = object_ptr<Ui::PaddingWrap<>>(
|
||||||
|
parent,
|
||||||
|
object_ptr<Ui::RpWidget>(parent),
|
||||||
|
st::storiesStealthFeatureMargin);
|
||||||
|
const auto widget = result->entity();
|
||||||
|
const auto icon = Ui::CreateChild<Info::Profile::FloatingIcon>(
|
||||||
|
widget,
|
||||||
|
feature.icon,
|
||||||
|
st::storiesStealthFeatureIconPosition);
|
||||||
|
const auto title = Ui::CreateChild<Ui::FlatLabel>(
|
||||||
|
widget,
|
||||||
|
feature.title,
|
||||||
|
st::storiesStealthFeatureTitle);
|
||||||
|
const auto about = Ui::CreateChild<Ui::FlatLabel>(
|
||||||
|
widget,
|
||||||
|
rpl::single(feature.about),
|
||||||
|
st::storiesStealthFeatureAbout);
|
||||||
|
icon->show();
|
||||||
|
title->show();
|
||||||
|
about->show();
|
||||||
|
widget->widthValue(
|
||||||
|
) | rpl::start_with_next([=](int width) {
|
||||||
|
const auto left = st::storiesStealthFeatureLabelLeft;
|
||||||
|
const auto available = width - left;
|
||||||
|
title->resizeToWidth(available);
|
||||||
|
about->resizeToWidth(available);
|
||||||
|
auto top = 0;
|
||||||
|
title->move(left, top);
|
||||||
|
top += title->height() + st::storiesStealthFeatureSkip;
|
||||||
|
about->move(left, top);
|
||||||
|
top += about->height();
|
||||||
|
widget->resize(width, top);
|
||||||
|
}, widget->lifetime());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] object_ptr<Ui::RoundButton> MakeButton(
|
||||||
|
QWidget *parent,
|
||||||
|
rpl::producer<State> state) {
|
||||||
|
auto text = rpl::duplicate(state) | rpl::map([](const State &state) {
|
||||||
|
if (!state.premium) {
|
||||||
|
return tr::lng_stealth_mode_unlock();
|
||||||
|
} else if (state.mode.cooldownTill <= state.now) {
|
||||||
|
return tr::lng_stealth_mode_enable();
|
||||||
|
}
|
||||||
|
return rpl::single(
|
||||||
|
rpl::empty
|
||||||
|
) | rpl::then(
|
||||||
|
base::timer_each(250)
|
||||||
|
) | rpl::map([=] {
|
||||||
|
const auto now = base::unixtime::now();
|
||||||
|
const auto left = std::max(state.mode.cooldownTill - now, 1);
|
||||||
|
return tr::lng_stealth_mode_cooldown_in(
|
||||||
|
tr::now,
|
||||||
|
lt_left,
|
||||||
|
LeftText(left));
|
||||||
|
}) | rpl::type_erased();
|
||||||
|
}) | rpl::flatten_latest();
|
||||||
|
|
||||||
|
auto result = object_ptr<Ui::RoundButton>(
|
||||||
|
parent,
|
||||||
|
rpl::single(QString()),
|
||||||
|
st::storiesStealthBox.button);
|
||||||
|
const auto raw = result.data();
|
||||||
|
|
||||||
|
const auto label = Ui::CreateChild<Ui::FlatLabel>(
|
||||||
|
raw,
|
||||||
|
std::move(text),
|
||||||
|
st::storiesStealthButtonLabel);
|
||||||
|
label->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||||
|
label->show();
|
||||||
|
|
||||||
|
const auto lock = Ui::CreateChild<Ui::RpWidget>(raw);
|
||||||
|
lock->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||||
|
lock->resize(st::storiesStealthLockIcon.size());
|
||||||
|
lock->paintRequest(
|
||||||
|
) | rpl::start_with_next([=] {
|
||||||
|
auto p = QPainter(lock);
|
||||||
|
st::storiesStealthLockIcon.paintInCenter(p, lock->rect());
|
||||||
|
}, lock->lifetime());
|
||||||
|
|
||||||
|
const auto lockLeft = -st::storiesStealthButtonLabel.style.font->height;
|
||||||
|
const auto updateLabelLockGeometry = [=] {
|
||||||
|
const auto outer = raw->width();
|
||||||
|
const auto added = -st::storiesStealthBox.button.width;
|
||||||
|
const auto skip = lock->isHidden() ? 0 : (lockLeft + lock->width());
|
||||||
|
const auto width = outer - added - skip;
|
||||||
|
const auto top = st::storiesStealthBox.button.textTop;
|
||||||
|
label->resizeToWidth(width);
|
||||||
|
label->move(added / 2, top);
|
||||||
|
const auto inner = std::min(label->textMaxWidth(), width);
|
||||||
|
const auto right = (added / 2) + (width - inner) / 2 + inner;
|
||||||
|
const auto lockTop = (label->height() - lock->height()) / 2;
|
||||||
|
lock->move(right + lockLeft, top + lockTop);
|
||||||
|
};
|
||||||
|
|
||||||
|
std::move(state) | rpl::start_with_next([=](const State &state) {
|
||||||
|
const auto cooldown = state.premium
|
||||||
|
&& (state.mode.cooldownTill > state.now);
|
||||||
|
label->setOpacity(cooldown ? kCooldownButtonLabelOpacity : 1.);
|
||||||
|
lock->setVisible(!state.premium);
|
||||||
|
updateLabelLockGeometry();
|
||||||
|
}, label->lifetime());
|
||||||
|
|
||||||
|
raw->widthValue(
|
||||||
|
) | rpl::start_with_next(updateLabelLockGeometry, label->lifetime());
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] object_ptr<Ui::BoxContent> StealthModeBox(
|
||||||
|
std::shared_ptr<ChatHelpers::Show> show) {
|
||||||
|
return Box([=](not_null<Ui::GenericBox*> box) {
|
||||||
|
struct Data {
|
||||||
|
rpl::variable<State> state;
|
||||||
|
bool requested = false;
|
||||||
|
};
|
||||||
|
const auto data = box->lifetime().make_state<Data>();
|
||||||
|
data->state = StateValue(&show->session());
|
||||||
|
box->setWidth(st::boxWideWidth);
|
||||||
|
box->setStyle(st::storiesStealthBox);
|
||||||
|
box->addRow(MakeLogo(box));
|
||||||
|
box->addRow(MakeTitle(box));
|
||||||
|
box->addRow(MakeAbout(box, data->state.value()));
|
||||||
|
box->addRow(MakeFeature(box, FeaturePast()));
|
||||||
|
box->addRow(
|
||||||
|
MakeFeature(box, FeatureNext()),
|
||||||
|
(st::boxRowPadding
|
||||||
|
+ QMargins(0, 0, 0, st::storiesStealthBoxBottom)));
|
||||||
|
box->setNoContentMargin(true);
|
||||||
|
box->addTopButton(st::storiesStealthBoxClose, [=] {
|
||||||
|
box->closeBox();
|
||||||
|
});
|
||||||
|
const auto button = box->addButton(
|
||||||
|
MakeButton(box, data->state.value()));
|
||||||
|
button->resizeToWidth(st::boxWideWidth
|
||||||
|
- st::storiesStealthBox.buttonPadding.left()
|
||||||
|
- st::storiesStealthBox.buttonPadding.right());
|
||||||
|
button->setClickedCallback([=] {
|
||||||
|
const auto now = data->state.current();
|
||||||
|
if (now.mode.enabledTill > now.now) {
|
||||||
|
show->showToast(ToastActivated());
|
||||||
|
box->closeBox();
|
||||||
|
} else if (!now.premium) {
|
||||||
|
data->requested = false;
|
||||||
|
const auto usage = ChatHelpers::WindowUsage::PremiumPromo;
|
||||||
|
if (const auto window = show->resolveWindow(usage)) {
|
||||||
|
Settings::ShowPremium(
|
||||||
|
window,
|
||||||
|
u"stories_stealth_mode"_q);
|
||||||
|
window->window().activate();
|
||||||
|
}
|
||||||
|
} else if (now.mode.cooldownTill > now.now) {
|
||||||
|
show->showToast(ToastCooldown());
|
||||||
|
box->closeBox();
|
||||||
|
} else if (!data->requested) {
|
||||||
|
data->requested = true;
|
||||||
|
show->session().data().stories().activateStealthMode(
|
||||||
|
crl::guard(box, [=] { data->requested = false; }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
data->state.value() | rpl::filter([](const State &state) {
|
||||||
|
return state.mode.enabledTill > state.now;
|
||||||
|
}) | rpl::start_with_next([=] {
|
||||||
|
box->closeBox();
|
||||||
|
show->showToast(ToastActivated());
|
||||||
|
}, box->lifetime());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void SetupStealthMode(std::shared_ptr<ChatHelpers::Show> show) {
|
||||||
|
const auto now = base::unixtime::now();
|
||||||
|
const auto mode = show->session().data().stories().stealthMode();
|
||||||
|
if (const auto left = mode.enabledTill - now; left > 0) {
|
||||||
|
show->showToast(ToastAlready(left));
|
||||||
|
} else {
|
||||||
|
show->show(StealthModeBox(show));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Media::Stories
|
18
Telegram/SourceFiles/media/stories/media_stories_stealth.h
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
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
|
||||||
|
|
||||||
|
namespace ChatHelpers {
|
||||||
|
class Show;
|
||||||
|
} // namespace ChatHelpers
|
||||||
|
|
||||||
|
namespace Media::Stories {
|
||||||
|
|
||||||
|
void SetupStealthMode(std::shared_ptr<ChatHelpers::Show> show);
|
||||||
|
|
||||||
|
} // namespace Media::Stories
|
|
@ -111,6 +111,14 @@ void View::tryProcessKeyInput(not_null<QKeyEvent*> e) {
|
||||||
_controller->tryProcessKeyInput(e);
|
_controller->tryProcessKeyInput(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool View::allowStealthMode() const {
|
||||||
|
return _controller->allowStealthMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
void View::setupStealthMode() {
|
||||||
|
_controller->setupStealthMode();
|
||||||
|
}
|
||||||
|
|
||||||
SiblingView View::sibling(SiblingType type) const {
|
SiblingView View::sibling(SiblingType type) const {
|
||||||
return _controller->sibling(type);
|
return _controller->sibling(type);
|
||||||
}
|
}
|
||||||
|
|
|
@ -91,6 +91,9 @@ public:
|
||||||
[[nodiscard]] bool ignoreWindowMove(QPoint position) const;
|
[[nodiscard]] bool ignoreWindowMove(QPoint position) const;
|
||||||
void tryProcessKeyInput(not_null<QKeyEvent*> e);
|
void tryProcessKeyInput(not_null<QKeyEvent*> e);
|
||||||
|
|
||||||
|
[[nodiscard]] bool allowStealthMode() const;
|
||||||
|
void setupStealthMode();
|
||||||
|
|
||||||
[[nodiscard]] rpl::lifetime &lifetime();
|
[[nodiscard]] rpl::lifetime &lifetime();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
|
@ -413,8 +413,8 @@ storiesSiblingWidthMin: 200px; // Try making sibling not less than this.
|
||||||
storiesMaxNameFontSize: 17px;
|
storiesMaxNameFontSize: 17px;
|
||||||
storiesRadius: 8px;
|
storiesRadius: 8px;
|
||||||
storiesControlSize: 64px;
|
storiesControlSize: 64px;
|
||||||
storiesLeft: icon {{ "mediaview/stories_next-flip_horizontal", mediaviewControlFg }};
|
storiesLeft: icon {{ "stories/next-flip_horizontal", mediaviewControlFg }};
|
||||||
storiesRight: icon {{ "mediaview/stories_next", mediaviewControlFg }};
|
storiesRight: icon {{ "stories/next", mediaviewControlFg }};
|
||||||
storiesSliderWidth: 2px;
|
storiesSliderWidth: 2px;
|
||||||
storiesSliderMargin: margins(8px, 7px, 8px, 6px);
|
storiesSliderMargin: margins(8px, 7px, 8px, 6px);
|
||||||
storiesSliderSkip: 4px;
|
storiesSliderSkip: 4px;
|
||||||
|
@ -911,3 +911,73 @@ storiesInfoTooltipMaxWidth: 360px;
|
||||||
storiesCaptionPullThreshold: 50px;
|
storiesCaptionPullThreshold: 50px;
|
||||||
storiesShowMorePadding: margins(6px, 4px, 6px, 4px);
|
storiesShowMorePadding: margins(6px, 4px, 6px, 4px);
|
||||||
storiesShowMoreFont: semiboldFont;
|
storiesShowMoreFont: semiboldFont;
|
||||||
|
|
||||||
|
storiesStealthLogoIcon: icon{{ "stories/stealth_logo", storiesComposeWhiteText }};
|
||||||
|
storiesStealthLogoAdd: 12px;
|
||||||
|
storiesStealthLogoMargin: margins(0px, 28px, 0px, 7px);
|
||||||
|
storiesStealthBox: Box(defaultBox) {
|
||||||
|
buttonPadding: margins(10px, 10px, 10px, 10px);
|
||||||
|
buttonHeight: 42px;
|
||||||
|
button: RoundButton(defaultBoxButton) {
|
||||||
|
height: 42px;
|
||||||
|
textTop: 12px;
|
||||||
|
font: font(13px semibold);
|
||||||
|
|
||||||
|
textFg: storiesComposeWhiteText;
|
||||||
|
textFgOver: storiesComposeWhiteText;
|
||||||
|
numbersTextFg: storiesComposeWhiteText;
|
||||||
|
numbersTextFgOver: storiesComposeWhiteText;
|
||||||
|
textBg: storiesComposeBlue;
|
||||||
|
textBgOver: storiesComposeBlue;
|
||||||
|
|
||||||
|
ripple: universalRippleAnimation;
|
||||||
|
}
|
||||||
|
margin: margins(0px, 56px, 0px, 10px);
|
||||||
|
bg: groupCallMembersBg;
|
||||||
|
title: FlatLabel(boxTitle) {
|
||||||
|
textFg: groupCallMembersFg;
|
||||||
|
align: align(top);
|
||||||
|
}
|
||||||
|
titleAdditionalFg: groupCallMemberNotJoinedStatus;
|
||||||
|
}
|
||||||
|
storiesStealthButtonLabel: FlatLabel(defaultFlatLabel) {
|
||||||
|
style: semiboldTextStyle;
|
||||||
|
textFg: storiesComposeWhiteText;
|
||||||
|
align: align(top);
|
||||||
|
minWidth: 20px;
|
||||||
|
maxHeight: 20px;
|
||||||
|
}
|
||||||
|
storiesStealthLockIcon: icon {{ "dialogs/dialogs_lock_on", storiesComposeWhiteText }};
|
||||||
|
storiesStealthTitleMargin: margins(0px, 10px, 0px, 0px);
|
||||||
|
storiesStealthBoxClose: IconButton(defaultIconButton) {
|
||||||
|
width: boxTitleHeight;
|
||||||
|
height: boxTitleHeight;
|
||||||
|
|
||||||
|
icon: icon {{ "box_button_close", storiesComposeGrayIcon }};
|
||||||
|
iconOver: icon {{ "box_button_close", storiesComposeGrayIcon }};
|
||||||
|
|
||||||
|
rippleAreaPosition: point(4px, 4px);
|
||||||
|
rippleAreaSize: 40px;
|
||||||
|
ripple: storiesComposeRippleLight;
|
||||||
|
}
|
||||||
|
storiesStealthAbout: FlatLabel(defaultFlatLabel) {
|
||||||
|
textFg: storiesComposeGrayText;
|
||||||
|
align: align(top);
|
||||||
|
minWidth: 20px;
|
||||||
|
}
|
||||||
|
storiesStealthAboutMargin: margins(0px, 5px, 0px, 15px);
|
||||||
|
storiesStealthFeatureTitle: storiesHeaderName;
|
||||||
|
storiesStealthFeatureAbout: FlatLabel(defaultFlatLabel) {
|
||||||
|
textFg: storiesComposeGrayText;
|
||||||
|
minWidth: 20px;
|
||||||
|
}
|
||||||
|
storiesStealthFeaturePastIcon: icon{{ "stories/stealth_5m", storiesComposeBlue }};
|
||||||
|
storiesStealthFeatureNextIcon: icon{{ "stories/stealth_25m", storiesComposeBlue }};
|
||||||
|
storiesStealthFeatureIconPosition: point(3px, 7px);
|
||||||
|
storiesStealthFeatureMargin: margins(0px, 8px, 0px, 6px);
|
||||||
|
storiesStealthFeatureLabelLeft: 46px;
|
||||||
|
storiesStealthFeatureSkip: 2px;
|
||||||
|
storiesStealthBoxBottom: 11px;
|
||||||
|
storiesStealthToast: Toast(defaultMultilineToast) {
|
||||||
|
maxWidth: 340px;
|
||||||
|
}
|
||||||
|
|
|
@ -1631,6 +1631,15 @@ void OverlayWidget::fillContextMenuActions(const MenuCallback &addAction) {
|
||||||
}
|
}
|
||||||
}, &st::mediaMenuIconReport);
|
}, &st::mediaMenuIconReport);
|
||||||
}();
|
}();
|
||||||
|
if (_stories && _stories->allowStealthMode()) {
|
||||||
|
const auto now = base::unixtime::now();
|
||||||
|
const auto stealth = _session->data().stories().stealthMode();
|
||||||
|
addAction(tr::lng_stealth_mode_menu_item(tr::now), [=] {
|
||||||
|
_stories->setupStealthMode();
|
||||||
|
}, ((_session->premium() || (stealth.enabledTill > now))
|
||||||
|
? &st::mediaMenuIconStealth
|
||||||
|
: &st::mediaMenuIconStealthLocked));
|
||||||
|
}
|
||||||
if (story && story->canReport()) {
|
if (story && story->canReport()) {
|
||||||
addAction(tr::lng_profile_report(tr::now), [=] {
|
addAction(tr::lng_profile_report(tr::now), [=] {
|
||||||
_stories->reportRequested();
|
_stories->reportRequested();
|
||||||
|
@ -5249,6 +5258,7 @@ void OverlayWidget::setContext(
|
||||||
{ story->peer->id, story->id });
|
{ story->peer->id, story->id });
|
||||||
if (maybeStory) {
|
if (maybeStory) {
|
||||||
_stories->show(*maybeStory, story->within);
|
_stories->show(*maybeStory, story->within);
|
||||||
|
_dropdown->raise();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_message = nullptr;
|
_message = nullptr;
|
||||||
|
@ -5289,7 +5299,6 @@ void OverlayWidget::setStoriesPeer(PeerData *peer) {
|
||||||
updateControlsGeometry();
|
updateControlsGeometry();
|
||||||
}, _stories->lifetime());
|
}, _stories->lifetime());
|
||||||
_storiesChanged.fire({});
|
_storiesChanged.fire({});
|
||||||
_dropdown->raise();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -134,6 +134,8 @@ mediaMenuIconProfile: icon {{ "menu/profile", mediaviewMenuFg }};
|
||||||
mediaMenuIconReport: icon {{ "menu/report", mediaviewMenuFg }};
|
mediaMenuIconReport: icon {{ "menu/report", mediaviewMenuFg }};
|
||||||
mediaMenuIconSaveStory: icon {{ "menu/stories_save", mediaviewMenuFg }};
|
mediaMenuIconSaveStory: icon {{ "menu/stories_save", mediaviewMenuFg }};
|
||||||
mediaMenuIconArchiveStory: icon {{ "menu/stories_archive", mediaviewMenuFg }};
|
mediaMenuIconArchiveStory: icon {{ "menu/stories_archive", mediaviewMenuFg }};
|
||||||
|
mediaMenuIconStealthLocked: icon {{ "menu/stealth_locked", mediaviewMenuFg }};
|
||||||
|
mediaMenuIconStealth: icon {{ "menu/stealth", mediaviewMenuFg }};
|
||||||
|
|
||||||
menuIconDeleteAttention: icon {{ "menu/delete", menuIconAttentionColor }};
|
menuIconDeleteAttention: icon {{ "menu/delete", menuIconAttentionColor }};
|
||||||
menuIconLeaveAttention: icon {{ "menu/leave", menuIconAttentionColor }};
|
menuIconLeaveAttention: icon {{ "menu/leave", menuIconAttentionColor }};
|
||||||
|
|