diff --git a/src/api/gramjs/methods/payments.ts b/src/api/gramjs/methods/payments.ts index b265eeb86..f65b5a7df 100644 --- a/src/api/gramjs/methods/payments.ts +++ b/src/api/gramjs/methods/payments.ts @@ -1,6 +1,9 @@ import BigInt from 'big-integer'; import { Api as GramJs } from '../../../lib/gramjs'; +import type { + GiftProfileFilterOptions, +} from '../../../types'; import type { ApiChat, ApiInputStorePaymentPurpose, @@ -451,16 +454,30 @@ export async function fetchSavedStarGifts({ peer, offset = '', limit, + filter, }: { peer: ApiPeer; offset?: string; limit?: number; + filter?: GiftProfileFilterOptions; }) { - const result = await invokeRequest(new GramJs.payments.GetSavedStarGifts({ + type GetSavedStarGiftsParams = ConstructorParameters[0]; + + const params : GetSavedStarGiftsParams = { peer: buildInputPeer(peer.id, peer.accessHash), offset, limit, - })); + ...(filter && { + sortByValue: filter.sortType === 'byValue' || undefined, + excludeUnlimited: !filter.shouldIncludeUnlimited || undefined, + excludeLimited: !filter.shouldIncludeLimited || undefined, + excludeUnique: !filter.shouldIncludeUnique || undefined, + excludeSaved: !filter.shouldIncludeDisplayed || undefined, + excludeUnsaved: !filter.shouldIncludeHidden || undefined, + } satisfies GetSavedStarGiftsParams), + }; + + const result = await invokeRequest(new GramJs.payments.GetSavedStarGifts(params)); if (!result) { return undefined; diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 403dbdfe7..ff57aecc7 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1599,6 +1599,15 @@ "ViewButtonGiftUnique" = "VIEW COLLECTIBLE"; "AuthContinueOnThisLanguage" = "Continue in English"; "Share" = "Share"; +"GiftSortByDate" = "Sort by Date"; +"GiftSortByValue" = "Sort by Value"; +"GiftFilterUnlimited" = "Unlimited"; +"GiftFilterLimited" = "Limited"; +"GiftFilterUnique" = "Unique"; +"GiftFilterDisplayed" = "Displayed"; +"GiftFilterHidden" = "Hidden"; +"GiftSearchEmpty" = "No matching gifts"; +"GiftSearchReset" = "View All Gifts"; "CheckPasswordTitle" = "Enter Password"; "CheckPasswordPlaceholder" = "Password"; "CheckPasswordDescription" = "Please enter your password to continue."; diff --git a/src/assets/tgs/SearchingDuck.tgs b/src/assets/tgs/SearchingDuck.tgs new file mode 100644 index 000000000..1cde51426 Binary files /dev/null and b/src/assets/tgs/SearchingDuck.tgs differ diff --git a/src/components/common/helpers/animatedAssets.ts b/src/components/common/helpers/animatedAssets.ts index 1642b2aa0..d368892db 100644 --- a/src/components/common/helpers/animatedAssets.ts +++ b/src/components/common/helpers/animatedAssets.ts @@ -20,6 +20,7 @@ import MonkeyPeek from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyPeek.tgs import MonkeyTracking from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyTracking.tgs'; import ReadTime from '../../../assets/tgs/ReadTime.tgs'; import Report from '../../../assets/tgs/Report.tgs'; +import SearchingDuck from '../../../assets/tgs/SearchingDuck.tgs'; import Congratulations from '../../../assets/tgs/settings/Congratulations.tgs'; import DiscussionGroups from '../../../assets/tgs/settings/DiscussionGroupsDucks.tgs'; import Experimental from '../../../assets/tgs/settings/Experimental.tgs'; @@ -64,4 +65,5 @@ export const LOCAL_TGS_URLS = { StarReactionEffect, StarReaction, Report, + SearchingDuck, }; diff --git a/src/components/right/Profile.scss b/src/components/right/Profile.scss index 50e79ba2b..6f00bf5fc 100644 --- a/src/components/right/Profile.scss +++ b/src/components/right/Profile.scss @@ -46,6 +46,36 @@ } } +.nothing-found-gifts { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding-top: 5rem; + height: 100%; + + .description { + color: var(--color-text-secondary); + font-size: 1rem; + font-weight: var(--font-weight-medium); + text-align: center; + margin-block: 1rem; + unicode-bidi: plaintext; + } + + .Link { + color: var(--color-links); + font-weight: var(--font-weight-medium); + transition: opacity 0.15s ease-in; + + &:active, + &:hover { + text-decoration: none; + opacity: 0.85; + } + } +} + .shared-media { display: flex; flex-direction: column-reverse; diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index 5669b4556..f2828f228 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -49,6 +49,7 @@ import { selectChatMessages, selectCurrentSharedMediaSearch, selectIsCurrentUserPremium, + selectIsGiftProfileFilterDefault, selectIsRightColumnShown, selectPeerStories, selectSimilarBotsIds, @@ -63,6 +64,7 @@ import { selectPremiumLimit } from '../../global/selectors/limits'; import buildClassName from '../../util/buildClassName'; import { captureEvents, SwipeDirection } from '../../util/captureEvents'; import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; +import { LOCAL_TGS_URLS } from '../common/helpers/animatedAssets'; import renderText from '../common/helpers/renderText'; import { getSenderName } from '../left/search/helpers/getSenderName'; @@ -79,6 +81,7 @@ import useProfileState from './hooks/useProfileState'; import useProfileViewportIds from './hooks/useProfileViewportIds'; import useTransitionFixes from './hooks/useTransitionFixes'; +import AnimatedIconWithPreview from '../common/AnimatedIconWithPreview'; import Audio from '../common/Audio'; import Document from '../common/Document'; import SavedGift from '../common/gift/SavedGift'; @@ -96,6 +99,7 @@ import MediaStory from '../story/MediaStory'; import Button from '../ui/Button'; import FloatingActionButton from '../ui/FloatingActionButton'; import InfiniteScroll from '../ui/InfiniteScroll'; +import Link from '../ui/Link'; import ListItem, { type MenuItemContextAction } from '../ui/ListItem'; import Spinner from '../ui/Spinner'; import TabList from '../ui/TabList'; @@ -155,6 +159,7 @@ type StateProps = { isSavedDialog?: boolean; forceScrollProfileTab?: boolean; isSynced?: boolean; + isNotDefaultGiftFilter?: boolean; }; type TabProps = { @@ -219,6 +224,7 @@ const Profile: FC = ({ forceScrollProfileTab, isSynced, onProfileStateChange, + isNotDefaultGiftFilter, }) => { const { setSharedMediaSearchType, @@ -237,6 +243,7 @@ const Profile: FC = ({ loadBotRecommendations, loadPreviewMedias, loadPeerSavedGifts, + resetGiftProfileFilter, } = getActions(); // eslint-disable-next-line no-null/no-null @@ -482,6 +489,10 @@ const Profile: FC = ({ setActiveTab(Math.min(newActiveTab, tabs.length - 1)); }, [hasMembersTab, activeTab, tabs]); + const handleResetGiftsFilter = useLastCallback(() => { + resetGiftProfileFilter(); + }); + useEffect(() => { if (!transitionRef.current || !IS_TOUCH_ENV) { return undefined; @@ -523,6 +534,28 @@ const Profile: FC = ({ }]; } + function renderNothingFoundGiftsWithFilter() { + return ( +
+ +
+ {lang('GiftSearchEmpty')} +
+ + {lang('GiftSearchReset')} + +
+ ); + } + function renderContent() { if (resultType === 'dialogs') { return ( @@ -535,7 +568,9 @@ const Profile: FC = ({ const forceRenderHiddenMembers = Boolean(resultType === 'members' && areMembersHidden); return ( -
+
{!noSpinner && !forceRenderHiddenMembers && } {forceRenderHiddenMembers && }
@@ -545,6 +580,10 @@ const Profile: FC = ({ if (viewportIds && !viewportIds?.length) { let text: string; + if (resultType === 'gifts' && isNotDefaultGiftFilter) { + return renderNothingFoundGiftsWithFilter(); + } + switch (resultType) { case 'members': text = areMembersHidden ? 'You have no access to group members list.' : 'No members found'; @@ -912,7 +951,9 @@ export default memo(withGlobal( const archiveStoryIds = peerStories?.archiveIds; const hasGiftsTab = Boolean(peerFullInfo?.starGiftCount) && !isSavedDialog; - const peerGifts = global.peers.giftsById[chatId]; + const peerGifts = selectTabState(global).savedGifts.giftsByPeerId[chatId]; + + const isNotDefaultGiftFilter = !selectIsGiftProfileFilterDefault(global); return { theme: selectTheme(global), @@ -952,6 +993,7 @@ export default memo(withGlobal( isTopicInfo, isSavedDialog, isSynced: global.isSynced, + isNotDefaultGiftFilter, limitSimilarPeers: selectPremiumLimit(global, 'recommendedChannels'), ...(hasMembersTab && members && { members, adminMembersById }), ...(hasCommonChatsTab && user && { commonChatIds: commonChats?.ids }), diff --git a/src/components/right/RightHeader.tsx b/src/components/right/RightHeader.tsx index 2e0b5460c..b35f72d35 100644 --- a/src/components/right/RightHeader.tsx +++ b/src/components/right/RightHeader.tsx @@ -1,10 +1,13 @@ import type { FC } from '../../lib/teact/teact'; -import React, { useEffect, useRef, useState } from '../../lib/teact/teact'; +import React, { + useEffect, useMemo, useRef, useState, +} from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import type { ApiExportedInvite } from '../../api/types'; +import type { GiftProfileFilterOptions, ThreadId } from '../../types'; import { MAIN_THREAD_ID } from '../../api/types'; -import { ManagementScreens, ProfileState, type ThreadId } from '../../types'; +import { ManagementScreens, ProfileState } from '../../types'; import { ANIMATION_END_DELAY, SAVED_FOLDER_ID } from '../../config'; import { @@ -12,6 +15,8 @@ import { } from '../../global/helpers'; import { selectCanManage, + selectCanUseGiftProfileAdminFilter, + selectCanUseGiftProfileFilter, selectChat, selectChatFullInfo, selectCurrentGifSearch, @@ -28,12 +33,16 @@ import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useElectronDrag from '../../hooks/useElectronDrag'; import useFlag from '../../hooks/useFlag'; import { useFolderManagerForChatsCount } from '../../hooks/useFolderManager'; +import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; import useOldLang from '../../hooks/useOldLang'; import Icon from '../common/icons/Icon'; import Button from '../ui/Button'; import ConfirmDialog from '../ui/ConfirmDialog'; +import DropdownMenu from '../ui/DropdownMenu'; +import MenuItem from '../ui/MenuItem'; +import MenuSeparator from '../ui/MenuSeparator'; import SearchInput from '../ui/SearchInput'; import Transition from '../ui/Transition'; @@ -76,6 +85,9 @@ type StateProps = { shouldSkipHistoryAnimations?: boolean; isBot?: boolean; canEditBot?: boolean; + giftProfileFilter: GiftProfileFilterOptions; + canUseGiftFilter?: boolean; + canUseGiftAdminFilter?:boolean; isInsideTopic?: boolean; canEditTopic?: boolean; isSavedMessages?: boolean; @@ -86,6 +98,7 @@ const COLUMN_ANIMATION_DURATION = 450 + ANIMATION_END_DELAY; enum HeaderContent { Profile, MemberList, + GiftList, SharedMedia, StoryList, Search, @@ -161,6 +174,9 @@ const RightHeader: FC = ({ onClose, onScreenSelect, canEditBot, + giftProfileFilter, + canUseGiftFilter, + canUseGiftAdminFilter, }) => { const { setStickerSearchQuery, @@ -171,11 +187,21 @@ const RightHeader: FC = ({ setEditingExportedInvite, deleteExportedChatInvite, openEditTopicPanel, + updateGiftProfileFilter, } = getActions(); const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag(); const { isMobile } = useAppLayout(); + const { + sortType: giftsSortType, + shouldIncludeUnlimited: shouldIncludeUnlimitedGifts, + shouldIncludeLimited: shouldIncludeLimitedGifts, + shouldIncludeUnique: shouldIncludeUniqueGifts, + shouldIncludeDisplayed: shouldIncludeDisplayedGifts, + shouldIncludeHidden: shouldIncludeHiddenGifts, + } = giftProfileFilter; + const foldersChatCount = useFolderManagerForChatsCount(); const handleEditInviteClick = useLastCallback(() => { @@ -226,7 +252,8 @@ const RightHeader: FC = ({ }, COLUMN_ANIMATION_DURATION); }, [isColumnOpen]); - const lang = useOldLang(); + const oldLang = useOldLang(); + const lang = useLang(); const contentKey = isProfile ? ( profileState === ProfileState.Profile ? ( HeaderContent.Profile @@ -234,6 +261,8 @@ const RightHeader: FC = ({ HeaderContent.SharedMedia ) : profileState === ProfileState.MemberList ? ( HeaderContent.MemberList + ) : profileState === ProfileState.GiftList ? ( + HeaderContent.GiftList ) : profileState === ProfileState.StoryList ? ( HeaderContent.StoryList ) : profileState === ProfileState.SavedDialogs ? ( @@ -309,24 +338,40 @@ const RightHeader: FC = ({ function getHeaderTitle() { if (isSavedMessages) { - return lang('SavedMessages'); + return oldLang('SavedMessages'); } if (isInsideTopic) { - return lang('AccDescrTopic'); + return oldLang('AccDescrTopic'); } if (isChannel) { - return lang('Channel.TitleInfo'); + return oldLang('Channel.TitleInfo'); } if (userId) { - return lang(isBot ? 'lng_info_bot_title' : 'lng_info_user_title'); + return oldLang(isBot ? 'lng_info_bot_title' : 'lng_info_user_title'); } - return lang('GroupInfo.Title'); + return oldLang('GroupInfo.Title'); } + const PrimaryLinkMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { + return ({ onTrigger, isOpen }) => ( + + ); + }, [isMobile, lang]); + function renderHeaderContent() { if (renderingContentKey === -1) { return undefined; @@ -334,48 +379,48 @@ const RightHeader: FC = ({ switch (renderingContentKey) { case HeaderContent.PollResults: - return

{lang('PollResults')}

; + return

{oldLang('PollResults')}

; case HeaderContent.AddingMembers: - return

{lang(isChannel ? 'ChannelAddSubscribers' : 'GroupAddMembers')}

; + return

{oldLang(isChannel ? 'ChannelAddSubscribers' : 'GroupAddMembers')}

; case HeaderContent.ManageInitial: - return

{lang('Edit')}

; + return

{oldLang('Edit')}

; case HeaderContent.ManageChatPrivacyType: - return

{lang(isChannel ? 'ChannelTypeHeader' : 'GroupTypeHeader')}

; + return

{oldLang(isChannel ? 'ChannelTypeHeader' : 'GroupTypeHeader')}

; case HeaderContent.ManageDiscussion: - return

{lang('Discussion')}

; + return

{oldLang('Discussion')}

; case HeaderContent.ManageChatAdministrators: - return

{lang('ChannelAdministrators')}

; + return

{oldLang('ChannelAdministrators')}

; case HeaderContent.ManageGroupRecentActions: - return

{lang('Group.Info.AdminLog')}

; + return

{oldLang('Group.Info.AdminLog')}

; case HeaderContent.ManageGroupAdminRights: - return

{lang('EditAdminRights')}

; + return

{oldLang('EditAdminRights')}

; case HeaderContent.ManageGroupNewAdminRights: - return

{lang('SetAsAdmin')}

; + return

{oldLang('SetAsAdmin')}

; case HeaderContent.ManageGroupPermissions: - return

{lang('ChannelPermissions')}

; + return

{oldLang('ChannelPermissions')}

; case HeaderContent.ManageGroupRemovedUsers: - return

{lang('BlockedUsers')}

; + return

{oldLang('BlockedUsers')}

; case HeaderContent.ManageChannelRemovedUsers: - return

{lang('ChannelBlockedUsers')}

; + return

{oldLang('ChannelBlockedUsers')}

; case HeaderContent.ManageGroupUserPermissionsCreate: - return

{lang('ChannelAddException')}

; + return

{oldLang('ChannelAddException')}

; case HeaderContent.ManageGroupUserPermissions: - return

{lang('UserRestrictions')}

; + return

{oldLang('UserRestrictions')}

; case HeaderContent.ManageInvites: - return

{lang('lng_group_invite_title')}

; + return

{oldLang('lng_group_invite_title')}

; case HeaderContent.ManageEditInvite: - return

{isEditingInvite ? lang('EditLink') : lang('NewLink')}

; + return

{isEditingInvite ? oldLang('EditLink') : oldLang('NewLink')}

; case HeaderContent.ManageInviteInfo: return ( <> -

{lang('InviteLink')}

+

{oldLang('InviteLink')}

{currentInviteInfo && !currentInviteInfo.isRevoked && ( @@ -599,6 +727,10 @@ export default withGlobal( const currentInviteInfo = chatId ? tabState.management.byChatId[chatId]?.inviteInfo?.invite : undefined; + const giftProfileFilter = tabState.savedGifts.filter; + const canUseGiftFilter = chatId ? selectCanUseGiftProfileFilter(global, chatId) : false; + const canUseGiftAdminFilter = chatId ? selectCanUseGiftProfileAdminFilter(global, chatId) : false; + return { canManage, canAddContact, @@ -616,6 +748,9 @@ export default withGlobal( isSavedMessages, shouldSkipHistoryAnimations: tabState.shouldSkipHistoryAnimations, canEditBot, + giftProfileFilter, + canUseGiftFilter, + canUseGiftAdminFilter, }; }, )(RightHeader); diff --git a/src/components/right/hooks/useProfileState.ts b/src/components/right/hooks/useProfileState.ts index 79612ccff..72ae54251 100644 --- a/src/components/right/hooks/useProfileState.ts +++ b/src/components/right/hooks/useProfileState.ts @@ -121,6 +121,8 @@ function getStateFromTabType(tabType: ProfileTabType) { switch (tabType) { case 'members': return ProfileState.MemberList; + case 'gifts': + return ProfileState.GiftList; case 'stories': return ProfileState.StoryList; case 'dialogs': diff --git a/src/components/ui/DropdownMenu.tsx b/src/components/ui/DropdownMenu.tsx index afcc3aab1..69adef39a 100644 --- a/src/components/ui/DropdownMenu.tsx +++ b/src/components/ui/DropdownMenu.tsx @@ -24,6 +24,7 @@ type OwnProps = { onTransitionEnd?: NoneToVoidFunction; onMouseEnterBackdrop?: (e: React.MouseEvent) => void; children: React.ReactNode; + autoClose?: boolean; }; const DropdownMenu: FC = ({ @@ -41,6 +42,7 @@ const DropdownMenu: FC = ({ onTransitionEnd, onMouseEnterBackdrop, onHide, + autoClose = true, }) => { // eslint-disable-next-line no-null/no-null const menuRef = useRef(null); @@ -110,7 +112,7 @@ const DropdownMenu: FC = ({ positionX={positionX} positionY={positionY} footer={footer} - autoClose + autoClose={autoClose} onClose={handleClose} onCloseAnimationEnd={onHide} onMouseEnterBackdrop={onMouseEnterBackdrop} diff --git a/src/config.ts b/src/config.ts index b7efaf536..d64b7576f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,9 @@ import type { ApiLimitType, ApiLimitTypeForPromo, ApiPremiumSection, ApiReactionEmoji, } from './api/types'; +import type { + GiftProfileFilterOptions, +} from './types'; export const APP_CODE_NAME = 'A'; export const APP_NAME = process.env.APP_NAME || `Telegram Web ${APP_CODE_NAME}`; @@ -427,3 +430,12 @@ export const PREMIUM_LIMITS_ORDER: ApiLimitTypeForPromo[] = [ 'dialogFiltersChats', 'recommendedChannels', ]; + +export const DEFAULT_GIFT_PROFILE_FILTER_OPTIONS : GiftProfileFilterOptions = { + sortType: 'byDate', + shouldIncludeUnlimited: true, + shouldIncludeLimited: true, + shouldIncludeUnique: true, + shouldIncludeDisplayed: true, + shouldIncludeHidden: true, +} as const; diff --git a/src/global/actions/api/stars.ts b/src/global/actions/api/stars.ts index 3d36c8723..3259c66ca 100644 --- a/src/global/actions/api/stars.ts +++ b/src/global/actions/api/stars.ts @@ -15,7 +15,10 @@ import { } from '../../reducers'; import { updateTabState } from '../../reducers/tabs'; import { + selectGiftProfileFilter, selectPeer, + selectPeerSavedGifts, + selectTabState, } from '../../selectors'; addActionHandler('loadStarStatus', async (global): Promise => { @@ -134,12 +137,14 @@ addActionHandler('loadStarGifts', async (global): Promise => { }); addActionHandler('loadPeerSavedGifts', async (global, actions, payload): Promise => { - const { peerId, shouldRefresh } = payload; + const { + peerId, shouldRefresh, tabId = getCurrentTabId(), + } = payload; const peer = selectPeer(global, peerId); if (!peer) return; - const currentGifts = global.peers.giftsById[peerId]; + const currentGifts = selectPeerSavedGifts(global, peerId, tabId); const localNextOffset = currentGifts?.nextOffset; if (!shouldRefresh && currentGifts && !localNextOffset) return; // Already loaded all @@ -147,6 +152,7 @@ addActionHandler('loadPeerSavedGifts', async (global, actions, payload): Promise const result = await callApi('fetchSavedStarGifts', { peer, offset: !shouldRefresh ? localNextOffset : '', + filter: selectGiftProfileFilter(global, peerId, tabId), }); if (!result) { @@ -157,7 +163,7 @@ addActionHandler('loadPeerSavedGifts', async (global, actions, payload): Promise const newGifts = currentGifts && !shouldRefresh ? currentGifts.gifts.concat(result.gifts) : result.gifts; - global = replacePeerSavedGifts(global, peerId, newGifts, result.nextOffset); + global = replacePeerSavedGifts(global, peerId, newGifts, result.nextOffset, tabId); setGlobal(global); }); @@ -216,14 +222,14 @@ addActionHandler('fulfillStarsSubscription', async (global, actions, payload): P }); addActionHandler('changeGiftVisibility', async (global, actions, payload): Promise => { - const { gift, shouldUnsave } = payload; + const { gift, shouldUnsave, tabId = getCurrentTabId() } = payload; const peerId = gift.type === 'user' ? global.currentUserId! : gift.chatId; const requestInputGift = getRequestInputSavedStarGift(global, gift); if (!requestInputGift) return; - const oldGifts = global.peers.giftsById[peerId]; + const oldGifts = selectTabState(global, tabId).savedGifts.giftsByPeerId[peerId]; if (oldGifts?.gifts?.length) { const newGifts = oldGifts.gifts.map((g) => { if (g.inputGift && areInputSavedGiftsEqual(g.inputGift, gift)) { @@ -234,7 +240,7 @@ addActionHandler('changeGiftVisibility', async (global, actions, payload): Promi } return g; }); - global = replacePeerSavedGifts(global, peerId, newGifts, oldGifts.nextOffset); + global = replacePeerSavedGifts(global, peerId, newGifts, oldGifts.nextOffset, tabId); setGlobal(global); } @@ -245,13 +251,17 @@ addActionHandler('changeGiftVisibility', async (global, actions, payload): Promi global = getGlobal(); if (!result) { - global = replacePeerSavedGifts(global, peerId, oldGifts.gifts, oldGifts.nextOffset); + global = replacePeerSavedGifts(global, peerId, oldGifts.gifts, oldGifts.nextOffset, tabId); setGlobal(global); return; } // Reload gift list to avoid issues with pagination - actions.loadPeerSavedGifts({ peerId, shouldRefresh: true }); + Object.values(global.byTabId).forEach((tabState) => { + if (selectPeerSavedGifts(global, peerId, tabId)) { + actions.loadPeerSavedGifts({ peerId, shouldRefresh: true, tabId: tabState.id }); + } + }); }); addActionHandler('convertGiftToStars', async (global, actions, payload): Promise => { @@ -268,7 +278,12 @@ addActionHandler('convertGiftToStars', async (global, actions, payload): Promise return; } - actions.loadPeerSavedGifts({ peerId: global.currentUserId!, shouldRefresh: true }); + const peerId = gift.type === 'user' ? global.currentUserId! : gift.chatId; + Object.values(global.byTabId).forEach((tabState) => { + if (selectPeerSavedGifts(global, peerId, tabId)) { + actions.loadPeerSavedGifts({ peerId, shouldRefresh: true, tabId: tabState.id }); + } + }); actions.openStarsBalanceModal({ tabId }); }); diff --git a/src/global/actions/ui/payments.ts b/src/global/actions/ui/payments.ts index 66c0778f2..1de0cd4a0 100644 --- a/src/global/actions/ui/payments.ts +++ b/src/global/actions/ui/payments.ts @@ -1,5 +1,6 @@ import type { ActionReturnType } from '../../types'; +import { DEFAULT_GIFT_PROFILE_FILTER_OPTIONS } from '../../../config'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { addActionHandler } from '../../index'; import { @@ -65,3 +66,55 @@ addActionHandler('closeGiftCodeModal', (global, actions, payload): ActionReturnT giftCodeModal: undefined, }, tabId); }); + +addActionHandler('updateGiftProfileFilter', (global, actions, payload): ActionReturnType => { + const { filter, tabId = getCurrentTabId() } = payload || {}; + const tabState = selectTabState(global, tabId); + + const prevFilter = tabState.savedGifts.filter; + let updatedFilter = { + ...prevFilter, + ...filter, + }; + + if (!updatedFilter.shouldIncludeUnlimited + && !updatedFilter.shouldIncludeLimited + && !updatedFilter.shouldIncludeUnique) { + updatedFilter = { + ...prevFilter, + shouldIncludeUnlimited: true, + shouldIncludeLimited: true, + shouldIncludeUnique: true, + ...filter, + }; + } + + if (!updatedFilter.shouldIncludeDisplayed && !updatedFilter.shouldIncludeHidden) { + updatedFilter = { + ...prevFilter, + shouldIncludeDisplayed: true, + shouldIncludeHidden: true, + ...filter, + }; + } + + return updateTabState(global, { + savedGifts: { + giftsByPeerId: {}, + filter: updatedFilter, + }, + }, tabId); +}); + +addActionHandler('resetGiftProfileFilter', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + + return updateTabState(global, { + savedGifts: { + giftsByPeerId: {}, + filter: { + ...DEFAULT_GIFT_PROFILE_FILTER_OPTIONS, + }, + }, + }, tabId); +}); diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 6f1219447..5d65c8361 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -5,6 +5,7 @@ import { NewChatMembersProgress } from '../types'; import { ANIMATION_LEVEL_DEFAULT, DARK_THEME_PATTERN_COLOR, + DEFAULT_GIFT_PROFILE_FILTER_OPTIONS, DEFAULT_MESSAGE_TEXT_SIZE_PX, DEFAULT_PATTERN_COLOR, DEFAULT_PLAYBACK_RATE, @@ -106,7 +107,6 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { }, peers: { - giftsById: {}, profilePhotosById: {}, }, @@ -365,6 +365,13 @@ export const INITIAL_TAB_STATE: TabState = { byChatId: {}, }, + savedGifts: { + filter: { + ...DEFAULT_GIFT_PROFILE_FILTER_OPTIONS, + }, + giftsByPeerId: {}, + }, + storyViewer: { isMuted: true, isRibbonShown: false, diff --git a/src/global/reducers/users.ts b/src/global/reducers/users.ts index 2a11f24c8..72b205d76 100644 --- a/src/global/reducers/users.ts +++ b/src/global/reducers/users.ts @@ -329,20 +329,20 @@ export function replacePeerSavedGifts( peerId: string, gifts: ApiSavedStarGift[], nextOffset?: string, + ...[tabId = getCurrentTabId()]: TabArgs ): T { - global = { - ...global, - peers: { - ...global.peers, - giftsById: { - ...global.peers.giftsById, + const tabState = selectTabState(global, tabId); + + return updateTabState(global, { + savedGifts: { + ...tabState.savedGifts, + giftsByPeerId: { + ...tabState.savedGifts.giftsByPeerId, [peerId]: { gifts, nextOffset, }, }, }, - }; - - return global; + }, tabId); } diff --git a/src/global/selectors/payments.ts b/src/global/selectors/payments.ts index 431f1af5c..eccfc72bd 100644 --- a/src/global/selectors/payments.ts +++ b/src/global/selectors/payments.ts @@ -1,6 +1,12 @@ import type { GlobalState, TabArgs } from '../types'; +import { DEFAULT_GIFT_PROFILE_FILTER_OPTIONS } from '../../config'; +import arePropsShallowEqual from '../../util/arePropsShallowEqual'; import { getCurrentTabId } from '../../util/establishMultitabRole'; +import { + getHasAdminRight, isChatAdmin, isChatChannel, +} from '../helpers'; +import { selectChat } from './chats'; import { selectTabState } from './tabs'; export function selectPaymentInputInvoice( @@ -58,3 +64,32 @@ export function selectSmartGlocalCredentials( ) { return selectTabState(global, tabId).payment.smartGlocalCredentials; } + +export function selectCanUseGiftProfileAdminFilter( + global: T, peerId: string, +) { + const chat = selectChat(global, peerId); + return chat && isChatChannel(chat) && isChatAdmin(chat) && getHasAdminRight(chat, 'postMessages'); +} + +export function selectCanUseGiftProfileFilter( + global: T, peerId: string, +) { + const chat = selectChat(global, peerId); + return chat && isChatChannel(chat); +} + +export function selectGiftProfileFilter( + global: T, + peerId: string, + ...[tabId = getCurrentTabId()]: TabArgs +) { + return selectCanUseGiftProfileFilter(global, peerId) ? selectTabState(global, tabId).savedGifts.filter : undefined; +} + +export function selectIsGiftProfileFilterDefault( + global: T, + ...[tabId = getCurrentTabId()]: TabArgs +) { + return arePropsShallowEqual(selectTabState(global, tabId).savedGifts.filter, DEFAULT_GIFT_PROFILE_FILTER_OPTIONS); +} diff --git a/src/global/selectors/peers.ts b/src/global/selectors/peers.ts index 8dff5571e..72fe631dd 100644 --- a/src/global/selectors/peers.ts +++ b/src/global/selectors/peers.ts @@ -1,8 +1,10 @@ -import type { ApiPeer } from '../../api/types'; -import type { GlobalState } from '../types'; +import type { ApiPeer, ApiSavedGifts } from '../../api/types'; +import type { GlobalState, TabArgs } from '../types'; import { SERVICE_NOTIFICATIONS_USER_ID } from '../../config'; +import { getCurrentTabId } from '../../util/establishMultitabRole'; import { selectChat, selectChatFullInfo } from './chats'; +import { selectTabState } from './tabs'; import { selectBot, selectIsPremiumPurchaseBlocked, selectUser } from './users'; export function selectPeer(global: T, peerId: string): ApiPeer | undefined { @@ -22,3 +24,11 @@ export function selectCanGift(global: T, peerId: string) return Boolean(!selectIsPremiumPurchaseBlocked(global) && !bot && peerId !== SERVICE_NOTIFICATIONS_USER_ID && areStarGiftsAvailable); } + +export function selectPeerSavedGifts( + global: T, + peerId: string, + ...[tabId = getCurrentTabId()]: TabArgs +) : ApiSavedGifts { + return selectTabState(global, tabId).savedGifts.giftsByPeerId[peerId]; +} diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 3a58fbf30..780708b2b 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -59,6 +59,7 @@ import type { CallSound, ChatListType, ConfettiParams, + GiftProfileFilterOptions, GlobalSearchContent, IAlbum, IAnchorPosition, @@ -2341,11 +2342,11 @@ export interface ActionPayloads { loadPeerSavedGifts: { peerId: string; shouldRefresh?: boolean; - }; + } & WithTabId; changeGiftVisibility: { gift: ApiInputSavedStarGift; shouldUnsave?: boolean; - }; + } & WithTabId; convertGiftToStars: { gift: ApiInputSavedStarGift; } & WithTabId; @@ -2369,6 +2370,11 @@ export interface ActionPayloads { } & WithTabId; closeSuggestedStatusModal: WithTabId | undefined; + updateGiftProfileFilter: { + filter: Partial; + } & WithTabId; + resetGiftProfileFilter: WithTabId | undefined; + // Invoice openInvoice: Exclude & WithTabId; diff --git a/src/global/types/globalState.ts b/src/global/types/globalState.ts index e772eadce..79d78d8a8 100644 --- a/src/global/types/globalState.ts +++ b/src/global/types/globalState.ts @@ -24,7 +24,6 @@ import type { ApiQuickReply, ApiReaction, ApiReactionKey, - ApiSavedGifts, ApiSavedReactionTag, ApiSession, ApiSponsoredMessage, @@ -176,7 +175,6 @@ export type GlobalState = { peers: { profilePhotosById: Record; - giftsById: Record; }; chats: { diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index 50830910a..6e18c6de7 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -32,6 +32,7 @@ import type { ApiPremiumSection, ApiReactionWithPaid, ApiReceiptRegular, + ApiSavedGifts, ApiSavedStarGift, ApiStarGift, ApiStarGiftAttribute, @@ -57,6 +58,7 @@ import type { ChatRequestedTranslations, ConfettiStyle, FocusDirection, + GiftProfileFilterOptions, GlobalSearchContent, IAlbum, IAnchorPosition, @@ -202,6 +204,11 @@ export type TabState = { byUsername: Record; }; + savedGifts: { + giftsByPeerId: Record; + filter: GiftProfileFilterOptions; + }; + globalSearch: { query?: string; minDate?: number; diff --git a/src/types/index.ts b/src/types/index.ts index 1028e8ef2..b9ecb696f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -417,6 +417,7 @@ export enum ProfileState { Profile, SharedMedia, MemberList, + GiftList, StoryList, SavedDialogs, } @@ -657,3 +658,12 @@ export type CallSound = ( export type BotAppPermissions = { geolocation?: boolean; }; + +export type GiftProfileFilterOptions = { + sortType: 'byDate' | 'byValue'; + shouldIncludeUnlimited: boolean; + shouldIncludeLimited: boolean; + shouldIncludeUnique: boolean; + shouldIncludeDisplayed: boolean; + shouldIncludeHidden: boolean; +}; diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 8f3f58d55..9ec85aa6e 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1306,6 +1306,15 @@ export interface LangPair { 'ViewButtonGiftUnique': undefined; 'AuthContinueOnThisLanguage': undefined; 'Share': undefined; + 'GiftSortByDate': undefined; + 'GiftSortByValue': undefined; + 'GiftFilterUnlimited': undefined; + 'GiftFilterLimited': undefined; + 'GiftFilterUnique': undefined; + 'GiftFilterDisplayed': undefined; + 'GiftFilterHidden': undefined; + 'GiftSearchEmpty': undefined; + 'GiftSearchReset': undefined; 'CheckPasswordTitle': undefined; 'CheckPasswordPlaceholder': undefined; 'CheckPasswordDescription': undefined;