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_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,

View File

@ -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,

View File

@ -77,15 +77,15 @@ export async function fetchAllStories({
const allUserStories = result.peerStories.reduce<Record<string, ApiPeerStories>>((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,
};
}

View File

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

View File

@ -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<number, ApiTypeStory>;
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;
};

View File

@ -109,6 +109,7 @@ type StateProps = {
adminMembersById?: Record<string, ApiChatMember>;
commonChatIds?: string[];
storyIds?: number[];
pinnedStoryIds?: number[];
archiveStoryIds?: number[];
storyByIds?: Record<number, ApiTypeStory>;
chatsById: Record<string, ApiChat>;
@ -155,6 +156,7 @@ const Profile: FC<OwnProps & StateProps> = ({
messagesById,
foundIds,
storyIds,
pinnedStoryIds,
archiveStoryIds,
storyByIds,
mediaSearchType,
@ -194,7 +196,7 @@ const Profile: FC<OwnProps & StateProps> = ({
focusMessage,
loadProfilePhotos,
setNewChatMembersDialogState,
loadPeerPinnedStories,
loadPeerProfileStories,
loadStoriesArchive,
openPremiumModal,
loadChannelRecommendations,
@ -264,7 +266,7 @@ const Profile: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
foundIds,
threadId,
storyIds,
pinnedStoryIds,
archiveStoryIds,
similarChannels,
);
@ -484,11 +487,11 @@ const Profile: FC<OwnProps & StateProps> = ({
/>
))
) : (resultType === 'stories' || resultType === 'storiesArchive') ? (
(viewportIds as number[])!.map((id) => storyByIds?.[id] && (
(viewportIds as number[])!.map((id, i) => storyByIds?.[id] && (
<MediaStory
teactOrderKey={i}
key={`${resultType}_${id}`}
story={storyByIds[id]}
isProtected={isChatProtected}
isArchive={resultType === 'storiesArchive'}
/>
))
@ -718,7 +721,8 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
userStatusesById,
chatsById,
storyIds,
pinnedStoryIds,
archiveStoryIds,
storyByIds,
isChatProtected: chat?.isProtected,

View File

@ -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 [

View File

@ -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);

View File

@ -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 (
<div
ref={containerRef}
@ -120,10 +136,18 @@ function MediaStory({ story, isProtected, isArchive }: OwnProps) {
>
{isDeleted && (
<span>
<i className={buildClassName(styles.expiredIcon, 'icon icon-story-expired')} aria-hidden />
<Icon className={styles.expiredIcon} name="story-expired" />
{lang('ExpiredStory')}
</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}>
{thumbUrl && (
<img src={thumbUrl} alt="" className={styles.media} draggable={false} />
@ -145,16 +169,45 @@ function MediaStory({ story, isProtected, isArchive }: OwnProps) {
onCloseAnimationEnd={handleContextMenuHide}
withPortal
>
{isArchive && <MenuItem icon="pin" onClick={handlePinClick}>{lang('StoryList.SaveToProfile')}</MenuItem>}
{isArchive && (
<MenuItem icon="archive" onClick={handleUnarchiveClick}>
{lang('StoryList.SaveToProfile')}
</MenuItem>
)}
{!isArchive && (
<MenuItem icon="unpin" onClick={handleUnpinClick}>
<MenuItem icon="archive" onClick={handleArchiveClick}>
{lang('Story.Context.RemoveFromProfile')}
</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>
)}
</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,
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(() => {

View File

@ -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<ApiPrivacySettings | undefined>(visibility);
const [isPinned, setIsPinned] = useState(story?.isPinned);
const [isPinned, setIsPinned] = useState(story?.isInProfile);
const [activeKey, setActiveKey] = useState<Screens>(Screens.privacy);
const [editingBlockingCategory, setEditingBlockingCategory] = useState<PrivacyVisibility>('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();
});

View File

@ -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<void> =>
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<void>
setGlobal(global);
});
addActionHandler('toggleStoryPinned', async (global, actions, payload): Promise<void> => {
const { peerId, storyId, isPinned } = payload;
addActionHandler('toggleStoryInProfile', async (global, actions, payload): Promise<void> => {
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<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) {
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<vo
setGlobal(global);
});
addActionHandler('loadPeerPinnedStories', async (global, actions, payload): Promise<void> => {
addActionHandler('loadPeerProfileStories', async (global, actions, payload): Promise<void> => {
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<void> => {
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);
});

View File

@ -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);

View File

@ -29,7 +29,7 @@ export function addStories<T extends GlobalState>(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<T extends GlobalState>(
global: T,
peerId: string,
newStories: Record<number, ApiTypeStory>,
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<T extends GlobalState>(
...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<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>(
global: T,
peerId: string,
@ -258,11 +281,11 @@ export function removePeerStory<T extends GlobalState>(
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<T extends GlobalState>(
global = updateStoriesForPeer(global, peerId, {
byId: newById,
orderedIds: newOrderedIds,
pinnedIds: newPinnedIds,
profileIds: newProfileIds,
lastUpdatedAt,
lastReadId: newLastReadId,
});
@ -346,7 +369,7 @@ export function updatePeerStory<T extends GlobalState>(
storyUpdate: Partial<ApiStory>,
): T {
const peerStories = selectPeerStories(global, peerId) || {
byId: {}, orderedIds: [], pinnedIds: [], archiveIds: [],
byId: {}, orderedIds: [], profileIds: [], archiveIds: [],
};
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,
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<T extends GlobalState>(
...global.stories.byPeerId,
[peerId]: {
...peerStories,
pinnedIds: newPinnedIds,
profileIds: newProfileIds,
},
},
},

View File

@ -35,6 +35,16 @@ export function selectPeerStory<T extends GlobalState>(
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>(
global: T, peerId: string,
) {
@ -124,7 +134,7 @@ function getPeerStoryIdsForViewer<T extends GlobalState>(
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) {

View File

@ -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;

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.getPeerMaxIDs#535983c3 id:Vector<InputPeer> = Vector<int>;
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.getMyBoosts#be77b4a = premium.MyBoosts;
premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector<int> peer:InputPeer = premium.MyBoosts;

View File

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