Profile: Display gift and story collections (#6190)

Co-authored-by: Alexander Zinchuk <alx.zinchuk@gmail.com>
This commit is contained in:
Alexander Zinchuk 2025-09-09 21:10:31 +02:00
parent 4fa82cc83c
commit ca8260c309
34 changed files with 1158 additions and 40 deletions

View File

@ -9,6 +9,7 @@ import type {
ApiStarGiftAttribute,
ApiStarGiftAttributeCounter,
ApiStarGiftAttributeId,
ApiStarGiftCollection,
ApiTypeResaleStarGifts,
} from '../../types';
@ -287,3 +288,21 @@ GramJs.TypeStarGiftAttributeId[] {
}
});
}
export function buildApiStarGiftCollection(collection: GramJs.StarGiftCollection): ApiStarGiftCollection | undefined {
if (!collection) return undefined;
const { collectionId, title, icon, giftsCount, hash } = collection;
if (icon) {
addDocumentToLocalDb(icon);
}
return {
collectionId,
title,
icon: icon && buildStickerFromDocument(icon),
giftsCount,
hash: hash.toString(),
};
}

View File

@ -5,6 +5,7 @@ import type {
ApiMediaAreaCoordinates,
ApiStealthMode,
ApiStory,
ApiStoryAlbum,
ApiStoryForwardInfo,
ApiStoryView,
ApiStoryViews,
@ -14,8 +15,10 @@ import type {
} from '../../types';
import { buildCollectionByCallback, omitUndefined } from '../../../util/iteratees';
import { buildPrivacyRules } from './common';
import { buildGeoPoint, buildMessageMediaContent, buildMessageTextContent } from './messageContent';
import { addDocumentToLocalDb } from '../helpers/localDb';
import { addPhotoToLocalDb } from '../helpers/localDb';
import { buildApiPhoto, buildPrivacyRules } from './common';
import { buildApiDocument, buildGeoPoint, buildMessageMediaContent, buildMessageTextContent } from './messageContent';
import { buildApiMessage } from './messages';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
import { buildApiReaction, buildReactionCount } from './reactions';
@ -279,3 +282,23 @@ export function buildApiStoryForwardInfo(forwardHeader: GramJs.TypeStoryFwdHeade
isModified: modified,
};
}
export function buildApiStoryAlbum(album: GramJs.StoryAlbum): ApiStoryAlbum {
const {
albumId, title, iconPhoto, iconVideo,
} = album;
if (iconPhoto) {
addPhotoToLocalDb(iconPhoto);
}
if (iconVideo) {
addDocumentToLocalDb(iconVideo);
}
return {
albumId,
title,
iconPhoto: iconPhoto && iconPhoto instanceof GramJs.Photo ? buildApiPhoto(iconPhoto) : undefined,
iconVideo: iconVideo ? buildApiDocument(iconVideo) : undefined,
};
}

View File

