From 468d8b04d638f2050913eb2e2c7c91953cc926fc Mon Sep 17 00:00:00 2001
From: John Preston <johnprestonmail@gmail.com>
Date: Tue, 16 Apr 2024 19:32:33 +0400
Subject: [PATCH] Implement stories pin-to-top.

---
 Telegram/Resources/langs/lang.strings         |  11 ++
 Telegram/SourceFiles/data/data_stories.cpp    | 143 ++++++++++++++++++
 Telegram/SourceFiles/data/data_stories.h      |  13 ++
 .../SourceFiles/data/data_stories_ids.cpp     |  38 +++--
 Telegram/SourceFiles/data/data_stories_ids.h  |   2 +-
 Telegram/SourceFiles/data/data_story.cpp      |  12 +-
 Telegram/SourceFiles/data/data_story.h        |   4 +
 Telegram/SourceFiles/info/info.style          |  22 +++
 Telegram/SourceFiles/info/info_top_bar.cpp    |  46 +++++-
 Telegram/SourceFiles/info/info_top_bar.h      |   3 +
 Telegram/SourceFiles/info/info_wrap_widget.h  |   2 +
 .../info/media/info_media_common.h            |   1 +
 .../info/media/info_media_list_widget.cpp     | 100 ++++++++++--
 .../info/media/info_media_list_widget.h       |   5 +
 .../info/stories/info_stories_provider.cpp    |  42 ++++-
 .../stories/media_stories_controller.cpp      |  73 ++++++---
 .../media/stories/media_stories_controller.h  |   4 +
 17 files changed, 464 insertions(+), 57 deletions(-)

diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings
index c727d4c5e..4aa6eae95 100644
--- a/Telegram/Resources/langs/lang.strings
+++ b/Telegram/Resources/langs/lang.strings
@@ -3424,6 +3424,17 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
 "lng_mediaview_forward" = "Forward";
 "lng_mediaview_delete" = "Delete";
 "lng_mediaview_save_to_profile" = "Save to Profile";
+"lng_mediaview_pin_story_done" = "Story pinned";
+"lng_mediaview_pin_story_about" = "Now it will be always shown on the top.";
+"lng_mediaview_pin_stories_done#one" = "{count} story pinned";
+"lng_mediaview_pin_stories_done#other" = "{count} stories pinned";
+"lng_mediaview_pin_stories_about#one" = "Now it will be always shown on the top.";
+"lng_mediaview_pin_stories_about#other" = "Now they will be always shown on the top.";
+"lng_mediaview_unpin_story_done" = "Story unpinned.";
+"lng_mediaview_unpin_stories_done#one" = "{count} story unpinned";
+"lng_mediaview_unpin_stories_done#other" = "{count} stories unpinned";
+"lng_mediaview_pin_limit#one" = "You can't pin more than {count} story.";
+"lng_mediaview_pin_limit#other" = "You can't pin more than {count} stories.";
 "lng_mediaview_archive_story" = "Archive Story";
 "lng_mediaview_photos_all" = "View all photos";
 "lng_mediaview_files_all" = "View all files";
diff --git a/Telegram/SourceFiles/data/data_stories.cpp b/Telegram/SourceFiles/data/data_stories.cpp
index 391caba74..7806e1aa6 100644
--- a/Telegram/SourceFiles/data/data_stories.cpp
+++ b/Telegram/SourceFiles/data/data_stories.cpp
@@ -21,6 +21,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
 #include "history/history.h"
 #include "history/history_item.h"
 #include "lang/lang_keys.h"
+#include "main/main_app_config.h"
 #include "main/main_session.h"
 #include "ui/layers/show.h"
 #include "ui/text/text_utilities.h"
@@ -77,6 +78,47 @@ using UpdateFlag = StoryUpdate::Flag;
 
 } // namespace
 
