Profile: Support pinned stories (#4516)

This commit is contained in:
Alexander Zinchuk 2024-05-14 04:23:30 +02:00
parent cf8eaf270e
commit 4ca1398e34
18 changed files with 300 additions and 80 deletions

View File

@ -71,6 +71,7 @@ export interface GramJsAppConfig extends LimitsConfig {
story_expire_period: number; story_expire_period: number;
story_viewers_expire_period: number; story_viewers_expire_period: number;
stories_changelog_user_id?: number; stories_changelog_user_id?: number;
stories_pinned_to_top_count_max?: number;
// Boosts // Boosts
group_transcribe_level_min?: number; group_transcribe_level_min?: number;
new_noncontact_peers_require_premium_without_ownpremium?: boolean; 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, storyExpirePeriod: appConfig.story_expire_period ?? STORY_EXPIRE_PERIOD,
storyViewersExpirePeriod: appConfig.story_viewers_expire_period ?? STORY_VIEWERS_EXPIRE_PERIOD, storyViewersExpirePeriod: appConfig.story_viewers_expire_period ?? STORY_VIEWERS_EXPIRE_PERIOD,
storyChangelogUserId: appConfig.stories_changelog_user_id?.toString() ?? SERVICE_NOTIFICATIONS_USER_ID, 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, groupTranscribeLevelMin: appConfig.group_transcribe_level_min,
canLimitNewMessagesWithoutPremium: appConfig.new_noncontact_peers_require_premium_without_ownpremium, canLimitNewMessagesWithoutPremium: appConfig.new_noncontact_peers_require_premium_without_ownpremium,
bandwidthPremiumNotifyPeriod: appConfig.upload_premium_speedup_notify_period, bandwidthPremiumNotifyPeriod: appConfig.upload_premium_speedup_notify_period,

View File

@ -66,7 +66,7 @@ export function buildApiStory(peerId: string, story: GramJs.TypeStoryItem): ApiT
content, content,
isPublic, isPublic,
isEdited: edited, isEdited: edited,
isPinned: pinned, isInProfile: pinned,
isForContacts: contacts, isForContacts: contacts,
isForSelectedContacts: selectedContacts, isForSelectedContacts: selectedContacts,
isForCloseFriends: closeFriends, isForCloseFriends: closeFriends,

View File

@ -77,15 +77,15 @@ export async function fetchAllStories({
const allUserStories = result.peerStories.reduce<Record<string, ApiPeerStories>>((acc, peerStories) => { const allUserStories = result.peerStories.reduce<Record<string, ApiPeerStories>>((acc, peerStories) => {
const peerId = getApiChatIdFromMtpPeer(peerStories.peer); const peerId = getApiChatIdFromMtpPeer(peerStories.peer);
const stories = buildApiPeerStories(peerStories); 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[]; orderedIds: number[];
lastUpdatedAt?: number; lastUpdatedAt?: number;
} }
>((dataAcc, story) => { >((dataAcc, story) => {
if ('isPinned' in story && story.isPinned) { if ('isInProfile' in story && story.isInProfile) {
dataAcc.pinnedIds.push(story.id); dataAcc.profileIds.push(story.id);
} }
if (!('isDeleted' in story)) { if (!('isDeleted' in story)) {
dataAcc.orderedIds.push(story.id); dataAcc.orderedIds.push(story.id);
@ -94,7 +94,7 @@ export async function fetchAllStories({
return dataAcc; return dataAcc;
}, { }, {
pinnedIds: [], profileIds: [],
orderedIds: [], orderedIds: [],
lastUpdatedAt: undefined, lastUpdatedAt: undefined,
}); });
@ -106,7 +106,7 @@ export async function fetchAllStories({
acc[peerId] = { acc[peerId] = {
byId: stories, byId: stories,
orderedIds, orderedIds,
pinnedIds, profileIds,
lastUpdatedAt, lastUpdatedAt,
lastReadId: peerStories.maxReadId, lastReadId: peerStories.maxReadId,
}; };
@ -154,7 +154,7 @@ export async function fetchPeerStories({
}; };
} }
export function fetchPeerPinnedStories({ export function fetchPeerProfileStories({
peer, offsetId, peer, offsetId,
}: { }: {
peer: ApiPeer; peer: ApiPeer;
@ -221,6 +221,7 @@ export async function fetchPeerStoriesByIds({ peer, ids }: { peer: ApiPeer; ids:
return { return {
chats, chats,
users, users,
pinnedIds: result.pinnedToTop,
stories, 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({ return invokeRequest(new GramJs.stories.TogglePinned({
peer: buildInputPeer(peer.id, peer.accessHash), peer: buildInputPeer(peer.id, peer.accessHash),
id: [storyId], 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, users,
chats, chats,
stories, stories,
pinnedIds: result.pinnedToTop,
}; };
} }

View File

@ -203,6 +203,7 @@ export interface ApiAppConfig {
storyExpirePeriod: number; storyExpirePeriod: number;
storyViewersExpirePeriod: number; storyViewersExpirePeriod: number;
storyChangelogUserId: string; storyChangelogUserId: string;
maxPinnedStoriesCount?: number;
groupTranscribeLevelMin?: number; groupTranscribeLevelMin?: number;
canLimitNewMessagesWithoutPremium?: boolean; canLimitNewMessagesWithoutPremium?: boolean;
bandwidthPremiumNotifyPeriod?: number; bandwidthPremiumNotifyPeriod?: number;

View File

@ -10,7 +10,7 @@ export interface ApiStory {
date: number; date: number;
expireDate: number; expireDate: number;
content: MediaContent; content: MediaContent;
isPinned?: boolean; isInProfile?: boolean;
isEdited?: boolean; isEdited?: boolean;
isForCloseFriends?: boolean; isForCloseFriends?: boolean;
isForContacts?: boolean; isForContacts?: boolean;
@ -56,8 +56,11 @@ export type ApiTypeStory = ApiStory | ApiStorySkipped | ApiStoryDeleted;
export type ApiPeerStories = { export type ApiPeerStories = {
byId: Record<number, ApiTypeStory>; byId: Record<number, ApiTypeStory>;
orderedIds: number[]; // Actual peer stories 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 archiveIds?: number[]; // Profile Shared Media: Archive Stories tab
isArchiveFullyLoaded?: boolean;
lastUpdatedAt?: number; lastUpdatedAt?: number;
lastReadId?: number; lastReadId?: number;
}; };

View File

@ -109,6 +109,7 @@ type StateProps = {
adminMembersById?: Record<string, ApiChatMember>; adminMembersById?: Record<string, ApiChatMember>;
commonChatIds?: string[]; commonChatIds?: string[];
storyIds?: number[]; storyIds?: number[];
pinnedStoryIds?: number[];
archiveStoryIds?: number[]; archiveStoryIds?: number[];
storyByIds?: Record<number, ApiTypeStory>; storyByIds?: Record<number, ApiTypeStory>;
chatsById: Record<string, ApiChat>; chatsById: Record<string, ApiChat>;
@ -155,6 +156,7 @@ const Profile: FC<OwnProps & StateProps> = ({
messagesById, messagesById,
foundIds, foundIds,
storyIds, storyIds,
pinnedStoryIds,
archiveStoryIds, archiveStoryIds,
storyByIds, storyByIds,
mediaSearchType, mediaSearchType,
@ -194,7 +196,7 @@ const Profile: FC<OwnProps & StateProps> = ({
focusMessage, focusMessage,
loadProfilePhotos, loadProfilePhotos,
setNewChatMembersDialogState, setNewChatMembersDialogState,
loadPeerPinnedStories, loadPeerProfileStories,
loadStoriesArchive, loadStoriesArchive,
openPremiumModal, openPremiumModal,
loadChannelRecommendations, loadChannelRecommendations,
@ -264,7 +266,7 @@ const Profile: FC<OwnProps & StateProps> = ({
const renderingActiveTab = activeTab > tabs.length - 1 ? tabs.length - 1 : activeTab; const renderingActiveTab = activeTab > tabs.length - 1 ? tabs.length - 1 : activeTab;
const tabType = tabs[renderingActiveTab].type as ProfileTabType; const tabType = tabs[renderingActiveTab].type as ProfileTabType;
const handleLoadPeerStories = useCallback(({ offsetId }: { offsetId: number }) => { const handleLoadPeerStories = useCallback(({ offsetId }: { offsetId: number }) => {
loadPeerPinnedStories({ peerId: chatId, offsetId }); loadPeerProfileStories({ peerId: chatId, offsetId });
}, [chatId]); }, [chatId]);
const handleLoadStoriesArchive = useCallback(({ offsetId }: { offsetId: number }) => { const handleLoadStoriesArchive = useCallback(({ offsetId }: { offsetId: number }) => {
loadStoriesArchive({ peerId: currentUserId!, offsetId }); loadStoriesArchive({ peerId: currentUserId!, offsetId });
@ -287,6 +289,7 @@ const Profile: FC<OwnProps & StateProps> = ({
foundIds, foundIds,
threadId, threadId,
storyIds, storyIds,
pinnedStoryIds,
archiveStoryIds, archiveStoryIds,
similarChannels, similarChannels,
); );
@ -484,11 +487,11 @@ const Profile: FC<OwnProps & StateProps> = ({
/> />
)) ))
) : (resultType === 'stories' || resultType === 'storiesArchive') ? ( ) : (resultType === 'stories' || resultType === 'storiesArchive') ? (
(viewportIds as number[])!.map((id) => storyByIds?.[id] && ( (viewportIds as number[])!.map((id, i) => storyByIds?.[id] && (
<MediaStory <MediaStory
teactOrderKey={i}
key={`${resultType}_${id}`} key={`${resultType}_${id}`}
story={storyByIds[id]} story={storyByIds[id]}
isProtected={isChatProtected}
isArchive={resultType === 'storiesArchive'} isArchive={resultType === 'storiesArchive'}
/> />
)) ))
@ -718,7 +721,8 @@ export default memo(withGlobal<OwnProps>(
const hasStoriesTab = peer && (user?.isSelf || (!peer.areStoriesHidden && peerFullInfo?.hasPinnedStories)) const hasStoriesTab = peer && (user?.isSelf || (!peer.areStoriesHidden && peerFullInfo?.hasPinnedStories))
&& !isSavedDialog; && !isSavedDialog;
const peerStories = hasStoriesTab ? selectPeerStories(global, peer.id) : undefined; 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 storyByIds = peerStories?.byId;
const archiveStoryIds = peerStories?.archiveIds; const archiveStoryIds = peerStories?.archiveIds;
@ -743,6 +747,7 @@ export default memo(withGlobal<OwnProps>(
userStatusesById, userStatusesById,
chatsById, chatsById,
storyIds, storyIds,
pinnedStoryIds,
archiveStoryIds, archiveStoryIds,
storyByIds, storyByIds,
isChatProtected: chat?.isProtected, isChatProtected: chat?.isProtected,

View File

@ -29,6 +29,7 @@ export default function useProfileViewportIds(
foundIds?: number[], foundIds?: number[],
threadId?: ThreadId, threadId?: ThreadId,
storyIds?: number[], storyIds?: number[],
pinnedStoryIds?: number[],
archiveStoryIds?: number[], archiveStoryIds?: number[],
similarChannels?: string[], similarChannels?: string[],
) { ) {
@ -82,8 +83,20 @@ export default function useProfileViewportIds(
loadCommonChats, chatIds, 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( const [storyViewportIds, getMoreStories, noProfileInfoForStories] = useInfiniteScrollForLoadableItems(
loadStories, storyIds, loadStories, sortedStoryIds,
); );
const [ const [

View File

@ -25,6 +25,35 @@
vertical-align: -0.1875rem; 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 { .contextMenu {
position: relative; position: relative;
z-index: var(--z-right-column-menu); z-index: var(--z-right-column-menu);

View File

@ -1,12 +1,14 @@
import React, { import React, {
memo, useCallback, useEffect, useRef, memo, useCallback, useEffect, useRef,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions } from '../../global'; import { getActions, withGlobal } from '../../global';
import type { ApiStory, ApiTypeStory } from '../../api/types'; import type { ApiStory, ApiTypeStory } from '../../api/types';
import { getStoryMediaHash } from '../../global/helpers'; import { getStoryMediaHash } from '../../global/helpers';
import { selectChat, selectPinnedStories } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { formatMediaDuration } from '../../util/date/dateFormat';
import stopEvent from '../../util/stopEvent'; import stopEvent from '../../util/stopEvent';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
@ -16,6 +18,7 @@ import useLastCallback from '../../hooks/useLastCallback';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import useMenuPosition from '../../hooks/useMenuPosition'; import useMenuPosition from '../../hooks/useMenuPosition';
import Icon from '../common/Icon';
import Menu from '../ui/Menu'; import Menu from '../ui/Menu';
import MenuItem from '../ui/MenuItem'; import MenuItem from '../ui/MenuItem';
import MediaAreaOverlay from './mediaArea/MediaAreaOverlay'; import MediaAreaOverlay from './mediaArea/MediaAreaOverlay';
@ -24,15 +27,23 @@ import styles from './MediaStory.module.scss';
interface OwnProps { interface OwnProps {
story: ApiTypeStory; story: ApiTypeStory;
isProtected?: boolean;
isArchive?: 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 { const {
openStoryViewer, openStoryViewer,
loadPeerSkippedStories, loadPeerSkippedStories,
toggleStoryPinned, toggleStoryInProfile,
toggleStoryPinnedToTop,
showNotification, showNotification,
} = getActions(); } = getActions();
@ -50,6 +61,7 @@ function MediaStory({ story, isProtected, isArchive }: OwnProps) {
const isOwn = isFullyLoaded && story.isOut; const isOwn = isFullyLoaded && story.isOut;
const isDeleted = story && 'isDeleted' in story; const isDeleted = story && 'isDeleted' in story;
const video = isFullyLoaded ? (story as ApiStory).content.video : undefined; const video = isFullyLoaded ? (story as ApiStory).content.video : undefined;
const duration = video && formatMediaDuration(video.duration);
const imageHash = isFullyLoaded ? getStoryMediaHash(story as ApiStory) : undefined; const imageHash = isFullyLoaded ? getStoryMediaHash(story as ApiStory) : undefined;
const imgBlobUrl = useMedia(imageHash); const imgBlobUrl = useMedia(imageHash);
const thumbUrl = imgBlobUrl || video?.thumbnail?.dataUri; const thumbUrl = imgBlobUrl || video?.thumbnail?.dataUri;
@ -90,26 +102,30 @@ function MediaStory({ story, isProtected, isArchive }: OwnProps) {
handleBeforeContextMenu(e); handleBeforeContextMenu(e);
}); });
const handlePinClick = useLastCallback((e: React.SyntheticEvent) => { const handleUnarchiveClick = useLastCallback((e: React.SyntheticEvent) => {
stopEvent(e); stopEvent(e);
toggleStoryPinned({ peerId, storyId: story.id, isPinned: true }); toggleStoryInProfile({ peerId, storyId: story.id, isInProfile: true });
showNotification({ showNotification({
message: lang('Story.ToastSavedToProfileText'), message: lang('Story.ToastSavedToProfileText'),
}); });
handleContextMenuClose(); handleContextMenuClose();
}); });
const handleUnpinClick = useLastCallback((e: React.SyntheticEvent) => { const handleArchiveClick = useLastCallback((e: React.SyntheticEvent) => {
stopEvent(e); stopEvent(e);
toggleStoryPinned({ peerId, storyId: story.id, isPinned: false }); toggleStoryInProfile({ peerId, storyId: story.id, isInProfile: false });
showNotification({ showNotification({
message: lang('Story.ToastRemovedFromProfileText'), message: lang('Story.ToastRemovedFromProfileText'),
}); });
handleContextMenuClose(); handleContextMenuClose();
}); });
const handleTogglePinned = useLastCallback(() => {
toggleStoryPinnedToTop({ peerId, storyId: story.id });
});
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@ -120,10 +136,18 @@ function MediaStory({ story, isProtected, isArchive }: OwnProps) {
> >
{isDeleted && ( {isDeleted && (
<span> <span>
<i className={buildClassName(styles.expiredIcon, 'icon icon-story-expired')} aria-hidden /> <Icon className={styles.expiredIcon} name="story-expired" />
{lang('ExpiredStory')} {lang('ExpiredStory')}
</span> </span>
)} )}
{isPinned && <Icon className={buildClassName(styles.overlayIcon, styles.pinnedIcon)} name="pin-badge" />}
{isFullyLoaded && Boolean(story.views?.viewsCount) && (
<span className={buildClassName(styles.overlayIcon, styles.viewsCount)}>
<Icon name="eye" />
{story.views.viewsCount}
</span>
)}
{duration && <span className={buildClassName(styles.overlayIcon, styles.duration)}>{duration}</span>}
<div className={styles.wrapper}> <div className={styles.wrapper}>
{thumbUrl && ( {thumbUrl && (
<img src={thumbUrl} alt="" className={styles.media} draggable={false} /> <img src={thumbUrl} alt="" className={styles.media} draggable={false} />
@ -145,16 +169,45 @@ function MediaStory({ story, isProtected, isArchive }: OwnProps) {
onCloseAnimationEnd={handleContextMenuHide} onCloseAnimationEnd={handleContextMenuHide}
withPortal withPortal
> >
{isArchive && <MenuItem icon="pin" onClick={handlePinClick}>{lang('StoryList.SaveToProfile')}</MenuItem>} {isArchive && (
<MenuItem icon="archive" onClick={handleUnarchiveClick}>
{lang('StoryList.SaveToProfile')}
</MenuItem>
)}
{!isArchive && ( {!isArchive && (
<MenuItem icon="unpin" onClick={handleUnpinClick}> <MenuItem icon="archive" onClick={handleArchiveClick}>
{lang('Story.Context.RemoveFromProfile')} {lang('Story.Context.RemoveFromProfile')}
</MenuItem> </MenuItem>
)} )}
{!isArchive && !isPinned && canPin && (
<MenuItem icon="pin" onClick={handleTogglePinned}>
{lang('StoryList.ItemAction.Pin')}
</MenuItem>
)}
{!isArchive && isPinned && (
<MenuItem icon="unpin" onClick={handleTogglePinned}>
{lang('StoryList.ItemAction.Unpin')}
</MenuItem>
)}
</Menu> </Menu>
)} )}
</div> </div>
); );
} }
export default memo(MediaStory); export default memo(withGlobal<OwnProps>((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));

View File

@ -140,7 +140,7 @@ function Story({
loadPeerSkippedStories, loadPeerSkippedStories,
openForwardMenu, openForwardMenu,
copyStoryLink, copyStoryLink,
toggleStoryPinned, toggleStoryInProfile,
openChat, openChat,
showNotification, showNotification,
openStoryPrivacyEditor, openStoryPrivacyEditor,
@ -188,11 +188,11 @@ function Story({
const isOut = isLoadedStory && story.isOut; const isOut = isLoadedStory && story.isOut;
const canPinToProfile = useCurrentOrPrev( const canPinToProfile = useCurrentOrPrev(
isOut ? !story.isPinned : undefined, isOut ? !story.isInProfile : undefined,
true, true,
); );
const canUnpinFromProfile = useCurrentOrPrev( const canUnpinFromProfile = useCurrentOrPrev(
isOut ? story.isPinned : undefined, isOut ? story.isInProfile : undefined,
true, true,
); );
const areViewsExpired = Boolean( const areViewsExpired = Boolean(
@ -460,11 +460,11 @@ function Story({
}); });
const handlePinClick = useLastCallback(() => { const handlePinClick = useLastCallback(() => {
toggleStoryPinned({ peerId, storyId, isPinned: true }); toggleStoryInProfile({ peerId, storyId, isInProfile: true });
}); });
const handleUnpinClick = useLastCallback(() => { const handleUnpinClick = useLastCallback(() => {
toggleStoryPinned({ peerId, storyId, isPinned: false }); toggleStoryInProfile({ peerId, storyId, isInProfile: false });
}); });
const handleDeleteStoryClick = useLastCallback(() => { const handleDeleteStoryClick = useLastCallback(() => {

View File

@ -91,12 +91,12 @@ function StorySettings({
currentUserId, currentUserId,
onClose, onClose,
}: OwnProps & StateProps) { }: OwnProps & StateProps) {
const { editStoryPrivacy, toggleStoryPinned } = getActions(); const { editStoryPrivacy, toggleStoryInProfile } = getActions();
const lang = useLang(); const lang = useLang();
const [isOpenModal, openModal, closeModal] = useFlag(false); const [isOpenModal, openModal, closeModal] = useFlag(false);
const [privacy, setPrivacy] = useState<ApiPrivacySettings | undefined>(visibility); const [privacy, setPrivacy] = useState<ApiPrivacySettings | undefined>(visibility);
const [isPinned, setIsPinned] = useState(story?.isPinned); const [isPinned, setIsPinned] = useState(story?.isInProfile);
const [activeKey, setActiveKey] = useState<Screens>(Screens.privacy); const [activeKey, setActiveKey] = useState<Screens>(Screens.privacy);
const [editingBlockingCategory, setEditingBlockingCategory] = useState<PrivacyVisibility>('everybody'); const [editingBlockingCategory, setEditingBlockingCategory] = useState<PrivacyVisibility>('everybody');
const isBackButton = activeKey !== Screens.privacy; const isBackButton = activeKey !== Screens.privacy;
@ -195,8 +195,8 @@ function StorySettings({
storyId: story!.id, storyId: story!.id,
privacy: privacy!, privacy: privacy!,
}); });
if (story!.isPinned !== isPinned) { if (story!.isInProfile !== isPinned) {
toggleStoryPinned({ peerId: story!.peerId, storyId: story!.id, isPinned }); toggleStoryInProfile({ peerId: story!.peerId, storyId: story!.id, isInProfile: isPinned });
} }
closeModal(); closeModal();
}); });

View File

@ -17,7 +17,8 @@ import {
updateLastReadStoryForPeer, updateLastReadStoryForPeer,
updateLastViewedStoryForPeer, updateLastViewedStoryForPeer,
updatePeer, updatePeer,
updatePeerPinnedStory, updatePeerProfileStory,
updatePeerStoriesFullyLoaded,
updatePeerStoriesHidden, updatePeerStoriesHidden,
updatePeerStory, updatePeerStory,
updatePeerStoryViews, updatePeerStoryViews,
@ -29,6 +30,7 @@ import {
} from '../../reducers'; } from '../../reducers';
import { import {
selectPeer, selectPeerStories, selectPeerStory, selectPeer, selectPeerStories, selectPeerStory,
selectPinnedStories,
} from '../../selectors'; } from '../../selectors';
const INFINITE_LOOP_MARKER = 100; const INFINITE_LOOP_MARKER = 100;
@ -153,7 +155,7 @@ addActionHandler('loadPeerSkippedStories', async (global, actions, payload): Pro
global = getGlobal(); global = getGlobal();
global = addUsers(global, buildCollectionByKey(result.users, 'id')); global = addUsers(global, buildCollectionByKey(result.users, 'id'));
global = addChats(global, buildCollectionByKey(result.chats, 'id')); global = addChats(global, buildCollectionByKey(result.chats, 'id'));
global = addStoriesForPeer(global, peerId, result.stories); global = addStoriesForPeer(global, peerId, result.stories, result.pinnedIds);
setGlobal(global); setGlobal(global);
}); });
@ -170,7 +172,7 @@ addActionHandler('viewStory', async (global, actions, payload): Promise<void> =>
const serverTime = getServerTime(); const serverTime = getServerTime();
if (story.expireDate < serverTime && story.isPinned) { if (story.expireDate < serverTime && story.isInProfile) {
void callApi('viewStory', { peer, storyId }); void callApi('viewStory', { peer, storyId });
} }
@ -212,8 +214,8 @@ addActionHandler('deleteStory', async (global, actions, payload): Promise<void>
setGlobal(global); setGlobal(global);
}); });
addActionHandler('toggleStoryPinned', async (global, actions, payload): Promise<void> => { addActionHandler('toggleStoryInProfile', async (global, actions, payload): Promise<void> => {
const { peerId, storyId, isPinned } = payload; const { peerId, storyId, isInProfile } = payload;
const peer = selectPeer(global, peerId); const peer = selectPeer(global, peerId);
if (!peer) { if (!peer) {
@ -221,16 +223,63 @@ addActionHandler('toggleStoryPinned', async (global, actions, payload): Promise<
} }
const story = selectPeerStory(global, peerId, storyId); const story = selectPeerStory(global, peerId, storyId);
const currentIsPinned = story && 'content' in story ? story.isPinned : undefined; const currentIsPinned = story && 'content' in story ? story.isInProfile : undefined;
global = updatePeerStory(global, peerId, storyId, { isPinned }); global = updatePeerStory(global, peerId, storyId, { isInProfile });
global = updatePeerPinnedStory(global, peerId, storyId, isPinned); global = updatePeerProfileStory(global, peerId, storyId, isInProfile);
setGlobal(global); 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<void> => {
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) { if (!result) {
global = getGlobal(); global = getGlobal();
global = updatePeerStory(global, peerId, storyId, { isPinned: currentIsPinned }); global = {
global = updatePeerPinnedStory(global, peerId, storyId, currentIsPinned); ...global,
stories: {
...global.stories,
byPeerId: {
...global.stories.byPeerId,
[peerId]: {
...peerStories,
pinnedIds: oldPinnedIds,
},
},
},
};
setGlobal(global); setGlobal(global);
} }
}); });
@ -255,29 +304,35 @@ addActionHandler('loadPeerStories', async (global, actions, payload): Promise<vo
setGlobal(global); setGlobal(global);
}); });
addActionHandler('loadPeerPinnedStories', async (global, actions, payload): Promise<void> => { addActionHandler('loadPeerProfileStories', async (global, actions, payload): Promise<void> => {
const { peerId, offsetId } = payload; const { peerId, offsetId } = payload;
const peer = selectPeer(global, peerId); const peer = selectPeer(global, peerId);
if (!peer) { const peerStories = selectPeerStories(global, peerId);
if (!peer || peerStories?.isFullyLoaded) {
return; return;
} }
const result = await callApi('fetchPeerPinnedStories', { peer, offsetId }); const result = await callApi('fetchPeerProfileStories', { peer, offsetId });
if (!result) { if (!result) {
return; return;
} }
global = getGlobal(); global = getGlobal();
if (Object.values(result.stories).length === 0) {
global = updatePeerStoriesFullyLoaded(global, peerId, true);
}
global = addUsers(global, buildCollectionByKey(result.users, 'id')); global = addUsers(global, buildCollectionByKey(result.users, 'id'));
global = addChats(global, buildCollectionByKey(result.chats, 'id')); global = addChats(global, buildCollectionByKey(result.chats, 'id'));
global = addStoriesForPeer(global, peerId, result.stories); global = addStoriesForPeer(global, peerId, result.stories, result.pinnedIds);
setGlobal(global); setGlobal(global);
}); });
addActionHandler('loadStoriesArchive', async (global, actions, payload): Promise<void> => { addActionHandler('loadStoriesArchive', async (global, actions, payload): Promise<void> => {
const { peerId, offsetId } = payload; const { peerId, offsetId } = payload;
const peer = selectPeer(global, peerId); 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 }); const result = await callApi('fetchStoriesArchive', { peer, offsetId });
if (!result) { if (!result) {
@ -285,9 +340,12 @@ addActionHandler('loadStoriesArchive', async (global, actions, payload): Promise
} }
global = getGlobal(); global = getGlobal();
if (Object.values(result.stories).length === 0) {
global = updatePeerStoriesFullyLoaded(global, peerId, true, true);
}
global = addUsers(global, buildCollectionByKey(result.users, 'id')); global = addUsers(global, buildCollectionByKey(result.users, 'id'));
global = addChats(global, buildCollectionByKey(result.chats, '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); setGlobal(global);
}); });

View File

@ -48,7 +48,7 @@ function checkStoryExpiration() {
stories.forEach((story) => { stories.forEach((story) => {
if (!('expireDate' in story)) return; if (!('expireDate' in story)) return;
if (story.expireDate > serverTime) 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; if ('isPublic' in story && !story.isPublic) return;
global = removePeerStory(global, story.peerId, story.id); global = removePeerStory(global, story.peerId, story.id);

View File

@ -29,7 +29,7 @@ export function addStories<T extends GlobalState>(global: T, newStoriesByPeerId:
} else { } else {
acc[peerId].byId = { ...acc[peerId].byId, ...newPeerStories.byId }; acc[peerId].byId = { ...acc[peerId].byId, ...newPeerStories.byId };
acc[peerId].orderedIds = unique(newPeerStories.orderedIds.concat(acc[peerId].orderedIds)); 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].lastUpdatedAt = newPeerStories.lastUpdatedAt;
acc[peerId].lastReadId = newPeerStories.lastReadId; acc[peerId].lastReadId = newPeerStories.lastReadId;
} }
@ -52,18 +52,19 @@ export function addStoriesForPeer<T extends GlobalState>(
global: T, global: T,
peerId: string, peerId: string,
newStories: Record<number, ApiTypeStory>, newStories: Record<number, ApiTypeStory>,
newPinnedIds?: number[],
addToArchive?: boolean, addToArchive?: boolean,
): T { ): T {
const { const {
byId, orderedIds, pinnedIds, archiveIds, byId, orderedIds, profileIds, archiveIds, pinnedIds,
} = global.stories.byPeerId[peerId] || {}; } = global.stories.byPeerId[peerId] || {};
const deletedIds = Object.keys(newStories).filter((id) => 'isDeleted' in newStories[Number(id)]).map(Number); const deletedIds = Object.keys(newStories).filter((id) => 'isDeleted' in newStories[Number(id)]).map(Number);
const updatedById = { ...byId, ...newStories }; const updatedById = { ...byId, ...newStories };
let updatedOrderedIds = [...(orderedIds || [])]; let updatedOrderedIds = [...(orderedIds || [])];
let updatedArchiveIds = [...(archiveIds || [])]; let updatedArchiveIds = [...(archiveIds || [])];
const updatedPinnedIds = unique( const updatedProfileIds = unique(
[...(pinnedIds || [])].concat(Object.values(newStories).reduce((ids, story) => { [...(profileIds || [])].concat(Object.values(newStories).reduce((ids, story) => {
if ('isPinned' in story && story.isPinned) { if ('isInProfile' in story && story.isInProfile) {
ids.push(story.id); ids.push(story.id);
} }
@ -95,7 +96,8 @@ export function addStoriesForPeer<T extends GlobalState>(
...global.stories.byPeerId[peerId], ...global.stories.byPeerId[peerId],
byId: updatedById, byId: updatedById,
orderedIds: updatedOrderedIds, orderedIds: updatedOrderedIds,
pinnedIds: updatedPinnedIds, profileIds: updatedProfileIds,
pinnedIds: pinnedIds || newPinnedIds,
...(addToArchive && { archiveIds: updatedArchiveIds }), ...(addToArchive && { archiveIds: updatedArchiveIds }),
}, },
}, },
@ -129,6 +131,27 @@ export function updateStoriesForPeer<T extends GlobalState>(
}; };
} }
export function updatePeerStoriesFullyLoaded<T extends GlobalState>(
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<T extends GlobalState>( export function updateLastReadStoryForPeer<T extends GlobalState>(
global: T, global: T,
peerId: string, peerId: string,
@ -258,11 +281,11 @@ export function removePeerStory<T extends GlobalState>(
storyId: number, storyId: number,
): T { ): T {
const { const {
orderedIds, pinnedIds, lastReadId, byId, orderedIds, profileIds, lastReadId, byId,
} = selectPeerStories(global, peerId) || { orderedIds: [] as number[], pinnedIds: [] as number[] }; } = selectPeerStories(global, peerId) || { orderedIds: [] as number[], profileIds: [] as number[] };
const newOrderedIds = orderedIds.filter((id) => id !== storyId); 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 lastStoryId = newOrderedIds.length ? orderedIds[orderedIds.length - 1] : undefined;
const previousStoryId = orderedIds[orderedIds.indexOf(storyId) - 1]; const previousStoryId = orderedIds[orderedIds.indexOf(storyId) - 1];
@ -283,7 +306,7 @@ export function removePeerStory<T extends GlobalState>(
global = updateStoriesForPeer(global, peerId, { global = updateStoriesForPeer(global, peerId, {
byId: newById, byId: newById,
orderedIds: newOrderedIds, orderedIds: newOrderedIds,
pinnedIds: newPinnedIds, profileIds: newProfileIds,
lastUpdatedAt, lastUpdatedAt,
lastReadId: newLastReadId, lastReadId: newLastReadId,
}); });
@ -346,7 +369,7 @@ export function updatePeerStory<T extends GlobalState>(
storyUpdate: Partial<ApiStory>, storyUpdate: Partial<ApiStory>,
): T { ): T {
const peerStories = selectPeerStories(global, peerId) || { const peerStories = selectPeerStories(global, peerId) || {
byId: {}, orderedIds: [], pinnedIds: [], archiveIds: [], byId: {}, orderedIds: [], profileIds: [], archiveIds: [],
}; };
return { return {
@ -389,19 +412,19 @@ export function updatePeerStoryViews<T extends GlobalState>(
}); });
} }
export function updatePeerPinnedStory<T extends GlobalState>( export function updatePeerProfileStory<T extends GlobalState>(
global: T, global: T,
peerId: string, peerId: string,
storyId: number, storyId: number,
isPinned?: boolean, isInProfile?: boolean,
): T { ): T {
const peerStories = selectPeerStories(global, peerId) || { const peerStories = selectPeerStories(global, peerId) || {
byId: {}, orderedIds: [], pinnedIds: [], archiveIds: [], byId: {}, orderedIds: [], profileIds: [], archiveIds: [],
}; };
const newPinnedIds = isPinned const newProfileIds = isInProfile
? unique(peerStories.pinnedIds.concat(storyId)).sort((a, b) => b - a) ? unique(peerStories.profileIds.concat(storyId)).sort((a, b) => b - a)
: peerStories.pinnedIds.filter((id) => storyId !== id); : peerStories.profileIds.filter((id) => storyId !== id);
return { return {
...global, ...global,
@ -411,7 +434,7 @@ export function updatePeerPinnedStory<T extends GlobalState>(
...global.stories.byPeerId, ...global.stories.byPeerId,
[peerId]: { [peerId]: {
...peerStories, ...peerStories,
pinnedIds: newPinnedIds, profileIds: newProfileIds,
}, },
}, },
}, },

View File

@ -35,6 +35,16 @@ export function selectPeerStory<T extends GlobalState>(
return selectPeerStories(global, peerId)?.byId[storyId]; return selectPeerStories(global, peerId)?.byId[storyId];
} }
export function selectPinnedStories<T extends GlobalState>(
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<T extends GlobalState>( export function selectPeerFirstUnreadStoryId<T extends GlobalState>(
global: T, peerId: string, global: T, peerId: string,
) { ) {
@ -124,7 +134,7 @@ function getPeerStoryIdsForViewer<T extends GlobalState>(
isPrivate?: boolean, isPrivate?: boolean,
): number[] | undefined { ): number[] | undefined {
const peerStories = selectPeerStories(global, peerId); const peerStories = selectPeerStories(global, peerId);
const storySourceProp = isArchive ? 'archiveIds' : isPrivate ? 'pinnedIds' : 'orderedIds'; const storySourceProp = isArchive ? 'archiveIds' : isPrivate ? 'profileIds' : 'orderedIds';
const storyIds = peerStories?.[storySourceProp]; const storyIds = peerStories?.[storySourceProp];
if (!peerStories || !storyIds?.length) { if (!peerStories || !storyIds?.length) {

View File

@ -2267,7 +2267,7 @@ export interface ActionPayloads {
loadPeerStories: { loadPeerStories: {
peerId: string; peerId: string;
}; };
loadPeerPinnedStories: { loadPeerProfileStories: {
peerId: string; peerId: string;
offsetId?: number; offsetId?: number;
} & WithTabId; } & WithTabId;
@ -2290,11 +2290,15 @@ export interface ActionPayloads {
peerId: string; peerId: string;
storyId: number; storyId: number;
} & WithTabId; } & WithTabId;
toggleStoryPinned: { toggleStoryInProfile: {
peerId: string; peerId: string;
storyId: number; storyId: number;
isPinned?: boolean; isInProfile?: boolean;
} & WithTabId; };
toggleStoryPinnedToTop: {
peerId: string;
storyId: number;
};
toggleStoryRibbon: { toggleStoryRibbon: {
isShown: boolean; isShown: boolean;
isArchived?: boolean; isArchived?: boolean;

View File

@ -1634,6 +1634,7 @@ stories.sendReaction#7fd736b2 flags:# add_to_recent:flags.0?true peer:InputPeer
stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories; stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories;
stories.getPeerMaxIDs#535983c3 id:Vector<InputPeer> = Vector<int>; stories.getPeerMaxIDs#535983c3 id:Vector<InputPeer> = Vector<int>;
stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool;
stories.togglePinnedToTop#b297e9b peer:InputPeer id:Vector<int> = Bool;
premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList; premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList;
premium.getMyBoosts#be77b4a = premium.MyBoosts; premium.getMyBoosts#be77b4a = premium.MyBoosts;
premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector<int> peer:InputPeer = premium.MyBoosts; premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector<int> peer:InputPeer = premium.MyBoosts;

View File

@ -340,6 +340,7 @@
"stories.togglePeerStoriesHidden", "stories.togglePeerStoriesHidden",
"stories.getPeerStories", "stories.getPeerStories",
"stories.getStoriesViews", "stories.getStoriesViews",
"stories.togglePinnedToTop",
"premium.getBoostsStatus", "premium.getBoostsStatus",
"premium.getBoostersList", "premium.getBoostersList",
"premium.applyBoost", "premium.applyBoost",