From ca8260c30929c51aa9256ca54deabf859b23439f Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 9 Sep 2025 21:10:31 +0200 Subject: [PATCH] Profile: Display gift and story collections (#6190) Co-authored-by: Alexander Zinchuk --- src/api/gramjs/apiBuilders/gifts.ts | 19 ++ src/api/gramjs/apiBuilders/stories.ts | 27 ++- src/api/gramjs/methods/stars.ts | 26 ++- src/api/gramjs/methods/stories.ts | 62 +++++ src/api/types/stars.ts | 8 + src/api/types/stories.ts | 13 ++ src/assets/localization/fallback.strings | 3 + .../common/AnimatedTabItem.module.scss | 22 ++ src/components/common/AnimatedTabItem.tsx | 46 ++++ .../common/AnimatedTabList.module.scss | 72 ++++++ src/components/common/AnimatedTabList.tsx | 114 +++++++++ src/components/right/Profile.scss | 31 +++ src/components/right/Profile.tsx | 220 ++++++++++++++++-- .../gifts/StarGiftCollectionList.module.scss | 3 + .../right/gifts/StarGiftCollectionList.tsx | 90 +++++++ .../right/stories/StoryAlbumList.module.scss | 3 + .../right/stories/StoryAlbumList.tsx | 90 +++++++ src/components/ui/Transition.scss | 3 +- src/components/ui/Transition.tsx | 11 +- src/global/actions/api/stars.ts | 32 ++- src/global/actions/api/stories.ts | 95 +++++++- src/global/actions/ui/payments.ts | 18 +- src/global/actions/ui/stars.ts | 42 +++- src/global/initialState.ts | 4 +- src/global/reducers/stories.ts | 46 +++- src/global/reducers/users.ts | 32 ++- src/global/selectors/payments.ts | 8 + src/global/selectors/peers.ts | 15 +- src/global/types/actions.ts | 25 ++ src/global/types/globalState.ts | 6 + src/global/types/tabState.ts | 5 +- src/lib/gramjs/tl/apiTl.ts | 3 + src/lib/gramjs/tl/static/api.json | 3 + src/types/language.d.ts | 1 + 34 files changed, 1158 insertions(+), 40 deletions(-) create mode 100644 src/components/common/AnimatedTabItem.module.scss create mode 100644 src/components/common/AnimatedTabItem.tsx create mode 100644 src/components/common/AnimatedTabList.module.scss create mode 100644 src/components/common/AnimatedTabList.tsx create mode 100644 src/components/right/gifts/StarGiftCollectionList.module.scss create mode 100644 src/components/right/gifts/StarGiftCollectionList.tsx create mode 100644 src/components/right/stories/StoryAlbumList.module.scss create mode 100644 src/components/right/stories/StoryAlbumList.tsx diff --git a/src/api/gramjs/apiBuilders/gifts.ts b/src/api/gramjs/apiBuilders/gifts.ts index 0b6d4843d..8943ddc6f 100644 --- a/src/api/gramjs/apiBuilders/gifts.ts +++ b/src/api/gramjs/apiBuilders/gifts.ts @@ -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(), + }; +} diff --git a/src/api/gramjs/apiBuilders/stories.ts b/src/api/gramjs/apiBuilders/stories.ts index e41240815..d814a18e5 100644 --- a/src/api/gramjs/apiBuilders/stories.ts +++ b/src/api/gramjs/apiBuilders/stories.ts @@ -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, + }; +} diff --git a/src/api/gramjs/methods/stars.ts b/src/api/gramjs/methods/stars.ts index 5b590e917..ecacf446b 100644 --- a/src/api/gramjs/methods/stars.ts +++ b/src/api/gramjs/methods/stars.ts @@ -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[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), + }; +} diff --git a/src/api/gramjs/methods/stories.ts b/src/api/gramjs/methods/stories.ts index b75d649fe..dac6c44cb 100644 --- a/src/api/gramjs/methods/stories.ts +++ b/src/api/gramjs/methods/stories.ts @@ -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 { + 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; + 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, + }; +} diff --git a/src/api/types/stars.ts b/src/api/types/stars.ts index c7ceb13e1..2f8676445 100644 --- a/src/api/types/stars.ts +++ b/src/api/types/stars.ts @@ -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; diff --git a/src/api/types/stories.ts b/src/api/types/stories.ts index 435b845b0..9f3c4f99a 100644 --- a/src/api/types/stories.ts +++ b/src/api/types/stories.ts @@ -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; // 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; +}; diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index e065f5aeb..1a35b98d4 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -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."; + diff --git a/src/components/common/AnimatedTabItem.module.scss b/src/components/common/AnimatedTabItem.module.scss new file mode 100644 index 000000000..bd82152ba --- /dev/null +++ b/src/components/common/AnimatedTabItem.module.scss @@ -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; +} diff --git a/src/components/common/AnimatedTabItem.tsx b/src/components/common/AnimatedTabItem.tsx new file mode 100644 index 000000000..d64957a01 --- /dev/null +++ b/src/components/common/AnimatedTabItem.tsx @@ -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 ( +
+ {sticker && ( + + )} + {title} +
+ ); +}; + +export default memo(AnimatedTabItem); diff --git a/src/components/common/AnimatedTabList.module.scss b/src/components/common/AnimatedTabList.module.scss new file mode 100644 index 000000000..2488da54e --- /dev/null +++ b/src/components/common/AnimatedTabList.module.scss @@ -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; + } +} diff --git a/src/components/common/AnimatedTabList.tsx b/src/components/common/AnimatedTabList.tsx new file mode 100644 index 000000000..24b259435 --- /dev/null +++ b/src/components/common/AnimatedTabList.tsx @@ -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(); + const clipPathContainerRef = useRef(); + const selectedIndex = items.findIndex((item) => item.id === selectedItemId) || 0; + const [clipPath, setClipPath] = useState(''); + 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 ( +
+ {items.map((item) => ( + + ))} + +
+ {items.map((item, i) => ( + + ))} +
+
+ ); +}; + +export default memo(AnimatedTabList); diff --git a/src/components/right/Profile.scss b/src/components/right/Profile.scss index 52887d085..7a82f9090 100644 --- a/src/components/right/Profile.scss +++ b/src/components/right/Profile.scss @@ -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; + } + } } diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index d4af3da68..732f4e43e 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -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; + selectedStoryAlbumId?: number; + activeCollectionId?: number; + giftsFilter?: any; chatsById: Record; usersById: Record; userStatusesById: Record; @@ -189,6 +202,9 @@ const Profile: FC = ({ pinnedStoryIds, archiveStoryIds, storyByIds, + selectedStoryAlbumId, + activeCollectionId, + giftsFilter, mediaSearchType, hasCommonChatsTab, hasStoriesTab, @@ -196,6 +212,8 @@ const Profile: FC = ({ hasPreviewMediaTab, hasGiftsTab, gifts, + storyAlbums, + giftCollections, botPreviewMedia, areMembersHidden, canAddMembers, @@ -241,6 +259,9 @@ const Profile: FC = ({ loadPreviewMedias, loadPeerSavedGifts, resetGiftProfileFilter, + loadStarGiftCollections, + loadStoryAlbums, + resetSelectedStoryAlbum, } = getActions(); const containerRef = useRef(); @@ -250,10 +271,13 @@ const Profile: FC = ({ const lang = useLang(); const [deletingUserId, setDeletingUserId] = useState(); + 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 = ({ } }, [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 = ({ 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 = ({ 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 = ({ 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 = ({ const noContent = (!viewportIds && !botPreviewMedia) || !canRenderContent || !messagesById; const noSpinner = isFirstTab && !canRenderContent; - const isSpinner = noContent && !noSpinner; return ( - +
+ {renderCategories()} {renderSpinnerOrContent(noContent, noSpinner)} - +
); } - function renderSpinnerOrContent(noContent: boolean, noSpinner: boolean) { + function renderCategories() { + if (resultType === 'gifts') { + return ( +
+ +
+ ); + } + + if (resultType === 'stories') { + return ( +
+ +
+ ); + } + + return undefined; + } + + function renderSpinnerOrContentBase(noContent: boolean, noSpinner: boolean) { if (noContent) { const forceRenderHiddenMembers = Boolean(resultType === 'members' && areMembersHidden); @@ -656,9 +764,15 @@ const Profile: FC = ({ return; } + const noTransition = resultType === 'gifts' ? isGiftCollectionsShowed + : resultType === 'stories' ? isStoryAlbumsShowed : false; return (
@@ -869,8 +983,69 @@ const Profile: FC = ({ ); } - 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 ( + + + {baseContent} + + + ); + } + + return ( + + {baseContent} + + ); + } + + 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 ( = ({ 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()} @@ -998,13 +1177,21 @@ export default memo(withGlobal( const hasStoriesTab = peer && (user?.isSelf || (!peer.areStoriesHidden && peerFullInfo?.hasPinnedStories)) && !isSavedDialog; const peerStories = hasStoriesTab ? selectPeerStories(global, peer.id) : undefined; - const storyIds = peerStories?.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( 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, diff --git a/src/components/right/gifts/StarGiftCollectionList.module.scss b/src/components/right/gifts/StarGiftCollectionList.module.scss new file mode 100644 index 000000000..2fae3c316 --- /dev/null +++ b/src/components/right/gifts/StarGiftCollectionList.module.scss @@ -0,0 +1,3 @@ +.tabList { + margin-top: 0.5rem; +} diff --git a/src/components/right/gifts/StarGiftCollectionList.tsx b/src/components/right/gifts/StarGiftCollectionList.tsx new file mode 100644 index 000000000..670730b44 --- /dev/null +++ b/src/components/right/gifts/StarGiftCollectionList.tsx @@ -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 ( + + ); +}; + +export default memo(withGlobal( + (global, { peerId }): StateProps => { + const { starGiftCollections } = global; + const collections = starGiftCollections?.byPeerId?.[peerId]; + const activeCollectionId = selectActiveCollectionId(global, peerId); + + return { + collections, + activeCollectionId, + animationLevel: selectSharedSettings(global).animationLevel, + }; + }, +)(StarGiftCollectionList)); diff --git a/src/components/right/stories/StoryAlbumList.module.scss b/src/components/right/stories/StoryAlbumList.module.scss new file mode 100644 index 000000000..a67ebbc22 --- /dev/null +++ b/src/components/right/stories/StoryAlbumList.module.scss @@ -0,0 +1,3 @@ +.tabList { + margin-block: 0.5rem; +} diff --git a/src/components/right/stories/StoryAlbumList.tsx b/src/components/right/stories/StoryAlbumList.tsx new file mode 100644 index 000000000..0361d6e7b --- /dev/null +++ b/src/components/right/stories/StoryAlbumList.tsx @@ -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 ( + + ); +}; + +export default memo(withGlobal( + (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)); diff --git a/src/components/ui/Transition.scss b/src/components/ui/Transition.scss index c57266d71..0670afb5f 100644 --- a/src/components/ui/Transition.scss +++ b/src/components/ui/Transition.scss @@ -29,8 +29,7 @@ &-slideOptimizedBackwards, &-slideOptimizedRtl, &-slideOptimizedRtlBackwards { - contain: strict; - + transform: translate3d(0, 0, 0); #root & > .Transition_slide { position: absolute; top: 0; diff --git a/src/components/ui/Transition.tsx b/src/components/ui/Transition.tsx index fecadcf91..bb7fedc0f 100644 --- a/src/components/ui/Transition.tsx +++ b/src/components/ui/Transition.tsx @@ -47,6 +47,8 @@ export type TransitionProps = { onScroll?: NoneToVoidFunction; onMouseDown?: (e: React.MouseEvent) => 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(); // 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(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; diff --git a/src/global/actions/api/stars.ts b/src/global/actions/api/stars.ts index b94491e48..fb0c4c33f 100644 --- a/src/global/actions/api/stars.ts +++ b/src/global/actions/api/stars.ts @@ -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 => { + 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); +}); diff --git a/src/global/actions/api/stories.ts b/src/global/actions/api/stories.ts index 9fe1e3d76..812b06569 100644 --- a/src/global/actions/api/stories.ts +++ b/src/global/actions/api/stories.ts @@ -301,13 +301,43 @@ addActionHandler('loadPeerStories', async (global, actions, payload): Promise => { 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 => { + 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 => { + 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); +}); diff --git a/src/global/actions/ui/payments.ts b/src/global/actions/ui/payments.ts index 9bff3ab6a..181cf9247 100644 --- a/src/global/actions/ui/payments.ts +++ b/src/global/actions/ui/payments.ts @@ -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, }, 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, }, filter: { ...DEFAULT_GIFT_PROFILE_FILTER_OPTIONS, diff --git a/src/global/actions/ui/stars.ts b/src/global/actions/ui/stars.ts index 7cbed76e3..bff50f243 100644 --- a/src/global/actions/ui/stars.ts +++ b/src/global/actions/ui/stars.ts @@ -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, + }); +}); diff --git a/src/global/initialState.ts b/src/global/initialState.ts index bd68008dd..d1a6af7b8 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -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: { diff --git a/src/global/reducers/stories.ts b/src/global/reducers/stories.ts index 590d93aac..3741c84ca 100644 --- a/src/global/reducers/stories.ts +++ b/src/global/reducers/stories.ts @@ -55,9 +55,10 @@ export function addStoriesForPeer( newStories: Record, 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( 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( .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( profileIds: updatedProfileIds, pinnedIds: pinnedIds || newPinnedIds, ...(addToArchive && { archiveIds: updatedArchiveIds }), + ...(albumId !== undefined && { idsByAlbumId: updatedIdsByAlbumId }), }, }, }, @@ -137,7 +153,33 @@ export function updatePeerStoriesFullyLoaded( 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: { diff --git a/src/global/reducers/users.ts b/src/global/reducers/users.ts index b065c60e8..980fb6454 100644 --- a/src/global/reducers/users.ts +++ b/src/global/reducers/users.ts @@ -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( 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( + global: T, + peerId: string, + collections: ApiStarGiftCollection[], +): T { + return { + ...global, + starGiftCollections: { + ...global.starGiftCollections, + byPeerId: { + ...global.starGiftCollections?.byPeerId, + [peerId]: collections, + }, + }, + }; +} diff --git a/src/global/selectors/payments.ts b/src/global/selectors/payments.ts index edcd77cd3..0d7ce978b 100644 --- a/src/global/selectors/payments.ts +++ b/src/global/selectors/payments.ts @@ -96,3 +96,11 @@ export function selectIsGiftProfileFilterDefault( ) { return arePropsShallowEqual(selectTabState(global, tabId).savedGifts.filter, DEFAULT_GIFT_PROFILE_FILTER_OPTIONS); } + +export function selectActiveCollectionId( + global: T, + peerId: string, + ...[tabId = getCurrentTabId()]: TabArgs +) { + return selectTabState(global, tabId).savedGifts.activeCollectionByPeerId?.[peerId]; +} diff --git a/src/global/selectors/peers.ts b/src/global/selectors/peers.ts index de3430c79..4a3012ce6 100644 --- a/src/global/selectors/peers.ts +++ b/src/global/selectors/peers.ts @@ -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( global: T, peerId: string, ...[tabId = getCurrentTabId()]: TabArgs -): 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( + global: T, + peerId: string, +): ApiStarGiftCollection[] | undefined { + return global.starGiftCollections?.byPeerId[peerId]; } export function selectPeerPaidMessagesStars( diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 3ba75791c..25b19559d 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -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; diff --git a/src/global/types/globalState.ts b/src/global/types/globalState.ts index 96996028c..8f39ff2b5 100644 --- a/src/global/types/globalState.ts +++ b/src/global/types/globalState.ts @@ -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; }; groupCalls: { @@ -307,6 +310,9 @@ export type GlobalState = { byId: Record; idsByCategory: Record; }; + starGiftCollections?: { + byPeerId: Record; + }; stickers: { setsById: Record; diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index f10a1caab..f49db8e4e 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -218,7 +218,8 @@ export type TabState = { }; savedGifts: { - giftsByPeerId: Record; + collectionsByPeerId: Record>; + activeCollectionByPeerId: Record; filter: GiftProfileFilterOptions; }; @@ -342,6 +343,8 @@ export type TabState = { }; }; + selectedStoryAlbumId?: number; + mediaViewer: { chatId?: string; threadId?: ThreadId; diff --git a/src/lib/gramjs/tl/apiTl.ts b/src/lib/gramjs/tl/apiTl.ts index d1c4ac725..cd950c1ee 100644 --- a/src/lib/gramjs/tl/apiTl.ts +++ b/src/lib/gramjs/tl/apiTl.ts @@ -1796,6 +1796,7 @@ payments.getStarGiftWithdrawalUrl#d06e93a8 stargift:InputSavedStarGift password: payments.toggleStarGiftsPinnedToTop#1513e7b0 peer:InputPeer stargift:Vector = 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 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 = Vector; stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; stories.togglePinnedToTop#b297e9b peer:InputPeer id:Vector = 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 peer:InputPeer = premium.MyBoosts; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 8d1604614..16695a475 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -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", diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 201abb4ad..b4c48e164 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1666,6 +1666,7 @@ export interface LangPair { 'PublicPostsPremiumFeatureSubtitle': undefined; 'PublicPostsSubscribeToPremium': undefined; 'PostsSearchTransaction': undefined; + 'AllStoriesCategory': undefined; 'TitleRating': undefined; 'RatingYourReflectsActivity': undefined; 'RatingGiftsFromTelegram': undefined;