diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 3986097cb..a7b38b96d 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -71,6 +71,7 @@ export interface GramJsAppConfig extends LimitsConfig { story_expire_period: number; story_viewers_expire_period: number; stories_changelog_user_id?: number; + stories_pinned_to_top_count_max?: number; // Boosts group_transcribe_level_min?: number; new_noncontact_peers_require_premium_without_ownpremium?: boolean; @@ -152,6 +153,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp storyExpirePeriod: appConfig.story_expire_period ?? STORY_EXPIRE_PERIOD, storyViewersExpirePeriod: appConfig.story_viewers_expire_period ?? STORY_VIEWERS_EXPIRE_PERIOD, storyChangelogUserId: appConfig.stories_changelog_user_id?.toString() ?? SERVICE_NOTIFICATIONS_USER_ID, + maxPinnedStoriesCount: appConfig.stories_pinned_to_top_count_max, groupTranscribeLevelMin: appConfig.group_transcribe_level_min, canLimitNewMessagesWithoutPremium: appConfig.new_noncontact_peers_require_premium_without_ownpremium, bandwidthPremiumNotifyPeriod: appConfig.upload_premium_speedup_notify_period, diff --git a/src/api/gramjs/apiBuilders/stories.ts b/src/api/gramjs/apiBuilders/stories.ts index c5b216c97..dde0947c3 100644 --- a/src/api/gramjs/apiBuilders/stories.ts +++ b/src/api/gramjs/apiBuilders/stories.ts @@ -66,7 +66,7 @@ export function buildApiStory(peerId: string, story: GramJs.TypeStoryItem): ApiT content, isPublic, isEdited: edited, - isPinned: pinned, + isInProfile: pinned, isForContacts: contacts, isForSelectedContacts: selectedContacts, isForCloseFriends: closeFriends, diff --git a/src/api/gramjs/methods/stories.ts b/src/api/gramjs/methods/stories.ts index b74c75b47..483945355 100644 --- a/src/api/gramjs/methods/stories.ts +++ b/src/api/gramjs/methods/stories.ts @@ -77,15 +77,15 @@ export async function fetchAllStories({ const allUserStories = result.peerStories.reduce>((acc, peerStories) => { const peerId = getApiChatIdFromMtpPeer(peerStories.peer); const stories = buildApiPeerStories(peerStories); - const { pinnedIds, orderedIds, lastUpdatedAt } = Object.values(stories).reduce< + const { profileIds, orderedIds, lastUpdatedAt } = Object.values(stories).reduce< { - pinnedIds: number[]; + profileIds: number[]; orderedIds: number[]; lastUpdatedAt?: number; } >((dataAcc, story) => { - if ('isPinned' in story && story.isPinned) { - dataAcc.pinnedIds.push(story.id); + if ('isInProfile' in story && story.isInProfile) { + dataAcc.profileIds.push(story.id); } if (!('isDeleted' in story)) { dataAcc.orderedIds.push(story.id); @@ -94,7 +94,7 @@ export async function fetchAllStories({ return dataAcc; }, { - pinnedIds: [], + profileIds: [], orderedIds: [], lastUpdatedAt: undefined, }); @@ -106,7 +106,7 @@ export async function fetchAllStories({ acc[peerId] = { byId: stories, orderedIds, - pinnedIds, + profileIds, lastUpdatedAt, lastReadId: peerStories.maxReadId, }; @@ -154,7 +154,7 @@ export async function fetchPeerStories({ }; } -export function fetchPeerPinnedStories({ +export function fetchPeerProfileStories({ peer, offsetId, }: { peer: ApiPeer; @@ -221,6 +221,7 @@ export async function fetchPeerStoriesByIds({ peer, ids }: { peer: ApiPeer; ids: return { chats, users, + pinnedIds: result.pinnedToTop, stories, }; } @@ -246,11 +247,26 @@ export function deleteStory({ peer, storyId }: { peer: ApiPeer; storyId: number })); } -export function toggleStoryPinned({ peer, storyId, isPinned }: { peer: ApiPeer; storyId: number; isPinned?: boolean }) { +export function toggleStoryInProfile({ + peer, storyId, isInProfile, +}: { + peer: ApiPeer; storyId: number; isInProfile?: boolean; +}) { return invokeRequest(new GramJs.stories.TogglePinned({ peer: buildInputPeer(peer.id, peer.accessHash), id: [storyId], - pinned: isPinned, + pinned: isInProfile, + })); +} + +export function toggleStoryPinnedToTop({ + peer, storyIds, +}: { + peer: ApiPeer; storyIds: number[]; +}) { + return invokeRequest(new GramJs.stories.TogglePinnedToTop({ + peer: buildInputPeer(peer.id, peer.accessHash), + id: storyIds, })); } @@ -422,6 +438,7 @@ async function fetchCommonStoriesRequest({ method, peerId }: { users, chats, stories, + pinnedIds: result.pinnedToTop, }; } diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index bf664b562..94450df2b 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -203,6 +203,7 @@ export interface ApiAppConfig { storyExpirePeriod: number; storyViewersExpirePeriod: number; storyChangelogUserId: string; + maxPinnedStoriesCount?: number; groupTranscribeLevelMin?: number; canLimitNewMessagesWithoutPremium?: boolean; bandwidthPremiumNotifyPeriod?: number; diff --git a/src/api/types/stories.ts b/src/api/types/stories.ts index 97d7fc2c4..a3f84e8a4 100644 --- a/src/api/types/stories.ts +++ b/src/api/types/stories.ts @@ -10,7 +10,7 @@ export interface ApiStory { date: number; expireDate: number; content: MediaContent; - isPinned?: boolean; + isInProfile?: boolean; isEdited?: boolean; isForCloseFriends?: boolean; isForContacts?: boolean; @@ -56,8 +56,11 @@ export type ApiTypeStory = ApiStory | ApiStorySkipped | ApiStoryDeleted; export type ApiPeerStories = { byId: Record; orderedIds: number[]; // Actual peer stories - pinnedIds: number[]; // Profile Shared Media: Pinned Stories tab + profileIds: number[]; // Profile Shared Media: Profile Stories tab + isFullyLoaded?: boolean; + pinnedIds?: number[]; // Profile Shared Media: Pinned profile stories archiveIds?: number[]; // Profile Shared Media: Archive Stories tab + isArchiveFullyLoaded?: boolean; lastUpdatedAt?: number; lastReadId?: number; }; diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index b853ec8ae..15b9d66b7 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -109,6 +109,7 @@ type StateProps = { adminMembersById?: Record; commonChatIds?: string[]; storyIds?: number[]; + pinnedStoryIds?: number[]; archiveStoryIds?: number[]; storyByIds?: Record; chatsById: Record; @@ -155,6 +156,7 @@ const Profile: FC = ({ messagesById, foundIds, storyIds, + pinnedStoryIds, archiveStoryIds, storyByIds, mediaSearchType, @@ -194,7 +196,7 @@ const Profile: FC = ({ focusMessage, loadProfilePhotos, setNewChatMembersDialogState, - loadPeerPinnedStories, + loadPeerProfileStories, loadStoriesArchive, openPremiumModal, loadChannelRecommendations, @@ -264,7 +266,7 @@ const Profile: FC = ({ const renderingActiveTab = activeTab > tabs.length - 1 ? tabs.length - 1 : activeTab; const tabType = tabs[renderingActiveTab].type as ProfileTabType; const handleLoadPeerStories = useCallback(({ offsetId }: { offsetId: number }) => { - loadPeerPinnedStories({ peerId: chatId, offsetId }); + loadPeerProfileStories({ peerId: chatId, offsetId }); }, [chatId]); const handleLoadStoriesArchive = useCallback(({ offsetId }: { offsetId: number }) => { loadStoriesArchive({ peerId: currentUserId!, offsetId }); @@ -287,6 +289,7 @@ const Profile: FC = ({ foundIds, threadId, storyIds, + pinnedStoryIds, archiveStoryIds, similarChannels, ); @@ -484,11 +487,11 @@ const Profile: FC = ({ /> )) ) : (resultType === 'stories' || resultType === 'storiesArchive') ? ( - (viewportIds as number[])!.map((id) => storyByIds?.[id] && ( + (viewportIds as number[])!.map((id, i) => storyByIds?.[id] && ( )) @@ -718,7 +721,8 @@ export default memo(withGlobal( const hasStoriesTab = peer && (user?.isSelf || (!peer.areStoriesHidden && peerFullInfo?.hasPinnedStories)) && !isSavedDialog; const peerStories = hasStoriesTab ? selectPeerStories(global, peer.id) : undefined; - const storyIds = peerStories?.pinnedIds; + const storyIds = peerStories?.profileIds; + const pinnedStoryIds = peerStories?.pinnedIds; const storyByIds = peerStories?.byId; const archiveStoryIds = peerStories?.archiveIds; @@ -743,6 +747,7 @@ export default memo(withGlobal( userStatusesById, chatsById, storyIds, + pinnedStoryIds, archiveStoryIds, storyByIds, isChatProtected: chat?.isProtected, diff --git a/src/components/right/hooks/useProfileViewportIds.ts b/src/components/right/hooks/useProfileViewportIds.ts index d2bd88a75..f1dd0f271 100644 --- a/src/components/right/hooks/useProfileViewportIds.ts +++ b/src/components/right/hooks/useProfileViewportIds.ts @@ -29,6 +29,7 @@ export default function useProfileViewportIds( foundIds?: number[], threadId?: ThreadId, storyIds?: number[], + pinnedStoryIds?: number[], archiveStoryIds?: number[], similarChannels?: string[], ) { @@ -82,8 +83,20 @@ export default function useProfileViewportIds( loadCommonChats, chatIds, ); + const sortedStoryIds = useMemo(() => { + if (!storyIds?.length) return storyIds; + const pinnedStoryIdsSet = new Set(pinnedStoryIds); + return storyIds.slice().sort((a, b) => { + const aIsPinned = pinnedStoryIdsSet.has(a); + const bIsPinned = pinnedStoryIdsSet.has(b); + if (aIsPinned && !bIsPinned) return -1; + if (!aIsPinned && bIsPinned) return 1; + return b - a; + }); + }, [storyIds, pinnedStoryIds]); + const [storyViewportIds, getMoreStories, noProfileInfoForStories] = useInfiniteScrollForLoadableItems( - loadStories, storyIds, + loadStories, sortedStoryIds, ); const [ diff --git a/src/components/story/MediaStory.module.scss b/src/components/story/MediaStory.module.scss index de77e7b86..14d79ed61 100644 --- a/src/components/story/MediaStory.module.scss +++ b/src/components/story/MediaStory.module.scss @@ -25,6 +25,35 @@ vertical-align: -0.1875rem; } +.overlayIcon { + position: absolute; + font-size: 0.75rem; + line-height: 1; + z-index: 1; + color: white; + + filter: drop-shadow(0 0 0.25rem black); +} + +.pinnedIcon { + top: 0.25rem; + right: 0.25rem; +} + +.viewsCount { + bottom: 0.25rem; + left: 0.25rem; + + display: flex; + align-items: center; + gap: 0.125rem; +} + +.duration { + bottom: 0.25rem; + right: 0.25rem; +} + .contextMenu { position: relative; z-index: var(--z-right-column-menu); diff --git a/src/components/story/MediaStory.tsx b/src/components/story/MediaStory.tsx index 2def2b368..abf279bd9 100644 --- a/src/components/story/MediaStory.tsx +++ b/src/components/story/MediaStory.tsx @@ -1,12 +1,14 @@ import React, { memo, useCallback, useEffect, useRef, } from '../../lib/teact/teact'; -import { getActions } from '../../global'; +import { getActions, withGlobal } from '../../global'; import type { ApiStory, ApiTypeStory } from '../../api/types'; import { getStoryMediaHash } from '../../global/helpers'; +import { selectChat, selectPinnedStories } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; +import { formatMediaDuration } from '../../util/date/dateFormat'; import stopEvent from '../../util/stopEvent'; import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; @@ -16,6 +18,7 @@ import useLastCallback from '../../hooks/useLastCallback'; import useMedia from '../../hooks/useMedia'; import useMenuPosition from '../../hooks/useMenuPosition'; +import Icon from '../common/Icon'; import Menu from '../ui/Menu'; import MenuItem from '../ui/MenuItem'; import MediaAreaOverlay from './mediaArea/MediaAreaOverlay'; @@ -24,15 +27,23 @@ import styles from './MediaStory.module.scss'; interface OwnProps { story: ApiTypeStory; - isProtected?: boolean; isArchive?: boolean; } -function MediaStory({ story, isProtected, isArchive }: OwnProps) { +interface StateProps { + isProtected?: boolean; + isPinned?: boolean; + canPin?: boolean; +} + +function MediaStory({ + story, isProtected, isArchive, isPinned, canPin, +}: OwnProps & StateProps) { const { openStoryViewer, loadPeerSkippedStories, - toggleStoryPinned, + toggleStoryInProfile, + toggleStoryPinnedToTop, showNotification, } = getActions(); @@ -50,6 +61,7 @@ function MediaStory({ story, isProtected, isArchive }: OwnProps) { const isOwn = isFullyLoaded && story.isOut; const isDeleted = story && 'isDeleted' in story; const video = isFullyLoaded ? (story as ApiStory).content.video : undefined; + const duration = video && formatMediaDuration(video.duration); const imageHash = isFullyLoaded ? getStoryMediaHash(story as ApiStory) : undefined; const imgBlobUrl = useMedia(imageHash); const thumbUrl = imgBlobUrl || video?.thumbnail?.dataUri; @@ -90,26 +102,30 @@ function MediaStory({ story, isProtected, isArchive }: OwnProps) { handleBeforeContextMenu(e); }); - const handlePinClick = useLastCallback((e: React.SyntheticEvent) => { + const handleUnarchiveClick = useLastCallback((e: React.SyntheticEvent) => { stopEvent(e); - toggleStoryPinned({ peerId, storyId: story.id, isPinned: true }); + toggleStoryInProfile({ peerId, storyId: story.id, isInProfile: true }); showNotification({ message: lang('Story.ToastSavedToProfileText'), }); handleContextMenuClose(); }); - const handleUnpinClick = useLastCallback((e: React.SyntheticEvent) => { + const handleArchiveClick = useLastCallback((e: React.SyntheticEvent) => { stopEvent(e); - toggleStoryPinned({ peerId, storyId: story.id, isPinned: false }); + toggleStoryInProfile({ peerId, storyId: story.id, isInProfile: false }); showNotification({ message: lang('Story.ToastRemovedFromProfileText'), }); handleContextMenuClose(); }); + const handleTogglePinned = useLastCallback(() => { + toggleStoryPinnedToTop({ peerId, storyId: story.id }); + }); + return (
{isDeleted && ( - + {lang('ExpiredStory')} )} + {isPinned && } + {isFullyLoaded && Boolean(story.views?.viewsCount) && ( + + + {story.views.viewsCount} + + )} + {duration && {duration}}
{thumbUrl && ( @@ -145,16 +169,45 @@ function MediaStory({ story, isProtected, isArchive }: OwnProps) { onCloseAnimationEnd={handleContextMenuHide} withPortal > - {isArchive && {lang('StoryList.SaveToProfile')}} + {isArchive && ( + + {lang('StoryList.SaveToProfile')} + + )} {!isArchive && ( - + {lang('Story.Context.RemoveFromProfile')} )} + {!isArchive && !isPinned && canPin && ( + + {lang('StoryList.ItemAction.Pin')} + + )} + {!isArchive && isPinned && ( + + {lang('StoryList.ItemAction.Unpin')} + + )} )}
); } -export default memo(MediaStory); +export default memo(withGlobal((global, { story }): StateProps => { + const chat = selectChat(global, story.peerId); + const isProtected = chat?.isProtected; + + const { maxPinnedStoriesCount } = global.appConfig || {}; + const isOwn = 'isOut' in story && story.isOut; + const pinnedStories = selectPinnedStories(global, story.peerId); + const isPinned = pinnedStories?.some((pinnedStory) => pinnedStory.id === story.id); + const canPinMore = isOwn && (!maxPinnedStoriesCount || (pinnedStories?.length || 0) < maxPinnedStoriesCount); + + return { + isProtected, + isPinned, + canPin: canPinMore, + }; +})(MediaStory)); diff --git a/src/components/story/Story.tsx b/src/components/story/Story.tsx index d4a86717a..c2a194a37 100644 --- a/src/components/story/Story.tsx +++ b/src/components/story/Story.tsx @@ -140,7 +140,7 @@ function Story({ loadPeerSkippedStories, openForwardMenu, copyStoryLink, - toggleStoryPinned, + toggleStoryInProfile, openChat, showNotification, openStoryPrivacyEditor, @@ -188,11 +188,11 @@ function Story({ const isOut = isLoadedStory && story.isOut; const canPinToProfile = useCurrentOrPrev( - isOut ? !story.isPinned : undefined, + isOut ? !story.isInProfile : undefined, true, ); const canUnpinFromProfile = useCurrentOrPrev( - isOut ? story.isPinned : undefined, + isOut ? story.isInProfile : undefined, true, ); const areViewsExpired = Boolean( @@ -460,11 +460,11 @@ function Story({ }); const handlePinClick = useLastCallback(() => { - toggleStoryPinned({ peerId, storyId, isPinned: true }); + toggleStoryInProfile({ peerId, storyId, isInProfile: true }); }); const handleUnpinClick = useLastCallback(() => { - toggleStoryPinned({ peerId, storyId, isPinned: false }); + toggleStoryInProfile({ peerId, storyId, isInProfile: false }); }); const handleDeleteStoryClick = useLastCallback(() => { diff --git a/src/components/story/StorySettings.tsx b/src/components/story/StorySettings.tsx index 123524823..744bf0b52 100644 --- a/src/components/story/StorySettings.tsx +++ b/src/components/story/StorySettings.tsx @@ -91,12 +91,12 @@ function StorySettings({ currentUserId, onClose, }: OwnProps & StateProps) { - const { editStoryPrivacy, toggleStoryPinned } = getActions(); + const { editStoryPrivacy, toggleStoryInProfile } = getActions(); const lang = useLang(); const [isOpenModal, openModal, closeModal] = useFlag(false); const [privacy, setPrivacy] = useState(visibility); - const [isPinned, setIsPinned] = useState(story?.isPinned); + const [isPinned, setIsPinned] = useState(story?.isInProfile); const [activeKey, setActiveKey] = useState(Screens.privacy); const [editingBlockingCategory, setEditingBlockingCategory] = useState('everybody'); const isBackButton = activeKey !== Screens.privacy; @@ -195,8 +195,8 @@ function StorySettings({ storyId: story!.id, privacy: privacy!, }); - if (story!.isPinned !== isPinned) { - toggleStoryPinned({ peerId: story!.peerId, storyId: story!.id, isPinned }); + if (story!.isInProfile !== isPinned) { + toggleStoryInProfile({ peerId: story!.peerId, storyId: story!.id, isInProfile: isPinned }); } closeModal(); }); diff --git a/src/global/actions/api/stories.ts b/src/global/actions/api/stories.ts index a1971a3fa..7a8780aad 100644 --- a/src/global/actions/api/stories.ts +++ b/src/global/actions/api/stories.ts @@ -17,7 +17,8 @@ import { updateLastReadStoryForPeer, updateLastViewedStoryForPeer, updatePeer, - updatePeerPinnedStory, + updatePeerProfileStory, + updatePeerStoriesFullyLoaded, updatePeerStoriesHidden, updatePeerStory, updatePeerStoryViews, @@ -29,6 +30,7 @@ import { } from '../../reducers'; import { selectPeer, selectPeerStories, selectPeerStory, + selectPinnedStories, } from '../../selectors'; const INFINITE_LOOP_MARKER = 100; @@ -153,7 +155,7 @@ addActionHandler('loadPeerSkippedStories', async (global, actions, payload): Pro global = getGlobal(); global = addUsers(global, buildCollectionByKey(result.users, 'id')); global = addChats(global, buildCollectionByKey(result.chats, 'id')); - global = addStoriesForPeer(global, peerId, result.stories); + global = addStoriesForPeer(global, peerId, result.stories, result.pinnedIds); setGlobal(global); }); @@ -170,7 +172,7 @@ addActionHandler('viewStory', async (global, actions, payload): Promise => const serverTime = getServerTime(); - if (story.expireDate < serverTime && story.isPinned) { + if (story.expireDate < serverTime && story.isInProfile) { void callApi('viewStory', { peer, storyId }); } @@ -212,8 +214,8 @@ addActionHandler('deleteStory', async (global, actions, payload): Promise setGlobal(global); }); -addActionHandler('toggleStoryPinned', async (global, actions, payload): Promise => { - const { peerId, storyId, isPinned } = payload; +addActionHandler('toggleStoryInProfile', async (global, actions, payload): Promise => { + const { peerId, storyId, isInProfile } = payload; const peer = selectPeer(global, peerId); if (!peer) { @@ -221,16 +223,63 @@ addActionHandler('toggleStoryPinned', async (global, actions, payload): Promise< } const story = selectPeerStory(global, peerId, storyId); - const currentIsPinned = story && 'content' in story ? story.isPinned : undefined; - global = updatePeerStory(global, peerId, storyId, { isPinned }); - global = updatePeerPinnedStory(global, peerId, storyId, isPinned); + const currentIsPinned = story && 'content' in story ? story.isInProfile : undefined; + global = updatePeerStory(global, peerId, storyId, { isInProfile }); + global = updatePeerProfileStory(global, peerId, storyId, isInProfile); setGlobal(global); - const result = await callApi('toggleStoryPinned', { peer, storyId, isPinned }); + const result = await callApi('toggleStoryInProfile', { peer, storyId, isInProfile }); + if (!result?.length) { + global = getGlobal(); + global = updatePeerStory(global, peerId, storyId, { isInProfile: currentIsPinned }); + global = updatePeerProfileStory(global, peerId, storyId, currentIsPinned); + setGlobal(global); + } +}); + +addActionHandler('toggleStoryPinnedToTop', async (global, actions, payload): Promise => { + const { peerId, storyId } = payload; + const peer = selectPeer(global, peerId); + const peerStories = selectPeerStories(global, peerId); + if (!peer || !peerStories) { + return; + } + + const oldPinnedIds = selectPinnedStories(global, peerId)?.map((s) => s.id) || []; + const isRemoving = oldPinnedIds.includes(storyId); + const newPinnedIds = isRemoving ? oldPinnedIds.filter((id) => id !== storyId) : [...oldPinnedIds, storyId]; + + global = { + ...getGlobal(), + stories: { + ...getGlobal().stories, + byPeerId: { + ...getGlobal().stories.byPeerId, + [peerId]: { + ...peerStories, + pinnedIds: newPinnedIds.sort((a, b) => b - a), + }, + }, + }, + }; + setGlobal(global); + const result = await callApi('toggleStoryPinnedToTop', { peer, storyIds: newPinnedIds }); + if (!result) { global = getGlobal(); - global = updatePeerStory(global, peerId, storyId, { isPinned: currentIsPinned }); - global = updatePeerPinnedStory(global, peerId, storyId, currentIsPinned); + global = { + ...global, + stories: { + ...global.stories, + byPeerId: { + ...global.stories.byPeerId, + [peerId]: { + ...peerStories, + pinnedIds: oldPinnedIds, + }, + }, + }, + }; setGlobal(global); } }); @@ -255,29 +304,35 @@ addActionHandler('loadPeerStories', async (global, actions, payload): Promise => { +addActionHandler('loadPeerProfileStories', async (global, actions, payload): Promise => { const { peerId, offsetId } = payload; const peer = selectPeer(global, peerId); - if (!peer) { + const peerStories = selectPeerStories(global, peerId); + if (!peer || peerStories?.isFullyLoaded) { return; } - const result = await callApi('fetchPeerPinnedStories', { peer, offsetId }); + const result = await callApi('fetchPeerProfileStories', { peer, offsetId }); if (!result) { return; } global = getGlobal(); + if (Object.values(result.stories).length === 0) { + global = updatePeerStoriesFullyLoaded(global, peerId, true); + } + global = addUsers(global, buildCollectionByKey(result.users, 'id')); global = addChats(global, buildCollectionByKey(result.chats, 'id')); - global = addStoriesForPeer(global, peerId, result.stories); + global = addStoriesForPeer(global, peerId, result.stories, result.pinnedIds); setGlobal(global); }); addActionHandler('loadStoriesArchive', async (global, actions, payload): Promise => { const { peerId, offsetId } = payload; const peer = selectPeer(global, peerId); - if (!peer) return; + const peerStories = selectPeerStories(global, peerId); + if (!peer || peerStories?.isArchiveFullyLoaded) return; const result = await callApi('fetchStoriesArchive', { peer, offsetId }); if (!result) { @@ -285,9 +340,12 @@ addActionHandler('loadStoriesArchive', async (global, actions, payload): Promise } global = getGlobal(); + if (Object.values(result.stories).length === 0) { + global = updatePeerStoriesFullyLoaded(global, peerId, true, true); + } global = addUsers(global, buildCollectionByKey(result.users, 'id')); global = addChats(global, buildCollectionByKey(result.chats, 'id')); - global = addStoriesForPeer(global, peerId, result.stories, true); + global = addStoriesForPeer(global, peerId, result.stories, undefined, true); setGlobal(global); }); diff --git a/src/global/intervals.ts b/src/global/intervals.ts index fbaa85379..a1a2ba00e 100644 --- a/src/global/intervals.ts +++ b/src/global/intervals.ts @@ -48,7 +48,7 @@ function checkStoryExpiration() { stories.forEach((story) => { if (!('expireDate' in story)) return; if (story.expireDate > serverTime) return; - if ('isPinned' in story && story.isPinned) return; + if ('isInProfile' in story && story.isInProfile) return; if ('isPublic' in story && !story.isPublic) return; global = removePeerStory(global, story.peerId, story.id); diff --git a/src/global/reducers/stories.ts b/src/global/reducers/stories.ts index f8b9db963..d1d66f298 100644 --- a/src/global/reducers/stories.ts +++ b/src/global/reducers/stories.ts @@ -29,7 +29,7 @@ export function addStories(global: T, newStoriesByPeerId: } else { acc[peerId].byId = { ...acc[peerId].byId, ...newPeerStories.byId }; acc[peerId].orderedIds = unique(newPeerStories.orderedIds.concat(acc[peerId].orderedIds)); - acc[peerId].pinnedIds = unique(newPeerStories.pinnedIds.concat(acc[peerId].pinnedIds)).sort((a, b) => b - a); + acc[peerId].profileIds = unique(newPeerStories.profileIds.concat(acc[peerId].profileIds)).sort((a, b) => b - a); acc[peerId].lastUpdatedAt = newPeerStories.lastUpdatedAt; acc[peerId].lastReadId = newPeerStories.lastReadId; } @@ -52,18 +52,19 @@ export function addStoriesForPeer( global: T, peerId: string, newStories: Record, + newPinnedIds?: number[], addToArchive?: boolean, ): T { const { - byId, orderedIds, pinnedIds, archiveIds, + byId, orderedIds, profileIds, archiveIds, pinnedIds, } = global.stories.byPeerId[peerId] || {}; const deletedIds = Object.keys(newStories).filter((id) => 'isDeleted' in newStories[Number(id)]).map(Number); const updatedById = { ...byId, ...newStories }; let updatedOrderedIds = [...(orderedIds || [])]; let updatedArchiveIds = [...(archiveIds || [])]; - const updatedPinnedIds = unique( - [...(pinnedIds || [])].concat(Object.values(newStories).reduce((ids, story) => { - if ('isPinned' in story && story.isPinned) { + const updatedProfileIds = unique( + [...(profileIds || [])].concat(Object.values(newStories).reduce((ids, story) => { + if ('isInProfile' in story && story.isInProfile) { ids.push(story.id); } @@ -95,7 +96,8 @@ export function addStoriesForPeer( ...global.stories.byPeerId[peerId], byId: updatedById, orderedIds: updatedOrderedIds, - pinnedIds: updatedPinnedIds, + profileIds: updatedProfileIds, + pinnedIds: pinnedIds || newPinnedIds, ...(addToArchive && { archiveIds: updatedArchiveIds }), }, }, @@ -129,6 +131,27 @@ export function updateStoriesForPeer( }; } +export function updatePeerStoriesFullyLoaded( + global: T, + peerId: string, + isFullyLoaded: boolean, + isArchive?: boolean, +): T { + return { + ...global, + stories: { + ...global.stories, + byPeerId: { + ...global.stories.byPeerId, + [peerId]: { + ...global.stories.byPeerId[peerId], + [isArchive ? 'isArchiveFullyLoaded' : 'isFullyLoaded']: isFullyLoaded, + }, + }, + }, + }; +} + export function updateLastReadStoryForPeer( global: T, peerId: string, @@ -258,11 +281,11 @@ export function removePeerStory( storyId: number, ): T { const { - orderedIds, pinnedIds, lastReadId, byId, - } = selectPeerStories(global, peerId) || { orderedIds: [] as number[], pinnedIds: [] as number[] }; + orderedIds, profileIds, lastReadId, byId, + } = selectPeerStories(global, peerId) || { orderedIds: [] as number[], profileIds: [] as number[] }; const newOrderedIds = orderedIds.filter((id) => id !== storyId); - const newPinnedIds = pinnedIds.filter((id) => id !== storyId); + const newProfileIds = profileIds.filter((id) => id !== storyId); const lastStoryId = newOrderedIds.length ? orderedIds[orderedIds.length - 1] : undefined; const previousStoryId = orderedIds[orderedIds.indexOf(storyId) - 1]; @@ -283,7 +306,7 @@ export function removePeerStory( global = updateStoriesForPeer(global, peerId, { byId: newById, orderedIds: newOrderedIds, - pinnedIds: newPinnedIds, + profileIds: newProfileIds, lastUpdatedAt, lastReadId: newLastReadId, }); @@ -346,7 +369,7 @@ export function updatePeerStory( storyUpdate: Partial, ): T { const peerStories = selectPeerStories(global, peerId) || { - byId: {}, orderedIds: [], pinnedIds: [], archiveIds: [], + byId: {}, orderedIds: [], profileIds: [], archiveIds: [], }; return { @@ -389,19 +412,19 @@ export function updatePeerStoryViews( }); } -export function updatePeerPinnedStory( +export function updatePeerProfileStory( global: T, peerId: string, storyId: number, - isPinned?: boolean, + isInProfile?: boolean, ): T { const peerStories = selectPeerStories(global, peerId) || { - byId: {}, orderedIds: [], pinnedIds: [], archiveIds: [], + byId: {}, orderedIds: [], profileIds: [], archiveIds: [], }; - const newPinnedIds = isPinned - ? unique(peerStories.pinnedIds.concat(storyId)).sort((a, b) => b - a) - : peerStories.pinnedIds.filter((id) => storyId !== id); + const newProfileIds = isInProfile + ? unique(peerStories.profileIds.concat(storyId)).sort((a, b) => b - a) + : peerStories.profileIds.filter((id) => storyId !== id); return { ...global, @@ -411,7 +434,7 @@ export function updatePeerPinnedStory( ...global.stories.byPeerId, [peerId]: { ...peerStories, - pinnedIds: newPinnedIds, + profileIds: newProfileIds, }, }, }, diff --git a/src/global/selectors/stories.ts b/src/global/selectors/stories.ts index bd2c0654d..e1a9be0d0 100644 --- a/src/global/selectors/stories.ts +++ b/src/global/selectors/stories.ts @@ -35,6 +35,16 @@ export function selectPeerStory( return selectPeerStories(global, peerId)?.byId[storyId]; } +export function selectPinnedStories( + global: T, peerId: string, +) { + const stories = selectPeerStories(global, peerId); + if (!stories?.pinnedIds?.length) return undefined; + return stories.pinnedIds.map((id) => stories.byId[id]).filter((s) => ( + s && 'isInProfile' in s && s.isInProfile + )); +} + export function selectPeerFirstUnreadStoryId( global: T, peerId: string, ) { @@ -124,7 +134,7 @@ function getPeerStoryIdsForViewer( isPrivate?: boolean, ): number[] | undefined { const peerStories = selectPeerStories(global, peerId); - const storySourceProp = isArchive ? 'archiveIds' : isPrivate ? 'pinnedIds' : 'orderedIds'; + const storySourceProp = isArchive ? 'archiveIds' : isPrivate ? 'profileIds' : 'orderedIds'; const storyIds = peerStories?.[storySourceProp]; if (!peerStories || !storyIds?.length) { diff --git a/src/global/types.ts b/src/global/types.ts index e1aa00f8a..3915b514a 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -2267,7 +2267,7 @@ export interface ActionPayloads { loadPeerStories: { peerId: string; }; - loadPeerPinnedStories: { + loadPeerProfileStories: { peerId: string; offsetId?: number; } & WithTabId; @@ -2290,11 +2290,15 @@ export interface ActionPayloads { peerId: string; storyId: number; } & WithTabId; - toggleStoryPinned: { + toggleStoryInProfile: { peerId: string; storyId: number; - isPinned?: boolean; - } & WithTabId; + isInProfile?: boolean; + }; + toggleStoryPinnedToTop: { + peerId: string; + storyId: number; + }; toggleStoryRibbon: { isShown: boolean; isArchived?: boolean; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 6d9530d49..70e2b1088 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1634,6 +1634,7 @@ stories.sendReaction#7fd736b2 flags:# add_to_recent:flags.0?true peer:InputPeer stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories; stories.getPeerMaxIDs#535983c3 id:Vector = Vector; stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; +stories.togglePinnedToTop#b297e9b peer:InputPeer id:Vector = Bool; premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList; premium.getMyBoosts#be77b4a = premium.MyBoosts; premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector peer:InputPeer = premium.MyBoosts; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 676642104..3f30966ac 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -340,6 +340,7 @@ "stories.togglePeerStoriesHidden", "stories.getPeerStories", "stories.getStoriesViews", + "stories.togglePinnedToTop", "premium.getBoostsStatus", "premium.getBoostersList", "premium.applyBoost",