+int IndexRespectingPinned(const StoriesIds &ids, StoryId id) {
+	const auto i = ids.list.find(id);
+	if (ids.pinnedToTop.empty() || i == end(ids.list)) {
+		return int(i - begin(ids.list));
+	}
+	const auto j = ranges::find(ids.pinnedToTop, id);
+	if (j != end(ids.pinnedToTop)) {
+		return int(j - begin(ids.pinnedToTop));
+	}
+	auto result = int(i - begin(ids.list));
+	for (const auto &pinnedId : ids.pinnedToTop) {
+		if (pinnedId < id) {
+			++result;
+		}
+	}
+
+	Ensures(result < int(ids.list.size()));
+	return result;
+}
+
+StoryId IdRespectingPinned(const StoriesIds &ids, int index) {
+	Expects(index >= 0 && index < int(ids.list.size()));
+
+	if (ids.pinnedToTop.empty()) {
+		return *(begin(ids.list) + index);
+	} else if (index < int(ids.pinnedToTop.size())) {
+		return ids.pinnedToTop[index];
+	}
+	auto i = begin(ids.list) + index - int(ids.pinnedToTop.size());
+	auto sorted = ids.pinnedToTop;
+	ranges::sort(sorted, ranges::greater());
+	for (const auto &pinnedId : sorted) {
+		if (pinnedId >= *i) {
+			++i;
+		}
+	}
+
+	Ensures(i != end(ids.list));
+	return *i;
+}
+
 StoriesSourceInfo StoriesSource::info() const {
 	return {
 		.id = peer->id,
@@ -1674,6 +1716,10 @@ void Stories::savedLoadMore(PeerId peerId) {
 
 		const auto &data = result.data();
 		const auto now = base::unixtime::now();
+		auto pinnedToTopIds = data.vpinned_to_top().value_or_empty();
+		auto pinnedToTop = pinnedToTopIds
+			| ranges::views::transform(&MTPint::v)
+			| ranges::to_vector;
 		saved.total = data.vcount().v;
 		for (const auto &story : data.vstories().v) {
 			const auto id = story.match([&](const auto &id) {
@@ -1691,6 +1737,7 @@ void Stories::savedLoadMore(PeerId peerId) {
 		const auto ids = int(saved.ids.list.size());
 		saved.loaded = data.vstories().v.empty();
 		saved.total = saved.loaded ? ids : std::max(saved.total, ids);
+		setPinnedToTop(peerId, std::move(pinnedToTop));
 		_savedChanged.fire_copy(peerId);
 	}).fail([=] {
 		auto &saved = _saved[peerId];
@@ -1701,6 +1748,33 @@ void Stories::savedLoadMore(PeerId peerId) {
 	}).send();
 }
 
+void Stories::setPinnedToTop(
+		PeerId peerId,
+		std::vector<StoryId> &&pinnedToTop) {
+	const auto i = _saved.find(peerId);
+	if (i == end(_saved) && pinnedToTop.empty()) {
+		return;
+	}
+	auto &saved = (i == end(_saved)) ? _saved[peerId] : i->second;
+	if (saved.ids.pinnedToTop != pinnedToTop) {
+		for (const auto id : saved.ids.pinnedToTop) {
+			if (!ranges::contains(pinnedToTop, id)) {
+				if (const auto maybeStory = lookup({ peerId, id })) {
+					(*maybeStory)->setPinnedToTop(false);
+				}
+			}
+		}
+		for (const auto id : pinnedToTop) {
+			if (!ranges::contains(saved.ids.pinnedToTop, id)) {
+				if (const auto maybeStory = lookup({ peerId, id })) {
+					(*maybeStory)->setPinnedToTop(true);
+				}
+			}
+		}
+		saved.ids.pinnedToTop = std::move(pinnedToTop);
+	}
+}
+
 void Stories::deleteList(const std::vector<FullStoryId> &ids) {
 	if (ids.empty()) {
 		return;
@@ -1788,6 +1862,75 @@ void Stories::toggleInProfileList(
 	}).send();
 }
 
+bool Stories::canTogglePinnedList(
+		const std::vector<FullStoryId> &ids,
+		bool pin) const {
+	Expects(!ids.empty());
+
+	if (!pin) {
+		return true;
+	}
+
+	const auto peerId = ids.front().peer;
+	const auto i = _saved.find(peerId);
+	if (i == end(_saved)) {
+		return false;
+	}
+
+	auto &already = i->second.ids.pinnedToTop;
+	auto count = int(already.size());
+	for (const auto &id : ids) {
+		if (!ranges::contains(already, id.story)) {
+			++count;
+		}
+	}
+	return count <= maxPinnedCount();
+}
+
+int Stories::maxPinnedCount() const {
+	const auto appConfig = &_owner->session().appConfig();
+	return appConfig->get<int>(u"stories_pinned_to_top_count_max"_q, 3);
+}
+
+void Stories::togglePinnedList(
+		const std::vector<FullStoryId> &ids,
+		bool pin) {
+	if (ids.empty()) {
+		return;
+	}
+	const auto peerId = ids.front().peer;
+	auto &saved = _saved[peerId];
+	auto list = QVector<MTPint>();
+	list.reserve(maxPinnedCount());
+	for (const auto &id : saved.ids.pinnedToTop) {
+		if (pin || !ranges::contains(ids, FullStoryId{ peerId, id })) {
+			list.push_back(MTP_int(id));
+		}
+	}
+	if (pin) {
+		auto copy = ids;
+		ranges::sort(copy, ranges::greater());
+		for (const auto &id : copy) {
+			if (id.peer == peerId
+				&& !ranges::contains(saved.ids.pinnedToTop, id.story)) {
+				list.push_back(MTP_int(id.story));
+			}
+		}
+	}
+	const auto api = &_owner->session().api();
+	const auto peer = session().data().peer(peerId);
+	api->request(MTPstories_TogglePinnedToTop(
+		peer->input,
+		MTP_vector<MTPint>(list)
+	)).done([=] {
+		setPinnedToTop(peerId, list
+			| ranges::views::transform(&MTPint::v)
+			| ranges::to_vector);
+		_savedChanged.fire_copy(peerId);
+	}).send();
+
+}
+
 void Stories::report(
 		std::shared_ptr<Ui::Show> show,
 		FullStoryId id,
diff --git a/Telegram/SourceFiles/data/data_stories.h b/Telegram/SourceFiles/data/data_stories.h
index 2508b3557..c5f340cc1 100644
--- a/Telegram/SourceFiles/data/data_stories.h
+++ b/Telegram/SourceFiles/data/data_stories.h
@@ -33,12 +33,17 @@ class StoryPreload;
 
 struct StoriesIds {
 	base::flat_set<StoryId, std::greater<>> list;
+	std::vector<StoryId> pinnedToTop;
 
 	friend inline bool operator==(
 		const StoriesIds&,
 		const StoriesIds&) = default;
 };
 
+// ids.list.size() if not found.
+[[nodiscard]] int IndexRespectingPinned(const StoriesIds &ids, StoryId id);
+[[nodiscard]] StoryId IdRespectingPinned(const StoriesIds &ids, int index);
+
 struct StoriesSourceInfo {
 	PeerId id = 0;
 	TimeId last = 0;
@@ -208,6 +213,11 @@ public:
 	void toggleInProfileList(
 		const std::vector<FullStoryId> &ids,
 		bool inProfile);
+	[[nodiscard]] bool canTogglePinnedList(
+		const std::vector<FullStoryId> &ids,
+		bool pin) const;
+	[[nodiscard]] int maxPinnedCount() const;
+	void togglePinnedList(const std::vector<FullStoryId> &ids, bool pin);
 	void report(
 		std::shared_ptr<Ui::Show> show,
 		FullStoryId id,
@@ -314,6 +324,9 @@ private:
 
 	void notifySourcesChanged(StorySourcesList list);
 	void pushHiddenCountsToFolder();
+	void setPinnedToTop(
+		PeerId peerId,
+		std::vector<StoryId> &&pinnedToTop);
 
 	[[nodiscard]] int pollingInterval(
 		const PollingSettings &settings) const;
diff --git a/Telegram/SourceFiles/data/data_stories_ids.cpp b/Telegram/SourceFiles/data/data_stories_ids.cpp
index f8921fe8b..fbff87e09 100644
--- a/Telegram/SourceFiles/data/data_stories_ids.cpp
+++ b/Telegram/SourceFiles/data/data_stories_ids.cpp
@@ -40,18 +40,23 @@ rpl::producer<StoriesIdsSlice> SavedStoriesIds(
 
 			const auto &saved = stories->saved(peerId);
 			const auto count = stories->savedCount(peerId);
-			const auto around = saved.list.lower_bound(aroundId);
-			const auto hasBefore = int(around - begin(saved.list));
-			const auto hasAfter = int(end(saved.list) - around);
+			auto aroundIndex = IndexRespectingPinned(saved, aroundId);
+			if (aroundIndex == int(saved.list.size())) {
+				const auto around = saved.list.lower_bound(aroundId);
+				aroundIndex = int(around - begin(saved.list));
+			}
+			const auto hasBefore = aroundIndex;
+			const auto hasAfter = int(saved.list.size()) - aroundIndex;
 			if (hasAfter < limit) {
 				stories->savedLoadMore(peerId);
 			}
 			const auto takeBefore = std::min(hasBefore, limit);
 			const auto takeAfter = std::min(hasAfter, limit);
-			auto ids = base::flat_set<StoryId>{
-				std::make_reverse_iterator(around + takeAfter),
-				std::make_reverse_iterator(around - takeBefore)
-			};
+			auto ids = std::vector<StoryId>();
+			ids.reserve(takeBefore + takeAfter);
+			for (auto i = aroundIndex - takeBefore; i != aroundIndex + takeAfter; ++i) {
+				ids.push_back(IdRespectingPinned(saved, i));
+			}
 			const auto added = int(ids.size());
 			state->slice = StoriesIdsSlice(
 				std::move(ids),
@@ -114,18 +119,23 @@ rpl::producer<StoriesIdsSlice> ArchiveStoriesIds(
 
 			const auto &archive = stories->archive(peerId);
 			const auto count = stories->archiveCount(peerId);
-			const auto i = archive.list.lower_bound(aroundId);
-			const auto hasBefore = int(i - begin(archive.list));
-			const auto hasAfter = int(end(archive.list) - i);
+			auto aroundIndex = IndexRespectingPinned(archive, aroundId);
+			if (aroundIndex == int(archive.list.size())) {
+				const auto around = archive.list.lower_bound(aroundId);
+				aroundIndex = int(around - begin(archive.list));
+			}
+			const auto hasBefore = aroundIndex;
+			const auto hasAfter = int(archive.list.size()) - aroundIndex;
 			if (hasAfter < limit) {
 				stories->archiveLoadMore(peerId);
 			}
 			const auto takeBefore = std::min(hasBefore, limit);
 			const auto takeAfter = std::min(hasAfter, limit);
-			auto ids = base::flat_set<StoryId>{
-				std::make_reverse_iterator(i + takeAfter),
-				std::make_reverse_iterator(i - takeBefore)
-			};
+			auto ids = std::vector<StoryId>();
+			ids.reserve(takeBefore + takeAfter);
+			for (auto i = aroundIndex - takeBefore; i != aroundIndex + takeAfter; ++i) {
+				ids.push_back(IdRespectingPinned(archive, i));
+			}
 			const auto added = int(ids.size());
 			state->slice = StoriesIdsSlice(
 				std::move(ids),
diff --git a/Telegram/SourceFiles/data/data_stories_ids.h b/Telegram/SourceFiles/data/data_stories_ids.h
index 6c95c16a9..88a5103b3 100644
--- a/Telegram/SourceFiles/data/data_stories_ids.h
+++ b/Telegram/SourceFiles/data/data_stories_ids.h
@@ -17,7 +17,7 @@ class Session;
 
 namespace Data {
 
-using StoriesIdsSlice = AbstractSparseIds<base::flat_set<StoryId>>;
+using StoriesIdsSlice = AbstractSparseIds<std::vector<StoryId>>;
 
 [[nodiscard]] rpl::producer<StoriesIdsSlice> SavedStoriesIds(
 	not_null<PeerData*> peer,
diff --git a/Telegram/SourceFiles/data/data_story.cpp b/Telegram/SourceFiles/data/data_story.cpp
index 6a525a7a6..d191d2360 100644
--- a/Telegram/SourceFiles/data/data_story.cpp
+++ b/Telegram/SourceFiles/data/data_story.cpp
@@ -389,6 +389,14 @@ TextWithEntities Story::inReplyText() const {
 			Ui::Text::WithEntities);
 }
 
+void Story::setPinnedToTop(bool pinned) {
+	_pinnedToTop = pinned;
+}
+
+bool Story::pinnedToTop() const {
+	return _pinnedToTop;
+}
+
 void Story::setInProfile(bool value) {
 	_inProfile = value;
 }
@@ -431,8 +439,8 @@ bool Story::canDownloadChecked() const {
 }
 
 bool Story::canShare() const {
-	return _privacyPublic 
-		&& !forbidsForward() 
+	return _privacyPublic
+		&& !forbidsForward()
 		&& (inProfile() || !expired());
 }
 
diff --git a/Telegram/SourceFiles/data/data_story.h b/Telegram/SourceFiles/data/data_story.h
index f4fd17d60..512f0ed38 100644
--- a/Telegram/SourceFiles/data/data_story.h
+++ b/Telegram/SourceFiles/data/data_story.h
@@ -153,6 +153,9 @@ public:
 	[[nodiscard]] Image *replyPreview() const;
 	[[nodiscard]] TextWithEntities inReplyText() const;
 
+	void setPinnedToTop(bool pinned);
+	bool pinnedToTop() const;
+
 	void setInProfile(bool value);
 	[[nodiscard]] bool inProfile() const;
 	[[nodiscard]] StoryPrivacy privacy() const;
@@ -251,6 +254,7 @@ private:
 	TimeId _lastUpdateTime = 0;
 	bool _out : 1 = false;
 	bool _inProfile : 1 = false;
+	bool _pinnedToTop : 1 = false;
 	bool _privacyPublic : 1 = false;
 	bool _privacyCloseFriends : 1 = false;
 	bool _privacyContacts : 1 = false;
diff --git a/Telegram/SourceFiles/info/info.style b/Telegram/SourceFiles/info/info.style
index cf6f410cd..27e3750b4 100644
--- a/Telegram/SourceFiles/info/info.style
+++ b/Telegram/SourceFiles/info/info.style
@@ -44,6 +44,8 @@ InfoTopBar {
 	mediaDelete: IconButton;
 	storiesSave: IconButton;
 	storiesArchive: IconButton;
+	storiesPin: IconButton;
+	storiesUnpin: IconButton;
 	search: IconButton;
 	searchRow: SearchFieldRow;
 	highlightBg: color;
@@ -185,6 +187,14 @@ infoTopBarArchiveStories: IconButton(infoTopBarForward) {
 	icon: icon {{ "info/info_stories_to_archive", boxTitleCloseFg }};
 	iconOver: icon {{ "info/info_stories_to_archive", boxTitleCloseFgOver }};
 }
+infoTopBarPinStories: IconButton(infoTopBarForward) {
+	icon: icon {{ "menu/pin", boxTitleCloseFg }};
+	iconOver: icon {{ "menu/pin", boxTitleCloseFgOver }};
+}
+infoTopBarUnpinStories: IconButton(infoTopBarForward) {
+	icon: icon {{ "menu/unpin", boxTitleCloseFg }};
+	iconOver: icon {{ "menu/unpin", boxTitleCloseFgOver }};
+}
 infoTopBar: InfoTopBar {
 	height: infoTopBarHeight;
 	back: infoTopBarBack;
@@ -205,6 +215,8 @@ infoTopBar: InfoTopBar {
 	mediaDelete: infoTopBarDelete;
 	storiesSave: infoTopBarSaveStories;
 	storiesArchive: infoTopBarArchiveStories;
+	storiesPin: infoTopBarPinStories;
+	storiesUnpin: infoTopBarUnpinStories;
 	search: infoTopBarSearch;
 	searchRow: infoTopBarSearchRow;
 	highlightBg: windowBgOver;
@@ -268,6 +280,14 @@ infoLayerTopBarArchiveStories: IconButton(infoLayerTopBarForward) {
 	icon: icon {{ "info/info_stories_to_archive", boxTitleCloseFg }};
 	iconOver: icon {{ "info/info_stories_to_archive", boxTitleCloseFgOver }};
 }
+infoLayerTopBarPinStories: IconButton(infoLayerTopBarForward) {
+	icon: icon {{ "menu/pin", boxTitleCloseFg }};
+	iconOver: icon {{ "menu/pin", boxTitleCloseFgOver }};
+}
+infoLayerTopBarUnpinStories: IconButton(infoLayerTopBarForward) {
+	icon: icon {{ "menu/unpin", boxTitleCloseFg }};
+	iconOver: icon {{ "menu/unpin", boxTitleCloseFgOver }};
+}
 infoLayerTopBar: InfoTopBar(infoTopBar) {
 	height: infoLayerTopBarHeight;
 	back: infoLayerTopBarBack;
@@ -282,6 +302,8 @@ infoLayerTopBar: InfoTopBar(infoTopBar) {
 	mediaDelete: infoLayerTopBarDelete;
 	storiesSave: infoLayerTopBarSaveStories;
 	storiesArchive: infoLayerTopBarArchiveStories;
+	storiesPin: infoLayerTopBarPinStories;
+	storiesUnpin: infoLayerTopBarUnpinStories;
 	search: infoTopBarSearch;
 	searchRow: infoTopBarSearchRow;
 	radius: boxRadius;
diff --git a/Telegram/SourceFiles/info/info_top_bar.cpp b/Telegram/SourceFiles/info/info_top_bar.cpp
index 2caa1ffa8..0a59d52f2 100644
--- a/Telegram/SourceFiles/info/info_top_bar.cpp
+++ b/Telegram/SourceFiles/info/info_top_bar.cpp
@@ -393,6 +393,8 @@ void TopBar::updateSelectionControlsGeometry(int newWidth) {
 		right += _delete->width();
 	}
 	if (_canToggleStoryPin) {
+		_toggleStoryInProfile->moveToRight(right, 0, newWidth);
+		right += _toggleStoryInProfile->width();
 		_toggleStoryPin->moveToRight(right, 0, newWidth);
 		right += _toggleStoryPin->width();
 	}
@@ -609,14 +611,23 @@ rpl::producer<SelectionAction> TopBar::selectionActionRequests() const {
 }
 
 void TopBar::updateSelectionState() {
-	Expects(_selectionText && _delete && _forward && _toggleStoryPin);
+	Expects(_selectionText
+		&& _delete
+		&& _forward
+		&& _toggleStoryInProfile
+		&& _toggleStoryPin);
 
 	_canDelete = computeCanDelete();
 	_canForward = computeCanForward();
+	_canUnpinStories = computeCanUnpinStories();
 	_selectionText->entity()->setValue(generateSelectedText());
 	_delete->toggle(_canDelete, anim::type::instant);
 	_forward->toggle(_canForward, anim::type::instant);
+	_toggleStoryInProfile->toggle(_canToggleStoryPin, anim::type::instant);
 	_toggleStoryPin->toggle(_canToggleStoryPin, anim::type::instant);
+	_toggleStoryPin->entity()->setIconOverride(
+		_canUnpinStories ? &_st.storiesUnpin.icon : nullptr,
+		_canUnpinStories ? &_st.storiesUnpin.iconOver : nullptr);
 
 	updateSelectionControlsGeometry(width());
 }
@@ -631,6 +642,7 @@ void TopBar::createSelectionControls() {
 	};
 	_canDelete = computeCanDelete();
 	_canForward = computeCanForward();
+	_canUnpinStories = computeCanUnpinStories();
 	_canToggleStoryPin = computeCanToggleStoryPin();
 	_cancelSelection = wrap(Ui::CreateChild<Ui::FadeWrap<Ui::IconButton>>(
 		this,
@@ -668,6 +680,7 @@ void TopBar::createSelectionControls() {
 		_selectionActionRequests,
 		_cancelSelection->lifetime());
 	_forward->entity()->setVisible(_canForward);
+
 	_delete = wrap(Ui::CreateChild<Ui::FadeWrap<Ui::IconButton>>(
 		this,
 		object_ptr<Ui::IconButton>(this, _st.mediaDelete),
@@ -683,13 +696,38 @@ void TopBar::createSelectionControls() {
 		_selectionActionRequests,
 		_cancelSelection->lifetime());
 	_delete->entity()->setVisible(_canDelete);
-	const auto archive = _toggleStoryPin = wrap(
+
+	_toggleStoryInProfile = wrap(
 		Ui::CreateChild<Ui::FadeWrap<Ui::IconButton>>(
 			this,
 			object_ptr<Ui::IconButton>(
 				this,
 				_storiesArchive ? _st.storiesSave : _st.storiesArchive),
 			st::infoTopBarScale));
+	registerToggleControlCallback(
+		_toggleStoryInProfile.data(),
+		[this] { return selectionMode() && _canToggleStoryPin; });
+	_toggleStoryInProfile->setDuration(st::infoTopBarDuration);
+	_toggleStoryInProfile->entity()->clicks(
+	) | rpl::map_to(
+		SelectionAction::ToggleStoryInProfile
+	) | rpl::start_to_stream(
+		_selectionActionRequests,
+		_cancelSelection->lifetime());
+	_toggleStoryInProfile->entity()->setVisible(_canToggleStoryPin);
+
+	_toggleStoryPin = wrap(
+		Ui::CreateChild<Ui::FadeWrap<Ui::IconButton>>(
+			this,
+			object_ptr<Ui::IconButton>(
+				this,
+				_st.storiesPin),
+			st::infoTopBarScale));
+	if (_canUnpinStories) {
+		_toggleStoryPin->entity()->setIconOverride(
+			_canUnpinStories ? &_st.storiesUnpin.icon : nullptr,
+			_canUnpinStories ? &_st.storiesUnpin.iconOver : nullptr);
+	}
 	registerToggleControlCallback(
 		_toggleStoryPin.data(),
 		[this] { return selectionMode() && _canToggleStoryPin; });
@@ -713,6 +751,10 @@ bool TopBar::computeCanForward() const {
 	return ranges::all_of(_selectedItems.list, &SelectedItem::canForward);
 }
 
+bool TopBar::computeCanUnpinStories() const {
+	return ranges::any_of(_selectedItems.list, &SelectedItem::canUnpinStory);
+}
+
 bool TopBar::computeCanToggleStoryPin() const {
 	return ranges::all_of(
 		_selectedItems.list,
diff --git a/Telegram/SourceFiles/info/info_top_bar.h b/Telegram/SourceFiles/info/info_top_bar.h
index 9e6ad80a8..2aa1093de 100644
--- a/Telegram/SourceFiles/info/info_top_bar.h
+++ b/Telegram/SourceFiles/info/info_top_bar.h
@@ -127,6 +127,7 @@ private:
 	[[nodiscard]] Ui::StringWithNumbers generateSelectedText() const;
 	[[nodiscard]] bool computeCanDelete() const;
 	[[nodiscard]] bool computeCanForward() const;
+	[[nodiscard]] bool computeCanUnpinStories() const;
 	[[nodiscard]] bool computeCanToggleStoryPin() const;
 	void updateSelectionState();
 	void createSelectionControls();
@@ -174,11 +175,13 @@ private:
 	bool _canDelete = false;
 	bool _canForward = false;
 	bool _canToggleStoryPin = false;
+	bool _canUnpinStories = false;
 	bool _storiesArchive = false;
 	QPointer<Ui::FadeWrap<Ui::IconButton>> _cancelSelection;
 	QPointer<Ui::FadeWrap<Ui::LabelWithNumbers>> _selectionText;
 	QPointer<Ui::FadeWrap<Ui::IconButton>> _forward;
 	QPointer<Ui::FadeWrap<Ui::IconButton>> _delete;
+	QPointer<Ui::FadeWrap<Ui::IconButton>> _toggleStoryInProfile;
 	QPointer<Ui::FadeWrap<Ui::IconButton>> _toggleStoryPin;
 	rpl::event_stream<SelectionAction> _selectionActionRequests;
 
diff --git a/Telegram/SourceFiles/info/info_wrap_widget.h b/Telegram/SourceFiles/info/info_wrap_widget.h
index db76d4b43..5feb502f7 100644
--- a/Telegram/SourceFiles/info/info_wrap_widget.h
+++ b/Telegram/SourceFiles/info/info_wrap_widget.h
@@ -59,6 +59,7 @@ struct SelectedItem {
 	bool canDelete = false;
 	bool canForward = false;
 	bool canToggleStoryPin = false;
+	bool canUnpinStory = false;
 };
 
 struct SelectedItems {
@@ -74,6 +75,7 @@ enum class SelectionAction {
 	Forward,
 	Delete,
 	ToggleStoryPin,
+	ToggleStoryInProfile,
 };
 
 class WrapWidget final : public Window::SectionWidget {
diff --git a/Telegram/SourceFiles/info/media/info_media_common.h b/Telegram/SourceFiles/info/media/info_media_common.h
index 27e8d4b9f..e25c860d8 100644
--- a/Telegram/SourceFiles/info/media/info_media_common.h
+++ b/Telegram/SourceFiles/info/media/info_media_common.h
@@ -31,6 +31,7 @@ struct ListItemSelectionData {
 	bool canDelete = false;
 	bool canForward = false;
 	bool canToggleStoryPin = false;
+	bool canUnpinStory = false;
 
 	friend inline bool operator==(
 		ListItemSelectionData,
diff --git a/Telegram/SourceFiles/info/media/info_media_list_widget.cpp b/Telegram/SourceFiles/info/media/info_media_list_widget.cpp
index be09febef..8e9006629 100644
--- a/Telegram/SourceFiles/info/media/info_media_list_widget.cpp
+++ b/Telegram/SourceFiles/info/media/info_media_list_widget.cpp
@@ -261,6 +261,9 @@ void ListWidget::selectionAction(SelectionAction action) {
 	case SelectionAction::Clear: clearSelected(); return;
 	case SelectionAction::Forward: forwardSelected(); return;
 	case SelectionAction::Delete: deleteSelected(); return;
+	case SelectionAction::ToggleStoryInProfile:
+		toggleStoryInProfileSelected();
+		return;
 	case SelectionAction::ToggleStoryPin: toggleStoryPinSelected(); return;
 	}
 }
@@ -340,6 +343,7 @@ auto ListWidget::collectSelectedItems() const -> SelectedItems {
 		result.canDelete = selection.canDelete;
 		result.canForward = selection.canForward;
 		result.canToggleStoryPin = selection.canToggleStoryPin;
+		result.canUnpinStory = selection.canUnpinStory;
 		return result;
 	};
 	auto transformation = [&](const auto &item) {
@@ -908,21 +912,26 @@ void ListWidget::showContextMenu(
 		}
 	}
 
-	auto canDeleteAll = [&] {
+	const auto canDeleteAll = [&] {
 		return ranges::none_of(_selected, [](auto &&item) {
 			return !item.second.canDelete;
 		});
 	};
-	auto canForwardAll = [&] {
+	const auto canForwardAll = [&] {
 		return ranges::none_of(_selected, [](auto &&item) {
 			return !item.second.canForward;
 		}) && (!_controller->key().storiesPeer() || _selected.size() == 1);
 	};
-	auto canToggleStoryPinAll = [&] {
+	const auto canToggleStoryPinAll = [&] {
 		return ranges::none_of(_selected, [](auto &&item) {
 			return !item.second.canToggleStoryPin;
 		});
 	};
+	const auto canUnpinStoryAll = [&] {
+		return ranges::any_of(_selected, [](auto &&item) {
+			return item.second.canUnpinStory;
+		});
+	};
 
 	auto link = ClickHandler::getActive();
 
@@ -1024,15 +1033,26 @@ void ListWidget::showContextMenu(
 	if (overSelected == SelectionState::OverSelectedItems) {
 		if (canToggleStoryPinAll()) {
 			const auto tab = _controller->key().storiesTab();
-			const auto pin = (tab == Stories::Tab::Archive);
+			const auto toProfile = (tab == Stories::Tab::Archive);
 			_contextMenu->addAction(
-				(pin
+				(toProfile
 					? tr::lng_mediaview_save_to_profile
 					: tr::lng_archived_add)(tr::now),
-				crl::guard(this, [this] { toggleStoryPinSelected(); }),
-				(pin
+				crl::guard(this, [this] { toggleStoryInProfileSelected(); }),
+				(toProfile
 					? &st::menuIconStoriesSave
 					: &st::menuIconStoriesArchive));
+			if (!toProfile) {
+				const auto unpin = canUnpinStoryAll();
+				_contextMenu->addAction(
+					(unpin
+						? tr::lng_context_unpin_from_top
+						: tr::lng_context_pin_to_top)(tr::now),
+					crl::guard(
+						this,
+						[this] { toggleStoryPinSelected(); }),
+					(unpin ? &st::menuIconUnpin : &st::menuIconPin));
+			}
 		}
 		if (canForwardAll()) {
 			_contextMenu->addAction(
@@ -1065,17 +1085,28 @@ void ListWidget::showContextMenu(
 				FullSelection);
 			if (selectionData.canToggleStoryPin) {
 				const auto tab = _controller->key().storiesTab();
-				const auto pin = (tab == Stories::Tab::Archive);
+				const auto toProfile = (tab == Stories::Tab::Archive);
 				_contextMenu->addAction(
-					(pin
+					(toProfile
 						? tr::lng_mediaview_save_to_profile
 						: tr::lng_mediaview_archive_story)(tr::now),
 					crl::guard(this, [=] {
-						toggleStoryPin({ 1, globalId.itemId });
+						toggleStoryInProfile({ 1, globalId.itemId });
 					}),
-					(pin
+					(toProfile
 						? &st::menuIconStoriesSave
 						: &st::menuIconStoriesArchive));
+				if (!toProfile) {
+					const auto unpin = selectionData.canUnpinStory;
+					_contextMenu->addAction(
+						(unpin
+							? tr::lng_context_unpin_from_top
+							: tr::lng_context_pin_to_top)(tr::now),
+						crl::guard(this, [=] { toggleStoryPin(
+							{ 1, globalId.itemId },
+							!unpin); }),
+						(unpin ? &st::menuIconUnpin : &st::menuIconPin));
+				}
 			}
 			if (selectionData.canForward) {
 				_contextMenu->addAction(
@@ -1193,13 +1224,23 @@ void ListWidget::deleteSelected() {
 	}));
 }
 
-void ListWidget::toggleStoryPinSelected() {
-	toggleStoryPin(collectSelectedIds(), crl::guard(this, [=] {
+void ListWidget::toggleStoryInProfileSelected() {
+	toggleStoryInProfile(collectSelectedIds(), crl::guard(this, [=] {
 		clearSelected();
 	}));
 }
 
-void ListWidget::toggleStoryPin(
+void ListWidget::toggleStoryPinSelected() {
+	const auto items = collectSelectedItems();
+	const auto pin = ranges::none_of(
+		items.list,
+		&SelectedItem::canUnpinStory);
+	toggleStoryPin(collectSelectedIds(items), pin, crl::guard(this, [=] {
+		clearSelected();
+	}));
+}
+
+void ListWidget::toggleStoryInProfile(
 		MessageIdsList &&items,
 		Fn<void()> confirmed) {
 	auto list = std::vector<FullStoryId>();
@@ -1250,6 +1291,37 @@ void ListWidget::toggleStoryPin(
 	}));
 }
 
+void ListWidget::toggleStoryPin(
+		MessageIdsList &&items,
+		bool pin,
+		Fn<void()> confirmed) {
+	auto list = std::vector<FullStoryId>();
+	for (const auto &id : items) {
+		if (IsStoryMsgId(id.msg)) {
+			list.push_back({ id.peer, StoryIdFromMsgId(id.msg) });
+		}
+	}
+	if (list.empty()) {
+		return;
+	}
+	const auto channel = peerIsChannel(list.front().peer);
+	const auto count = int(list.size());
+	const auto controller = _controller;
+	const auto stories = &controller->session().data().stories();
+	if (stories->canTogglePinnedList(list, pin)) {
+		using namespace ::Media::Stories;
+		stories->togglePinnedList(list, pin);
+		controller->showToast(PrepareTogglePinToast(channel, count, pin));
+		if (confirmed) {
+			confirmed();
+		}
+	} else {
+		const auto limit = stories->maxPinnedCount();
+		controller->showToast(
+			tr::lng_mediaview_pin_limit(tr::now, lt_count, limit));
+	}
+}
+
 void ListWidget::deleteItem(GlobalMsgId globalId) {
 	if (const auto item = MessageByGlobalId(globalId)) {
 		auto items = SelectedItems(_provider->type());
diff --git a/Telegram/SourceFiles/info/media/info_media_list_widget.h b/Telegram/SourceFiles/info/media/info_media_list_widget.h
index 10984f27e..5b486c1de 100644
--- a/Telegram/SourceFiles/info/media/info_media_list_widget.h
+++ b/Telegram/SourceFiles/info/media/info_media_list_widget.h
@@ -190,10 +190,15 @@ private:
 	void forwardItems(MessageIdsList &&items);
 	void deleteSelected();
 	void toggleStoryPinSelected();
+	void toggleStoryInProfileSelected();
 	void deleteItem(GlobalMsgId globalId);
 	void deleteItems(SelectedItems &&items, Fn<void()> confirmed = nullptr);
+	void toggleStoryInProfile(
+		MessageIdsList &&items,
+		Fn<void()> confirmed = nullptr);
 	void toggleStoryPin(
 		MessageIdsList &&items,
+		bool pin,
 		Fn<void()> confirmed = nullptr);
 	void applyItemSelection(
 		HistoryItem *item,
diff --git a/Telegram/SourceFiles/info/stories/info_stories_provider.cpp b/Telegram/SourceFiles/info/stories/info_stories_provider.cpp
index ba82c297f..c3025bf85 100644
--- a/Telegram/SourceFiles/info/stories/info_stories_provider.cpp
+++ b/Telegram/SourceFiles/info/stories/info_stories_provider.cpp
@@ -189,9 +189,22 @@ void Provider::refreshViewer() {
 			return;
 		}
 		_slice = std::move(slice);
-		if (const auto nearest = _slice.nearest(idForViewer)) {
-			_aroundId = *nearest;
+
+		auto nearestId = std::optional<StoryId>();
+		for (auto i = 0; i != _slice.size(); ++i) {
+			if (!nearestId
+				|| std::abs(*nearestId - idForViewer)
+					> std::abs(_slice[i] - idForViewer)) {
+				nearestId = _slice[i];
+			}
 		}
+		if (nearestId) {
+			_aroundId = *nearestId;
+		}
+
+		//if (const auto nearest = _slice.nearest(idForViewer)) {
+		//	_aroundId = *nearest;
+		//}
 		_refreshed.fire({});
 	}, _viewerLifetime);
 }
@@ -208,8 +221,8 @@ std::vector<ListSection> Provider::fillSections(
 	auto result = std::vector<ListSection>();
 	auto section = ListSection(Type::PhotoVideo, sectionDelegate());
 	auto count = _slice.size();
-	for (auto i = count; i != 0;) {
-		const auto storyId = _slice[--i];
+	for (auto i = 0; i != count; ++i) {
+		const auto storyId = _slice[i];
 		if (const auto layout = getLayout(storyId, delegate)) {
 			if (!section.addItem(layout)) {
 				section.finishSection();
@@ -361,6 +374,7 @@ ListItemSelectionData Provider::computeSelectionData(
 		const auto story = *maybeStory;
 		result.canForward = peer->isSelf() && story->canShare();
 		result.canDelete = story->canDelete();
+		result.canUnpinStory = story->pinnedToTop();
 	}
 	result.canToggleStoryPin = peer->isSelf()
 		|| (channel && channel->canEditStories());
@@ -417,12 +431,28 @@ int64 Provider::scrollTopStatePosition(not_null<HistoryItem*> item) {
 HistoryItem *Provider::scrollTopStateItem(ListScrollTopState state) {
 	if (state.item && _slice.indexOf(StoryIdFromMsgId(state.item->id))) {
 		return state.item;
-	} else if (const auto id = _slice.nearest(state.position)) {
-		const auto full = FullMsgId(_peer->id, StoryIdToMsgId(*id));
+	//} else if (const auto id = _slice.nearest(state.position)) {
+	//	const auto full = FullMsgId(_peer->id, StoryIdToMsgId(*id));
+	//	if (const auto item = _controller->session().data().message(full)) {
+	//		return item;
+	//	}
+	}
+
+	auto nearestId = std::optional<StoryId>();
+	for (auto i = 0; i != _slice.size(); ++i) {
+		if (!nearestId
+			|| std::abs(*nearestId - state.position)
+				> std::abs(_slice[i] - state.position)) {
+			nearestId = _slice[i];
+		}
+	}
+	if (nearestId) {
+		const auto full = FullMsgId(_peer->id, StoryIdToMsgId(*nearestId));
 		if (const auto item = _controller->session().data().message(full)) {
 			return item;
 		}
 	}
+
 	return state.item;
 }
 
diff --git a/Telegram/SourceFiles/media/stories/media_stories_controller.cpp b/Telegram/SourceFiles/media/stories/media_stories_controller.cpp
index 10490e2aa..2b06956bd 100644
--- a/Telegram/SourceFiles/media/stories/media_stories_controller.cpp
+++ b/Telegram/SourceFiles/media/stories/media_stories_controller.cpp
@@ -80,13 +80,18 @@ struct SameDayRange {
 		int index) {
 	Expects(index >= 0 && index < ids.list.size());
 
+	const auto pinned = int(ids.pinnedToTop.size());
+	if (index < pinned) {
+		return SameDayRange{ .from = 0, .till = pinned - 1 };
+	}
+
 	auto result = SameDayRange{ .from = index, .till = index };
 	const auto peerId = story->peer()->id;
 	const auto stories = &story->owner().stories();
 	const auto now = base::unixtime::parse(story->date());
-	const auto b = begin(ids.list);
-	for (auto i = b + index; i != b;) {
-		if (const auto maybeStory = stories->lookup({ peerId, *--i })) {
+	for (auto i = index; i != 0;) {
+		const auto storyId = IdRespectingPinned(ids, --i);
+		if (const auto maybeStory = stories->lookup({ peerId, storyId })) {
 			const auto day = base::unixtime::parse((*maybeStory)->date());
 			if (day.date() != now.date()) {
 				break;
@@ -94,8 +99,9 @@ struct SameDayRange {
 		}
 		--result.from;
 	}
-	for (auto i = b + index + 1, e = end(ids.list); i != e; ++i) {
-		if (const auto maybeStory = stories->lookup({ peerId, *i })) {
+	for (auto i = index + 1, c = int(ids.list.size()); i != c; ++i) {
+		const auto storyId = IdRespectingPinned(ids, i);
+		if (const auto maybeStory = stories->lookup({ peerId, storyId })) {
 			const auto day = base::unixtime::parse((*maybeStory)->date());
 			if (day.date() != now.date()) {
 				break;
@@ -694,17 +700,16 @@ void Controller::rebuildFromContext(
 	}, [&](StoriesContextSaved) {
 		if (stories.savedCountKnown(peerId)) {
 			const auto &saved = stories.saved(peerId);
-			const auto &ids = saved.list;
-			const auto i = ids.find(id);
-			if (i != end(ids)) {
+			const auto i = IndexRespectingPinned(saved, id);
+			if (i < saved.list.size()) {
 				list = StoriesList{
 					.peer = peer,
 					.ids = saved,
 					.total = stories.savedCount(peerId),
 				};
-				_index = int(i - begin(ids));
-				if (ids.size() < list->total
-					&& (end(ids) - i) < kPreloadStoriesCount) {
+				_index = i;
+				if (saved.list.size() < list->total
+					&& (saved.list.size() - i) < kPreloadStoriesCount) {
 					stories.savedLoadMore(peerId);
 				}
 			}
@@ -713,17 +718,16 @@ void Controller::rebuildFromContext(
 	}, [&](StoriesContextArchive) {
 		if (stories.archiveCountKnown(peerId)) {
 			const auto &archive = stories.archive(peerId);
-			const auto &ids = archive.list;
-			const auto i = ids.find(id);
-			if (i != end(ids)) {
+			const auto i = IndexRespectingPinned(archive, id);
+			if (i < archive.list.size()) {
 				list = StoriesList{
 					.peer = peer,
 					.ids = archive,
 					.total = stories.archiveCount(peerId),
 				};
-				_index = int(i - begin(ids));
-				if (ids.size() < list->total
-					&& (end(ids) - i) < kPreloadStoriesCount) {
+				_index = i;
+				if (archive.list.size() < list->total
+					&& (archive.list.size() - i) < kPreloadStoriesCount) {
 					stories.archiveLoadMore(peerId);
 				}
 			}
@@ -1520,7 +1524,7 @@ StoryId Controller::shownId(int index) const {
 	return _source
 		? (_source->ids.begin() + index)->id
 		: (index < int(_list->ids.list.size()))
-		? *(_list->ids.list.begin() + index)
+		? IdRespectingPinned(_list->ids, index)
 		: StoryId();
 }
 
@@ -1801,6 +1805,39 @@ Ui::Toast::Config PrepareToggleInProfileToast(
 	};
 }
 
+Ui::Toast::Config PrepareTogglePinToast(
+		bool channel,
+		int count,
+		bool pin) {
+	return {
+		.title = (pin
+			? (count == 1
+				? tr::lng_mediaview_pin_story_done(tr::now)
+				: tr::lng_mediaview_pin_stories_done(
+					tr::now,
+					lt_count,
+					count))
+			: QString()),
+		.text = (pin
+			? (count == 1
+				? tr::lng_mediaview_pin_story_about(tr::now)
+				: tr::lng_mediaview_pin_stories_about(
+					tr::now,
+					lt_count,
+					count))
+			: (count == 1
+				? tr::lng_mediaview_unpin_story_done(tr::now)
+				: tr::lng_mediaview_unpin_stories_done(
+					tr::now,
+					lt_count,
+					count))),
+		.st = &st::storiesActionToast,
+		.duration = (pin
+			? Data::Stories::kInProfileToastDuration
+			: Ui::Toast::kDefaultDuration),
+	};
+}
+
 void ReportRequested(
 		std::shared_ptr<Main::SessionShow> show,
 		FullStoryId id,
diff --git a/Telegram/SourceFiles/media/stories/media_stories_controller.h b/Telegram/SourceFiles/media/stories/media_stories_controller.h
index 2b5de5bc7..45590e0a2 100644
--- a/Telegram/SourceFiles/media/stories/media_stories_controller.h
+++ b/Telegram/SourceFiles/media/stories/media_stories_controller.h
@@ -332,6 +332,10 @@ private:
 	bool channel,
 	int count,
 	bool inProfile);
+[[nodiscard]] Ui::Toast::Config PrepareTogglePinToast(
+	bool channel,
+	int count,
+	bool pin);
 void ReportRequested(
 	std::shared_ptr<Main::SessionShow> show,
 	FullStoryId id,