@ -13,7 +13,7 @@ import type {
import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { buildApiResaleGifts, buildApiSavedStarGift, buildApiStarGift,
buildApiStarGiftAttribute, buildInputResaleGiftsAttributes } from '../apiBuilders/gifts';
buildApiStarGiftAttribute, buildApiStarGiftCollection, buildInputResaleGiftsAttributes } from '../apiBuilders/gifts';
import {
buildApiCurrencyAmount,
buildApiStarsGiftOptions,
@ -112,11 +112,13 @@ export async function fetchSavedStarGifts({
offset = DEFAULT_PRIMITIVES.STRING,
limit = DEFAULT_PRIMITIVES.INT,
filter,
collectionId,
}: {
peer: ApiPeer;
offset?: string;
limit?: number;
filter?: GiftProfileFilterOptions;
collectionId?: number;
}) {
type GetSavedStarGiftsParams = ConstructorParameters<typeof GramJs.payments.GetSavedStarGifts>[0];
@ -124,6 +126,7 @@ export async function fetchSavedStarGifts({
peer: buildInputPeer(peer.id, peer.accessHash),
offset,
limit,
collectionId,
...(filter && {
sortByValue: filter.sortType === 'byValue' || undefined,
excludeUnlimited: !filter.shouldIncludeUnlimited || undefined,
@ -476,3 +479,24 @@ export async function fetchStarGiftWithdrawalUrl({
return undefined;
}
export async function fetchStarGiftCollections({
peer,
hash,
}: {
peer: ApiPeer;
hash?: string;
}) {
const result = await invokeRequest(new GramJs.payments.GetStarGiftCollections({
peer: buildInputPeer(peer.id, peer.accessHash),
hash: hash ? bigInt(hash) : DEFAULT_PRIMITIVES.BIGINT,
}));
if (!result || result instanceof GramJs.payments.StarGiftCollectionsNotModified) {
return undefined;
}
return {
collections: result.collections.map(buildApiStarGiftCollection).filter(Boolean),
};
}

View File

@ -7,6 +7,7 @@ import type {
ApiPeerStories,
ApiReaction,
ApiStealthMode,
ApiStoryAlbum,
ApiTypeStory,
} from '../../types';
@ -19,6 +20,7 @@ import {
buildApiPeerStories,
buildApiStealthMode,
buildApiStory,
buildApiStoryAlbum,
buildApiStoryView,
buildApiStoryViews,
} from '../apiBuilders/stories';
@ -463,3 +465,63 @@ export function activateStealthMode({
shouldReturnTrue: true,
});
}
export async function fetchAlbums({
peer,
}: {
peer: ApiPeer;
}): Promise<ApiStoryAlbum[] | undefined> {
const result = await invokeRequest(new GramJs.stories.GetAlbums({
peer: buildInputPeer(peer.id, peer.accessHash),
hash: DEFAULT_PRIMITIVES.BIGINT,
}));
if (!result || result instanceof GramJs.stories.AlbumsNotModified) {
return undefined;
}
return result.albums.map(buildApiStoryAlbum);
}
export async function fetchAlbumStories({
peer,
albumId,
offset = 0,
limit = STORY_LIST_LIMIT,
}: {
peer: ApiPeer;
albumId: number;
offset?: number;
limit?: number;
}): Promise<{
stories: Record<number, ApiTypeStory>;
pinnedIds?: number[];
count: number;
} | undefined> {
const result = await invokeRequest(new GramJs.stories.GetAlbumStories({
peer: buildInputPeer(peer.id, peer.accessHash),
albumId,
offset,
limit,
}));
if (!result) {
return undefined;
}
const stories = buildCollectionByCallback(result.stories, (story) => (
[story.id, buildApiStory(peer.id, story)]
));
result.stories.forEach((story) => {
if (story && story instanceof GramJs.StoryItem) {
addStoryToLocalDb(story, peer.id);
}
});
return {
stories,
pinnedIds: result.pinnedToTop,
count: result.count,
};
}

View File

@ -282,6 +282,14 @@ export interface ApiDisallowedGiftsSettings {
shouldDisallowPremiumGifts?: true;
}
export interface ApiStarGiftCollection {
collectionId: number;
title: string;
icon?: ApiSticker;
giftsCount: number;
hash: string;
}
export interface ApiStarsRating {
level: number;
currentLevelStars: number;

View File

@ -1,6 +1,8 @@
import type {
ApiDocument,
ApiGeoPoint,
ApiMessage,
ApiPhoto,
ApiReaction,
ApiReactionCount,
ApiSticker,
@ -69,6 +71,10 @@ export type ApiPeerStories = {
isArchiveFullyLoaded?: boolean;
lastUpdatedAt?: number;
lastReadId?: number;
idsByAlbumId?: Record<number, {
ids: number[];
isFullyLoaded?: boolean;
}>; // Story IDs grouped by album ID with loading state
};
export type ApiMessageStoryData = {
@ -184,3 +190,10 @@ export type ApiMediaAreaUniqueGift = {
export type ApiMediaArea = ApiMediaAreaVenue | ApiMediaAreaGeoPoint | ApiMediaAreaSuggestedReaction
| ApiMediaAreaChannelPost | ApiMediaAreaUrl | ApiMediaAreaWeather | ApiMediaAreaUniqueGift;
export type ApiStoryAlbum = {
albumId: number;
title: string;
iconPhoto?: ApiPhoto;
iconVideo?: ApiDocument;
};

View File

@ -2235,6 +2235,8 @@
"PublicPostsPremiumFeatureSubtitle" = "Global search is a Premium feature.";
"PublicPostsSubscribeToPremium" = "Subscribe to Premium";
"NotificationPaidExtraSearch" = "{stars} spent on extra search.";
"PostsSearchTransaction" = "Posts Search";
"AllStoriesCategory" = "All stories";
"PostsSearchTransaction" = "Public Post Search";
"TitleRating" = "Rating";
"RatingReflectsActivity" = "This rating reflects {name}'s activity on Telegram. It is based on:";
@ -2258,3 +2260,4 @@
"ErrorFocusInaccessibleMessage" = "Unfortunately, you can't access this message. You aren't a member of the chat where it was posted.";
"ContextMenuHintMouse" = "To edit or reply, close this menu. Then right click on a message.";
"ContextMenuHintTouch" = "To edit or reply, close this menu. Then long tap on a message.";

View File

@ -0,0 +1,22 @@
.item {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.75rem;
font-weight: var(--font-weight-medium);
white-space: nowrap;
transition: opacity 0.15s;
&:hover {
opacity: 0.85;
}
}
.icon {
margin-right: 0.375rem;
}

View File

@ -0,0 +1,46 @@
import { memo } from '../../lib/teact/teact';
import type { ApiSticker } from '../../api/types';
import useLastCallback from '../../hooks/useLastCallback';
import AnimatedIconFromSticker from './AnimatedIconFromSticker';
import styles from './AnimatedTabItem.module.scss';
type OwnProps = {
id: string;
title: string;
sticker?: ApiSticker;
onClick?: (id: string) => void;
};
const AnimatedTabItem = ({
id,
title,
sticker,
onClick,
}: OwnProps) => {
const handleClick = useLastCallback(() => {
onClick?.(id);
});
return (
<div
className={styles.item}
onClick={handleClick}
>
{sticker && (
<AnimatedIconFromSticker
className={styles.icon}
sticker={sticker}
size={20}
forcePreview
/>
)}
{title}
</div>
);
};
export default memo(AnimatedTabItem);

View File

@ -0,0 +1,72 @@
.container,
.clipPathContainer {
display: flex;
flex-shrink: 0;
flex-wrap: nowrap;
gap: var(--tab-gap-size, 0.25rem);
align-items: flex-end;
justify-content: flex-start;
padding-inline: 0.5rem;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
}
.container {
user-select: none;
position: relative;
z-index: 1;
overflow-x: auto;
color: var(--color-text-secondary);
opacity: 0;
transition: background-color 150ms, opacity 150ms;
mask-image: linear-gradient(to right, transparent, black 0.5rem, black calc(100% - 0.5rem), transparent);
&::-webkit-scrollbar {
height: 0;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0);
// `box-shadow` prevents repaint on macOS when hovering out of scrollable container
box-shadow: 0 0 1px rgba(255, 255, 255, 0.01);
}
&.isVisible {
opacity: 1;
}
}
.clipPathContainer {
// Hardware acceleration for clip-path animations
will-change: clip-path;
// Use GPU compositing for better performance
isolation: isolate;
position: absolute;
z-index: 3;
right: 0;
left: 0;
// Optimize for animations
contain: layout style paint;
overflow: hidden;
width: fit-content;
color: var(--color-primary);
background-color: var(--color-interactive-element-hover);
transition: var(--slide-transition) clip-path;
&.noAnimation {
transition: none;
}
}

View File

@ -0,0 +1,114 @@
import { memo, useEffect, useRef, useState } from '../../lib/teact/teact';
import type { ApiSticker } from '../../api/types';
import type { AnimationLevel } from '../../types';
import buildClassName from '../../util/buildClassName';
import useHorizontalScroll from '../../hooks/useHorizontalScroll';
import useLastCallback from '../../hooks/useLastCallback';
import useResizeObserver from '../../hooks/useResizeObserver';
import AnimatedTabItem from './AnimatedTabItem';
import styles from './AnimatedTabList.module.scss';
export type TabItem = {
id: string;
title: string;
sticker?: ApiSticker;
};
type OwnProps = {
items: TabItem[];
selectedItemId?: string;
className?: string;
animationLevel?: AnimationLevel;
onItemSelect?: (itemId: string) => void;
};
const AnimatedTabList = ({
items,
selectedItemId,
animationLevel = 1,
onItemSelect,
className,
}: OwnProps) => {
const containerRef = useRef<HTMLDivElement>();
const clipPathContainerRef = useRef<HTMLDivElement>();
const selectedIndex = items.findIndex((item) => item.id === selectedItemId) || 0;
const [clipPath, setClipPath] = useState<string>('');
const shouldAnimate = animationLevel > 0;
useHorizontalScroll(containerRef, !items.length, true);
const updateClipPath = useLastCallback(() => {
const clipPathContainer = clipPathContainerRef.current;
const activeTab = selectedIndex >= 0 && clipPathContainer?.childNodes[selectedIndex] as HTMLElement | null;
if (clipPathContainer && activeTab && clipPathContainer.offsetWidth > 0) {
const { offsetLeft, offsetWidth } = activeTab;
const containerWidth = clipPathContainer.offsetWidth;
const left = (offsetLeft / containerWidth * 100).toFixed(1);
const right = ((containerWidth - (offsetLeft + offsetWidth)) / containerWidth * 100).toFixed(1);
const newClipPath = `inset(0 ${right}% 0 ${left}% round 1rem)`;
setClipPath(newClipPath);
}
});
useEffect(() => {
updateClipPath();
}, [selectedIndex, items]);
useResizeObserver(clipPathContainerRef, updateClipPath);
if (!items.length) return undefined;
return (
<div
ref={containerRef}
className={
buildClassName(
styles.container,
'no-scrollbar',
className,
clipPath && styles.isVisible,
)
}
>
{items.map((item) => (
<AnimatedTabItem
key={item.id}
id={item.id}
title={item.title}
sticker={item.sticker}
onClick={onItemSelect}
/>
))}
<div
ref={clipPathContainerRef}
className={buildClassName(
styles.clipPathContainer,
'clip-path-container',
!shouldAnimate && styles.noAnimation,
)}
style={clipPath ? `clip-path: ${clipPath}` : undefined}
aria-hidden
>
{items.map((item, i) => (
<AnimatedTabItem
key={item.id}
id={item.id}
title={item.title}
sticker={item.sticker}
onClick={onItemSelect}
/>
))}
</div>
</div>
);
};
export default memo(AnimatedTabList);

View File

@ -101,7 +101,20 @@
height: 100% !important;
}
.shared-media-transition {
overflow: hidden;
}
.content {
transition: transform 0.3s;
&.showContentPanel {
transform: translateY(3rem);
padding-bottom: 3.5rem !important;
}
&.noTransition {
transition: none;
}
&.empty-list {
display: flex;
align-items: flex-start;
@ -210,4 +223,22 @@
}
}
}
.contentCategoriesPanel {
position: absolute;
top: 0;
right: 0;
left: 0;
transition: transform 0.3s, opacity 0.3s;
&.hiddenPanel {
transform: translateY(-100%);
opacity: 0;
}
&.noTransition {
transition: none;
}
}
}

View File

@ -8,6 +8,8 @@ import type {
ApiChatMember,
ApiMessage,
ApiSavedStarGift,
ApiStarGiftCollection,
ApiStoryAlbum,
ApiTypeStory,
ApiUser,
ApiUserStatus,
@ -19,6 +21,8 @@ import { MAIN_THREAD_ID } from '../../api/types';
import { AudioOrigin, MediaViewerOrigin, NewChatMembersProgress } from '../../types';
import { MEMBERS_SLICE, PROFILE_SENSITIVE_AREA, SHARED_MEDIA_SLICE, SLIDE_TRANSITION_DURATION } from '../../config';
const CONTENT_PANEL_SHOW_DELAY = 300;
import {
getHasAdminRight,
getIsDownloading,
@ -53,6 +57,7 @@ import {
import { selectPremiumLimit } from '../../global/selectors/limits';
import { selectMessageDownloadableMedia } from '../../global/selectors/media';
import { selectSharedSettings } from '../../global/selectors/sharedState';
import { areDeepEqual } from '../../util/areDeepEqual';
import { IS_TOUCH_ENV } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName';
import { captureEvents, SwipeDirection } from '../../util/captureEvents';
@ -71,6 +76,7 @@ import { useIntersectionObserver } from '../../hooks/useIntersectionObserver';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import useSyncEffect from '../../hooks/useSyncEffect';
import useAsyncRendering from './hooks/useAsyncRendering';
import useProfileState from './hooks/useProfileState';
import useProfileViewportIds from './hooks/useProfileViewportIds';
@ -100,6 +106,8 @@ import Spinner from '../ui/Spinner';
import TabList from '../ui/TabList';
import Transition from '../ui/Transition';
import DeleteMemberModal from './DeleteMemberModal';
import StarGiftCollectionList from './gifts/StarGiftCollectionList';
import StoryAlbumList from './stories/StoryAlbumList';
import './Profile.scss';
@ -127,6 +135,8 @@ type StateProps = {
hasPreviewMediaTab?: boolean;
hasGiftsTab?: boolean;
gifts?: ApiSavedStarGift[];
storyAlbums?: ApiStoryAlbum[];
giftCollections?: ApiStarGiftCollection[];
areMembersHidden?: boolean;
canAddMembers?: boolean;
canDeleteMembers?: boolean;
@ -137,6 +147,9 @@ type StateProps = {
pinnedStoryIds?: number[];
archiveStoryIds?: number[];
storyByIds?: Record<number, ApiTypeStory>;
selectedStoryAlbumId?: number;
activeCollectionId?: number;
giftsFilter?: any;
chatsById: Record<string, ApiChat>;
usersById: Record<string, ApiUser>;
userStatusesById: Record<string, ApiUserStatus>;
@ -189,6 +202,9 @@ const Profile: FC<OwnProps & StateProps> = ({
pinnedStoryIds,
archiveStoryIds,
storyByIds,
selectedStoryAlbumId,
activeCollectionId,
giftsFilter,
mediaSearchType,
hasCommonChatsTab,
hasStoriesTab,
@ -196,6 +212,8 @@ const Profile: FC<OwnProps & StateProps> = ({
hasPreviewMediaTab,
hasGiftsTab,
gifts,
storyAlbums,
giftCollections,
botPreviewMedia,
areMembersHidden,
canAddMembers,
@ -241,6 +259,9 @@ const Profile: FC<OwnProps & StateProps> = ({
loadPreviewMedias,
loadPeerSavedGifts,
resetGiftProfileFilter,
loadStarGiftCollections,
loadStoryAlbums,
resetSelectedStoryAlbum,
} = getActions();
const containerRef = useRef<HTMLDivElement>();
@ -250,10 +271,13 @@ const Profile: FC<OwnProps & StateProps> = ({
const lang = useLang();
const [deletingUserId, setDeletingUserId] = useState<string | undefined>();
const [isViewTransitionEnabled, enableViewTransition, disableViewTransition] = useFlag();
const profileId = isSavedDialog ? String(threadId) : chatId;
const isSavedMessages = profileId === currentUserId && !isSavedDialog;
const [restoreContentHeightKey, setRestoreContentHeightKey] = useState(0);
const tabs = useMemo(() => {
const arr: TabProps[] = [];
if (isSavedMessages && !isSavedDialog) {
@ -352,6 +376,25 @@ const Profile: FC<OwnProps & StateProps> = ({
}
}, [chatId, isBot, similarBots, isSynced]);
useEffect(() => {
resetSelectedStoryAlbum();
}, [chatId]);
useSyncEffect(() => {
enableViewTransition();
}, [giftsFilter]);
useSyncEffect(() => {
disableViewTransition();
}, [gifts]);
useEffect(() => {
if (hasGiftsTab && isSynced) {
loadStarGiftCollections({ peerId: chatId });
loadStoryAlbums({ peerId: chatId });
}
}, [chatId, hasGiftsTab, isSynced]);
const [renderingGifts, setRenderingGifts] = useState(gifts);
const { startViewTransition, shouldApplyVtn } = useViewTransition();
@ -371,12 +414,17 @@ const Profile: FC<OwnProps & StateProps> = ({
const handleLoadGifts = useCallback(() => {
loadPeerSavedGifts({ peerId: chatId });
}, [chatId]);
const handleLoadMoreMembers = useCallback(() => {
loadMoreMembers({ chatId });
}, [chatId, loadMoreMembers]);
useEffectWithPrevDeps(([prevGifts]) => {
if (!gifts || !prevGifts) {
if (areDeepEqual(gifts, prevGifts)) {
return;
}
if (!gifts || !prevGifts || !isViewTransitionEnabled) {
setRenderingGifts(gifts);
return;
}
@ -385,14 +433,14 @@ const Profile: FC<OwnProps & StateProps> = ({
const newGiftIds = gifts.map((gift) => getSavedGiftKey(gift));
const hasOrderChanged = prevGiftIds.some((id, index) => id !== newGiftIds[index]);
if (hasOrderChanged) {
if (hasOrderChanged && animationLevel > 0) {
startViewTransition(() => {
setRenderingGifts(gifts);
});
} else {
setRenderingGifts(gifts);
}
}, [gifts, startViewTransition]);
}, [gifts, startViewTransition, animationLevel, isViewTransitionEnabled]);
const [resultType, viewportIds, getMore, noProfileInfo] = useProfileViewportIds({
loadMoreMembers: handleLoadMoreMembers,
@ -418,12 +466,40 @@ const Profile: FC<OwnProps & StateProps> = ({
similarChannels,
similarBots,
});
const isFirstTab = (isSavedMessages && resultType === 'dialogs')
|| (hasStoriesTab && resultType === 'stories')
|| resultType === 'members'
|| (!hasMembersTab && resultType === 'media');
const activeKey = tabs.findIndex(({ type }) => type === resultType);
const [isGiftCollectionsShowed, markGiftCollectionsShowed, unmarkGiftCollectionsShowed] = useFlag(false);
const [isStoryAlbumsShowed, markStoryAlbumsShowed, unmarkStoryAlbums] = useFlag(false);
const hasGiftsCollections = giftCollections && giftCollections.length > 0;
const hasStoryAlbums = storyAlbums && storyAlbums.length > 0;
const isGiftsResult = resultType === 'gifts';
const isStoriesResult = resultType === 'stories';
const shouldShowContentPanel = (isGiftsResult && hasGiftsCollections) || (isStoriesResult && hasStoryAlbums);
useEffect(() => {
if (hasGiftsCollections) {
setTimeout(() => {
markGiftCollectionsShowed();
}, CONTENT_PANEL_SHOW_DELAY);
} else {
unmarkGiftCollectionsShowed();
}
if (hasStoryAlbums) {
setTimeout(() => {
markStoryAlbumsShowed();
}, CONTENT_PANEL_SHOW_DELAY);
} else {
unmarkStoryAlbums();
}
}, [hasGiftsCollections, hasStoryAlbums, markGiftCollectionsShowed, markStoryAlbumsShowed]);
usePeerStoriesPolling(resultType === 'members' ? viewportIds as string[] : undefined);
const handleStopAutoScrollToTabs = useLastCallback(() => {
@ -583,16 +659,48 @@ const Profile: FC<OwnProps & StateProps> = ({
const noContent = (!viewportIds && !botPreviewMedia) || !canRenderContent || !messagesById;
const noSpinner = isFirstTab && !canRenderContent;
const isSpinner = noContent && !noSpinner;
return (
<Transition activeKey={isSpinner ? 0 : 1} name="fade" shouldCleanup>
<div>
{renderCategories()}
{renderSpinnerOrContent(noContent, noSpinner)}
</Transition>
</div>
);
}
function renderSpinnerOrContent(noContent: boolean, noSpinner: boolean) {
function renderCategories() {
if (resultType === 'gifts') {
return (
<div
className={buildClassName(
'contentCategoriesPanel',
!shouldShowContentPanel && 'hiddenPanel',
isGiftCollectionsShowed && 'noTransition',
)}
>
<StarGiftCollectionList peerId={chatId} />
</div>
);
}
if (resultType === 'stories') {
return (
<div
className={buildClassName(
'contentCategoriesPanel',
!shouldShowContentPanel && 'hiddenPanel',
isStoryAlbumsShowed && 'noTransition',
)}
>
<StoryAlbumList peerId={chatId} />
</div>
);
}
return undefined;
}
function renderSpinnerOrContentBase(noContent: boolean, noSpinner: boolean) {
if (noContent) {
const forceRenderHiddenMembers = Boolean(resultType === 'members' && areMembersHidden);
@ -656,9 +764,15 @@ const Profile: FC<OwnProps & StateProps> = ({
return;
}
const noTransition = resultType === 'gifts' ? isGiftCollectionsShowed
: resultType === 'stories' ? isStoryAlbumsShowed : false;
return (
<div
className={`content ${resultType}-list`}
className={buildClassName(
`content ${resultType}-list`,
shouldShowContentPanel && 'showContentPanel',
noTransition && 'noTransition',
)}
dir={oldLang.isRtl && resultType === 'media' ? 'rtl' : undefined}
teactFastList
>
@ -869,8 +983,69 @@ const Profile: FC<OwnProps & StateProps> = ({
);
}
const activeListSelector = `.shared-media-transition > .Transition_slide-active .${resultType}-list`;
const itemSelector = `${activeListSelector} > .scroll-item`;
const shouldUseTransitionForContent = resultType === 'stories' || resultType === 'gifts';
const contentTransitionKey = (() => {
if (resultType === 'stories') {
return selectedStoryAlbumId || 0;
}
if (resultType === 'gifts') {
return activeCollectionId || 0;
}
return 0;
})();
const handleOnStop = useLastCallback(() => {
setRestoreContentHeightKey(restoreContentHeightKey + 1);
});
function renderSpinnerOrContent(noContent: boolean, noSpinner: boolean) {
const baseContent = renderSpinnerOrContentBase(noContent, noSpinner);
const isSpinner = noContent && !noSpinner;
if (shouldUseTransitionForContent) {
return (
<Transition
className={`${resultType}-list`}
activeKey={contentTransitionKey}
name={resolveTransitionName('slideOptimized', animationLevel, undefined, oldLang.isRtl)}
shouldCleanup
shouldRestoreHeight
restoreHeightKey={restoreContentHeightKey}
contentSelector=".Transition > .Transition_slide-active > .content"
>
<Transition
activeKey={isSpinner ? 0 : 1}
name="fade"
shouldCleanup
shouldRestoreHeight
restoreHeightKey={restoreContentHeightKey}
contentSelector=".content"
onStop={handleOnStop}
>
{baseContent}
</Transition>
</Transition>
);
}
return (
<Transition
activeKey={isSpinner ? 0 : 1}
name="fade"
shouldCleanup
shouldRestoreHeight
>
{baseContent}
</Transition>
);
}
const activeListSelector = `.shared-media-transition > .Transition_slide-active`;
const itemSelector = !shouldUseTransitionForContent
? `${activeListSelector} .${resultType}-list > .scroll-item`
/* eslint-disable @stylistic/max-len */
: `${activeListSelector} > .Transition > .Transition_slide-active > .Transition > .Transition_slide-active > .gifts-list > .scroll-item`;
return (
<InfiniteScroll
@ -908,6 +1083,10 @@ const Profile: FC<OwnProps & StateProps> = ({
className="shared-media-transition"
onStart={applyTransitionFix}
onStop={handleTransitionStop}
restoreHeightKey={shouldUseTransitionForContent ? restoreContentHeightKey : undefined}
contentSelector={shouldUseTransitionForContent
? '.Transition > .Transition_slide-active > .Transition > .Transition_slide-active > .content'
: undefined}
>
{renderContent()}
</Transition>
@ -998,13 +1177,21 @@ 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?.profileIds;
const tabState = selectTabState(global);
const { selectedStoryAlbumId, nextProfileTab, forceScrollProfileTab, savedGifts } = tabState;
const storyIds = selectedStoryAlbumId
? peerStories?.idsByAlbumId?.[selectedStoryAlbumId]?.ids
: peerStories?.profileIds;
const pinnedStoryIds = peerStories?.pinnedIds;
const storyByIds = peerStories?.byId;
const archiveStoryIds = peerStories?.archiveIds;
const hasGiftsTab = Boolean(peerFullInfo?.starGiftCount) && !isSavedDialog;
const peerGifts = selectTabState(global).savedGifts.giftsByPeerId[chatId];
const activeCollectionId = savedGifts.activeCollectionByPeerId[chatId];
const peerGifts = savedGifts.collectionsByPeerId[chatId]?.[activeCollectionId || 'all'];
const storyAlbums = global.stories.albumsByPeerId?.[chatId];
const giftCollections = global.starGiftCollections?.byPeerId?.[chatId];
const monoforumChannel = selectMonoforumChannel(global, chatId);
const isRestricted = chat && selectIsChatRestricted(global, chat.id);
@ -1033,12 +1220,17 @@ export default memo(withGlobal<OwnProps>(
storyIds,
hasGiftsTab,
gifts: peerGifts?.gifts,
storyAlbums,
giftCollections,
pinnedStoryIds,
archiveStoryIds,
storyByIds,
selectedStoryAlbumId,
activeCollectionId,
giftsFilter: savedGifts.filter,
isChatProtected: chat?.isProtected,
nextProfileTab: selectTabState(global).nextProfileTab,
forceScrollProfileTab: selectTabState(global).forceScrollProfileTab,
nextProfileTab,
forceScrollProfileTab,
animationLevel,
shouldWarnAboutFiles,
similarChannels: similarChannelIds,

View File

@ -0,0 +1,3 @@
.tabList {
margin-top: 0.5rem;
}

View File

@ -0,0 +1,90 @@
import { memo, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiStarGiftCollection } from '../../../api/types';
import type { AnimationLevel } from '../../../types';
import type { TabItem } from '../../common/AnimatedTabList';
import { selectActiveCollectionId } from '../../../global/selectors';
import { selectSharedSettings } from '../../../global/selectors/sharedState';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import AnimatedTabList from '../../common/AnimatedTabList';
import styles from './StarGiftCollectionList.module.scss';
type OwnProps = {
peerId: string;
className?: string;
};
type StateProps = {
collections?: ApiStarGiftCollection[];
activeCollectionId?: number;
animationLevel?: AnimationLevel;
};
const StarGiftCollectionList = ({
peerId,
className,
collections,
activeCollectionId,
animationLevel,
}: StateProps & OwnProps) => {
const { updateSelectedGiftCollection, resetSelectedGiftCollection } = getActions();
const lang = useLang();
const handleItemSelect = useLastCallback((itemId?: string) => {
if (itemId === 'all') {
resetSelectedGiftCollection({ peerId });
} else {
const collectionId = Number(itemId);
updateSelectedGiftCollection({ peerId, collectionId });
}
});
if (!collections || collections.length === 0) {
return undefined;
}
const items: TabItem[] = useMemo(() => [
{
id: 'all',
title: lang('AllGiftsCategory'),
},
...collections.map((collection) => ({
id: String(collection.collectionId),
title: collection.title,
sticker: collection.icon,
})),
], [collections, lang]);
const selectedItemId = activeCollectionId ? String(activeCollectionId) : 'all';
return (
<AnimatedTabList
items={items}
selectedItemId={selectedItemId}
animationLevel={animationLevel}
onItemSelect={handleItemSelect}
className={buildClassName(styles.tabList, className)}
/>
);
};
export default memo(withGlobal<OwnProps>(
(global, { peerId }): StateProps => {
const { starGiftCollections } = global;
const collections = starGiftCollections?.byPeerId?.[peerId];
const activeCollectionId = selectActiveCollectionId(global, peerId);
return {
collections,
activeCollectionId,
animationLevel: selectSharedSettings(global).animationLevel,
};
},
)(StarGiftCollectionList));

View File

@ -0,0 +1,3 @@
.tabList {
margin-block: 0.5rem;
}

View File

@ -0,0 +1,90 @@
import { memo, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiStoryAlbum } from '../../../api/types';
import type { AnimationLevel } from '../../../types';
import type { TabItem } from '../../common/AnimatedTabList';
import { selectTabState } from '../../../global/selectors';
import { selectSharedSettings } from '../../../global/selectors/sharedState';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import AnimatedTabList from '../../common/AnimatedTabList';
import styles from './StoryAlbumList.module.scss';
type OwnProps = {
peerId: string;
className?: string;
};
type StateProps = {
albums?: ApiStoryAlbum[];
selectedAlbumId?: number;
animationLevel?: AnimationLevel;
};
const StoryAlbumList = ({
peerId,
className,
albums,
selectedAlbumId,
animationLevel,
}: StateProps & OwnProps) => {
const { selectStoryAlbum, resetSelectedStoryAlbum } = getActions();
const lang = useLang();
const handleItemSelect = useLastCallback((itemId: string) => {
if (itemId === 'all') {
resetSelectedStoryAlbum();
} else {
const albumId = Number(itemId);
selectStoryAlbum({ peerId, albumId });
}
});
if (!albums?.length) {
return undefined;
}
const items: TabItem[] = useMemo(() => [
{
id: 'all',
title: lang('AllStoriesCategory'),
},
...albums.map((album) => ({
id: String(album.albumId),
title: album.title,
})),
], [albums, lang]);
const selectedItemId = selectedAlbumId ? String(selectedAlbumId) : 'all';
return (
<AnimatedTabList
items={items}
selectedItemId={selectedItemId}
animationLevel={animationLevel}
onItemSelect={handleItemSelect}
className={buildClassName(styles.tabList, className)}
/>
);
};
export default memo(withGlobal<OwnProps>(
(global, { peerId }): StateProps => {
const { stories } = global;
const tabState = selectTabState(global);
const albums = stories?.albumsByPeerId?.[peerId];
const selectedAlbumId = tabState.selectedStoryAlbumId;
return {
albums,
selectedAlbumId,
animationLevel: selectSharedSettings(global).animationLevel,
};
},
)(StoryAlbumList));

View File

@ -29,8 +29,7 @@
&-slideOptimizedBackwards,
&-slideOptimizedRtl,
&-slideOptimizedRtlBackwards {
contain: strict;
transform: translate3d(0, 0, 0);
#root & > .Transition_slide {
position: absolute;
top: 0;

View File

@ -47,6 +47,8 @@ export type TransitionProps = {
onScroll?: NoneToVoidFunction;
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void;
children: React.ReactNode | ChildrenFn;
contentSelector?: string;
restoreHeightKey?: number;
};
const FALLBACK_ANIMATION_END = 1000;
@ -89,6 +91,8 @@ function Transition({
onScroll,
onMouseDown,
children,
contentSelector,
restoreHeightKey,
}: TransitionProps) {
const currentKeyRef = useRef<number>();
// No need for a container to update on change
@ -361,7 +365,10 @@ function Transition({
return;
}
const { clientHeight, clientWidth } = activeElement || {};
const contentElement = contentSelector
? activeElement.querySelector<HTMLDivElement>(contentSelector) : activeElement;
const { clientHeight, clientWidth } = contentElement || activeElement || {};
if (!clientHeight || !clientWidth) {
return;
}
@ -373,7 +380,7 @@ function Transition({
flexBasis: `${clientHeight}px`,
});
});
}, [shouldRestoreHeight, children]);
}, [shouldRestoreHeight, children, restoreHeightKey, contentSelector]);
const asFastList = !renderCount;
const renders = rendersRef.current;

View File

@ -18,12 +18,14 @@ import {
appendStarsTransactions,
replacePeerSavedGifts,
updateChats,
updatePeerStarGiftCollections,
updateStarsBalance,
updateStarsSubscriptionLoading,
updateUsers,
} from '../../reducers';
import { updateTabState } from '../../reducers/tabs';
import {
selectActiveCollectionId,
selectGiftProfileFilter,
selectPeer,
selectPeerSavedGifts,
@ -301,17 +303,20 @@ addActionHandler('loadPeerSavedGifts', async (global, actions, payload): Promise
if (!shouldRefresh && currentGifts && !localNextOffset) return; // Already loaded all
const fetchingFilter = selectGiftProfileFilter(global, peerId, tabId);
const fetchingCollectionId = selectActiveCollectionId(global, peerId, tabId);
const result = await callApi('fetchSavedStarGifts', {
peer,
offset: !shouldRefresh ? localNextOffset : '',
filter: fetchingFilter,
collectionId: fetchingCollectionId ? Number(fetchingCollectionId) : undefined,
});
global = getGlobal();
const currentFilter = selectGiftProfileFilter(global, peerId, tabId);
const currentCollectionId = selectActiveCollectionId(global, peerId, tabId);
if (!result || currentFilter !== fetchingFilter) {
if (!result || currentCollectionId !== fetchingCollectionId || currentFilter !== fetchingFilter) {
return;
}
@ -395,7 +400,8 @@ addActionHandler('changeGiftVisibility', async (global, actions, payload): Promi
const requestInputGift = getRequestInputSavedStarGift(global, gift);
if (!requestInputGift) return;
const oldGifts = selectTabState(global, tabId).savedGifts.giftsByPeerId[peerId];
const activeCollectionId = selectActiveCollectionId(global, peerId, tabId) || 'all';
const oldGifts = selectTabState(global, tabId).savedGifts.collectionsByPeerId[peerId]?.[activeCollectionId];
if (oldGifts?.gifts?.length) {
const newGifts = oldGifts.gifts.map((g) => {
if (g.inputGift && areInputSavedGiftsEqual(g.inputGift, gift)) {
@ -530,3 +536,25 @@ addActionHandler('updateStarGiftPrice', async (global, actions, payload): Promis
actions.reloadPeerSavedGifts({ peerId: global.currentUserId! });
});
addActionHandler('loadStarGiftCollections', async (global, actions, payload): Promise<void> => {
const {
peerId,
hash,
} = payload;
const peer = selectPeer(global, peerId);
if (!peer) return;
const result = await callApi('fetchStarGiftCollections', {
peer,
hash,
});
if (!result) return;
global = getGlobal();
global = updatePeerStarGiftCollections(global, peerId, result.collections);
setGlobal(global);
});

View File

@ -301,13 +301,43 @@ addActionHandler('loadPeerStories', async (global, actions, payload): Promise<vo
addActionHandler('loadPeerProfileStories', async (global, actions, payload): Promise<void> => {
if (selectIsCurrentUserFrozen(global)) return;
const { peerId, offsetId } = payload;
const { peerId, offsetId, tabId = getCurrentTabId() } = payload;
const peer = selectPeer(global, peerId);
let peerStories = selectPeerStories(global, peerId);
if (!peer || peerStories?.isFullyLoaded) {
return;
}
const tabState = selectTabState(global, tabId);
const selectedAlbumId = tabState.selectedStoryAlbumId;
if (selectedAlbumId) {
let albumData = peerStories?.idsByAlbumId?.[selectedAlbumId];
if (albumData?.isFullyLoaded) {
return;
}
const result = await callApi('fetchAlbumStories', {
peer,
albumId: selectedAlbumId,
offset: offsetId || 0,
});
if (!result) {
return;
}
global = getGlobal();
global = addStoriesForPeer(global, peerId, result.stories, result.pinnedIds, false, selectedAlbumId);
peerStories = selectPeerStories(global, peerId);
albumData = peerStories?.idsByAlbumId?.[selectedAlbumId];
if (Object.values(result.stories).length === 0
|| (albumData?.ids?.length && albumData.ids.length >= result.count)) {
global = updatePeerStoriesFullyLoaded(global, peerId, true, false, selectedAlbumId);
}
setGlobal(global);
return;
}
const result = await callApi('fetchPeerProfileStories', { peer, offsetId });
if (!result) {
return;
@ -619,3 +649,66 @@ addActionHandler('activateStealthMode', (global, actions, payload): ActionReturn
callApi('activateStealthMode', { isForPast: isForPast || true, isForFuture: isForFuture || true });
});
addActionHandler('loadStoryAlbums', async (global, actions, payload): Promise<void> => {
const { peerId } = payload;
const peer = selectPeer(global, peerId);
if (!peer) return;
const albums = await callApi('fetchAlbums', { peer });
if (!albums) return;
global = getGlobal();
global = {
...global,
stories: {
...global.stories,
albumsByPeerId: {
...global.stories.albumsByPeerId,
[peerId]: albums,
},
},
};
setGlobal(global);
});
addActionHandler('selectStoryAlbum', (global, actions, payload): ActionReturnType => {
const { peerId, albumId, tabId = getCurrentTabId() } = payload;
if (albumId && peerId) {
global = updatePeerStoriesFullyLoaded(global, peerId, false);
}
global = updateTabState(global, {
selectedStoryAlbumId: albumId || undefined,
}, tabId);
setGlobal(global);
actions.loadPeerProfileStories({ peerId, tabId });
});
addActionHandler('loadAlbumStories', async (global, actions, payload): Promise<void> => {
const { peerId, albumId, offsetId } = payload;
const peer = selectPeer(global, peerId);
if (!peer) return;
const result = await callApi('fetchAlbumStories', {
peer,
albumId,
offset: offsetId || 0,
});
if (!result) return;
global = getGlobal();
global = addStoriesForPeer(global, peerId, result.stories, result.pinnedIds);
setGlobal(global);
});
addActionHandler('resetSelectedStoryAlbum', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
selectedStoryAlbumId: undefined,
}, tabId);
});

View File

@ -1,6 +1,8 @@
import type { ApiSavedGifts } from '../../../api/types';
import type { ActionReturnType } from '../../types';
import { DEFAULT_GIFT_PROFILE_FILTER_OPTIONS } from '../../../config';
import { selectActiveCollectionId } from '../../../global/selectors';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { addActionHandler, setGlobal } from '../../index';
import {
@ -98,11 +100,15 @@ addActionHandler('updateGiftProfileFilter', (global, actions, payload): ActionRe
};
}
const activeCollectionId = selectActiveCollectionId(global, peerId, tabId) || 'all';
global = updateTabState(global, {
savedGifts: {
...tabState.savedGifts,
giftsByPeerId: {
[peerId]: tabState.savedGifts.giftsByPeerId[peerId],
collectionsByPeerId: {
[peerId]: {
[activeCollectionId]: tabState.savedGifts.collectionsByPeerId[peerId]?.[activeCollectionId],
} as Record<number | 'all', ApiSavedGifts>,
},
filter: updatedFilter,
},
@ -118,11 +124,15 @@ addActionHandler('resetGiftProfileFilter', (global, actions, payload): ActionRet
const { peerId, tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);
const activeCollectionId = selectActiveCollectionId(global, peerId, tabId) || 'all';
global = updateTabState(global, {
savedGifts: {
...tabState.savedGifts,
giftsByPeerId: {
[peerId]: tabState.savedGifts.giftsByPeerId[peerId],
collectionsByPeerId: {
[peerId]: {
[activeCollectionId]: tabState.savedGifts.collectionsByPeerId[peerId]?.[activeCollectionId],
} as Record<number | 'all', ApiSavedGifts>,
},
filter: {
...DEFAULT_GIFT_PROFILE_FILTER_OPTIONS,

View File

@ -6,7 +6,7 @@ import { getCurrentTabId } from '../../../util/establishMultitabRole';
import * as langProvider from '../../../util/oldLangProvider';
import { addTabStateResetterAction } from '../../helpers/meta';
import { getPrizeStarsTransactionFromGiveaway, getStarsTransactionFromGift } from '../../helpers/payments';
import { addActionHandler } from '../../index';
import { addActionHandler, setGlobal } from '../../index';
import {
clearStarPayment, openStarsTransactionModal,
} from '../../reducers';
@ -344,3 +344,43 @@ addActionHandler('openGiftTransferModal', (global, actions, payload): ActionRetu
});
addTabStateResetterAction('closeGiftTransferModal', 'giftTransferModal');
addActionHandler('updateSelectedGiftCollection', (global, actions, payload): ActionReturnType => {
const { peerId, collectionId, tabId = getCurrentTabId() } = payload;
const tabState = selectTabState(global, tabId);
global = updateTabState(global, {
savedGifts: {
...tabState.savedGifts,
activeCollectionByPeerId: {
...tabState.savedGifts.activeCollectionByPeerId,
[peerId]: collectionId,
},
},
}, tabId);
setGlobal(global);
actions.loadPeerSavedGifts({
peerId, shouldRefresh: true, tabId: tabState.id,
});
});
addActionHandler('resetSelectedGiftCollection', (global, actions, payload): ActionReturnType => {
const { peerId, tabId = getCurrentTabId() } = payload;
const tabState = selectTabState(global, tabId);
global = updateTabState(global, {
savedGifts: {
...tabState.savedGifts,
activeCollectionByPeerId: {
...tabState.savedGifts.activeCollectionByPeerId,
[peerId]: undefined,
},
},
}, tabId);
setGlobal(global);
actions.loadPeerSavedGifts({
peerId, shouldRefresh: true, tabId: tabState.id,
});
});

View File

@ -165,6 +165,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
stories: {
byPeerId: {},
albumsByPeerId: {},
orderedPeerIds: {
archived: [],
active: [],
@ -393,7 +394,8 @@ export const INITIAL_TAB_STATE: TabState = {
filter: {
...DEFAULT_GIFT_PROFILE_FILTER_OPTIONS,
},
giftsByPeerId: {},
collectionsByPeerId: {},
activeCollectionByPeerId: {},
},
resaleGifts: {

View File

@ -55,9 +55,10 @@ export function addStoriesForPeer<T extends GlobalState>(
newStories: Record<number, ApiTypeStory>,
newPinnedIds?: number[],
addToArchive?: boolean,
albumId?: number,
): T {
const {
byId, orderedIds, profileIds, archiveIds, pinnedIds,
byId, orderedIds, profileIds, archiveIds, pinnedIds, idsByAlbumId,
} = global.stories.byPeerId[peerId] || {};
const deletedIds = Object.keys(newStories).filter((id) => 'isDeleted' in newStories[Number(id)]).map(Number);
const updatedById = { ...byId, ...newStories };
@ -65,7 +66,7 @@ export function addStoriesForPeer<T extends GlobalState>(
let updatedArchiveIds = [...(archiveIds || [])];
const updatedProfileIds = unique(
[...(profileIds || [])].concat(Object.values(newStories).reduce((ids, story) => {
if ('isInProfile' in story && story.isInProfile) {
if (('isInProfile' in story && story.isInProfile)) {
ids.push(story.id);
}
@ -87,6 +88,20 @@ export function addStoriesForPeer<T extends GlobalState>(
.filter((storyId) => !deletedIds.includes(storyId));
}
const updatedIdsByAlbumId = { ...(idsByAlbumId || {}) };
if (albumId !== undefined) {
const newAlbumStoryIds = Object.keys(newStories).map(Number)
.filter((storyId) => !deletedIds.includes(storyId));
const existingAlbumData = updatedIdsByAlbumId[albumId];
const existingIds = existingAlbumData?.ids || [];
updatedIdsByAlbumId[albumId] = {
...updatedIdsByAlbumId[albumId],
ids: unique([...existingIds, ...newAlbumStoryIds]).sort((a, b) => b - a),
};
}
global = {
...global,
stories: {
@ -100,6 +115,7 @@ export function addStoriesForPeer<T extends GlobalState>(
profileIds: updatedProfileIds,
pinnedIds: pinnedIds || newPinnedIds,
...(addToArchive && { archiveIds: updatedArchiveIds }),
...(albumId !== undefined && { idsByAlbumId: updatedIdsByAlbumId }),
},
},
},
@ -137,7 +153,33 @@ export function updatePeerStoriesFullyLoaded<T extends GlobalState>(
peerId: string,
isFullyLoaded: boolean,
isArchive?: boolean,
albumId?: number,
): T {
const { byPeerId } = global.stories;
const peerStories = byPeerId[peerId];
if (albumId !== undefined && peerStories?.idsByAlbumId?.[albumId]) {
return {
...global,
stories: {
...global.stories,
byPeerId: {
...byPeerId,
[peerId]: {
...peerStories,
idsByAlbumId: {
...peerStories.idsByAlbumId,
[albumId]: {
...peerStories.idsByAlbumId[albumId],
isFullyLoaded,
},
},
},
},
},
};
}
return {
...global,
stories: {

View File

@ -1,6 +1,7 @@
import type {
ApiMissingInvitedUser,
ApiSavedStarGift,
ApiStarGiftCollection,
ApiUser,
ApiUserCommonChats,
ApiUserFullInfo,
@ -14,6 +15,7 @@ import { getCurrentTabId } from '../../util/establishMultitabRole';
import { omit, omitUndefined, unique } from '../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import { getSavedGiftKey } from '../helpers/stars';
import { selectActiveCollectionId } from '../selectors';
import { selectTabState } from '../selectors';
import { updateTabState } from './tabs';
@ -344,16 +346,38 @@ export function replacePeerSavedGifts<T extends GlobalState>(
keyCounts.set(id, count + 1);
});
const activeCollectionId = selectActiveCollectionId(global, peerId, tabId) || 'all';
return updateTabState(global, {
savedGifts: {
...tabState.savedGifts,
giftsByPeerId: {
...tabState.savedGifts.giftsByPeerId,
collectionsByPeerId: {
...tabState.savedGifts.collectionsByPeerId,
[peerId]: {
gifts,
nextOffset,
...tabState.savedGifts.collectionsByPeerId[peerId],
[activeCollectionId]: {
gifts,
nextOffset,
},
},
},
},
}, tabId);
}
export function updatePeerStarGiftCollections<T extends GlobalState>(
global: T,
peerId: string,
collections: ApiStarGiftCollection[],
): T {
return {
...global,
starGiftCollections: {
...global.starGiftCollections,
byPeerId: {
...global.starGiftCollections?.byPeerId,
[peerId]: collections,
},
},
};
}

View File

@ -96,3 +96,11 @@ export function selectIsGiftProfileFilterDefault<T extends GlobalState>(
) {
return arePropsShallowEqual(selectTabState(global, tabId).savedGifts.filter, DEFAULT_GIFT_PROFILE_FILTER_OPTIONS);
}
export function selectActiveCollectionId<T extends GlobalState>(
global: T,
peerId: string,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
return selectTabState(global, tabId).savedGifts.activeCollectionByPeerId?.[peerId];
}

View File

@ -1,4 +1,4 @@
import type { ApiPeer, ApiSavedGifts } from '../../api/types';
import type { ApiPeer, ApiSavedGifts, ApiStarGiftCollection } from '../../api/types';
import type { GlobalState, TabArgs } from '../types';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../../config';
@ -32,8 +32,17 @@ export function selectPeerSavedGifts<T extends GlobalState>(
global: T,
peerId: string,
...[tabId = getCurrentTabId()]: TabArgs<T>
): ApiSavedGifts {
return selectTabState(global, tabId).savedGifts.giftsByPeerId[peerId];
): ApiSavedGifts | undefined {
const tabState = selectTabState(global, tabId);
const activeCollectionId = tabState.savedGifts.activeCollectionByPeerId[peerId] || 'all';
return tabState.savedGifts.collectionsByPeerId[peerId]?.[activeCollectionId];
}
export function selectPeerStarGiftCollections<T extends GlobalState>(
global: T,
peerId: string,
): ApiStarGiftCollection[] | undefined {
return global.starGiftCollections?.byPeerId[peerId];
}
export function selectPeerPaidMessagesStars<T extends GlobalState>(

View File

@ -1691,6 +1691,19 @@ export interface ActionPayloads {
isForPast?: boolean;
isForFuture?: boolean;
} | undefined;
loadStoryAlbums: {
peerId: string;
};
selectStoryAlbum: {
peerId: string;
albumId?: number;
} & WithTabId;
loadAlbumStories: {
peerId: string;
albumId: number;
offsetId?: number;
};
resetSelectedStoryAlbum: WithTabId | undefined;
openBoostModal: {
chatId: string;
@ -2596,6 +2609,13 @@ export interface ActionPayloads {
recipientId: string;
} & WithTabId;
closeGiftTransferModal: WithTabId | undefined;
updateSelectedGiftCollection: {
peerId: string;
collectionId: number;
} & WithTabId;
resetSelectedGiftCollection: {
peerId: string;
} & WithTabId;
loadPeerSavedGifts: {
peerId: string;
@ -2621,6 +2641,11 @@ export interface ActionPayloads {
price: ApiTypeCurrencyAmount;
} & WithTabId;
loadStarGiftCollections: {
peerId: string;
hash?: string;
} & WithTabId;
openStarsGiftModal: ({
chatId?: string;
forUserId?: string;

View File

@ -30,12 +30,14 @@ import type {
ApiSavedReactionTag,
ApiSession,
ApiSponsoredMessage,
ApiStarGiftCollection,
ApiStarGiftRegular,
ApiStarsAmount,
ApiStarTopupOption,
ApiStealthMode,
ApiSticker,
ApiStickerSet,
ApiStoryAlbum,
ApiTimezone,
ApiTonAmount,
ApiTranscription,
@ -253,6 +255,7 @@ export type GlobalState = {
archived: string[];
};
stealthMode: ApiStealthMode;
albumsByPeerId: Record<string, ApiStoryAlbum[]>;
};
groupCalls: {
@ -307,6 +310,9 @@ export type GlobalState = {
byId: Record<string, ApiStarGiftRegular>;
idsByCategory: Record<StarGiftCategory, string[]>;
};
starGiftCollections?: {
byPeerId: Record<string, ApiStarGiftCollection[]>;
};
stickers: {
setsById: Record<string, ApiStickerSet>;

View File

@ -218,7 +218,8 @@ export type TabState = {
};
savedGifts: {
giftsByPeerId: Record<string, ApiSavedGifts>;
collectionsByPeerId: Record<string, Record<number | 'all', ApiSavedGifts>>;
activeCollectionByPeerId: Record<string, number | undefined>;
filter: GiftProfileFilterOptions;
};
@ -342,6 +343,8 @@ export type TabState = {
};
};
selectedStoryAlbumId?: number;
mediaViewer: {
chatId?: string;
threadId?: ThreadId;

View File

@ -1796,6 +1796,7 @@ payments.getStarGiftWithdrawalUrl#d06e93a8 stargift:InputSavedStarGift password:
payments.toggleStarGiftsPinnedToTop#1513e7b0 peer:InputPeer stargift:Vector<InputSavedStarGift> = Bool;
payments.getResaleStarGifts#7a5fa236 flags:# sort_by_price:flags.1?true sort_by_num:flags.2?true attributes_hash:flags.0?long gift_id:long attributes:flags.3?Vector<StarGiftAttributeId> offset:string limit:int = payments.ResaleStarGifts;
payments.updateStarGiftPrice#edbe6ccb stargift:InputSavedStarGift resell_amount:StarsAmount = Updates;
payments.getStarGiftCollections#981b91dd peer:InputPeer hash:long = payments.StarGiftCollections;
phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall;
@ -1855,6 +1856,8 @@ 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;
stories.getAlbums#25b3eac7 peer:InputPeer hash:long = stories.Albums;
stories.getAlbumStories#ac806d61 peer:InputPeer album_id:int offset:int limit:int = stories.Stories;
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

@ -330,6 +330,7 @@
"payments.toggleStarGiftsPinnedToTop",
"payments.getResaleStarGifts",
"payments.updateStarGiftPrice",
"payments.getStarGiftCollections",
"langpack.getLangPack",
"langpack.getStrings",
"langpack.getLanguages",
@ -404,6 +405,8 @@
"stories.getPeerStories",
"stories.getStoriesViews",
"stories.togglePinnedToTop",
"stories.getAlbums",
"stories.getAlbumStories",
"premium.getBoostsStatus",
"premium.getBoostersList",
"premium.applyBoost",

View File

@ -1666,6 +1666,7 @@ export interface LangPair {
'PublicPostsPremiumFeatureSubtitle': undefined;
'PublicPostsSubscribeToPremium': undefined;
'PostsSearchTransaction': undefined;
'AllStoriesCategory': undefined;
'TitleRating': undefined;
'RatingYourReflectsActivity': undefined;
'RatingGiftsFromTelegram': undefined;