From d0ba0c2530ee17b1e6fab6a9b930ad115ed75da7 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:42:54 +0100 Subject: [PATCH] Chat List: Show active auctions (#6641) --- src/api/gramjs/apiBuilders/gifts.ts | 4 +- src/api/gramjs/methods/stars.ts | 14 ++ src/api/types/stars.ts | 2 +- src/assets/localization/fallback.strings | 11 ++ src/bundles/stars.ts | 2 +- src/components/common/CustomEmoji.tsx | 21 ++- src/components/common/helpers/gifts.ts | 14 ++ .../left/main/panes/ChatListPanes.tsx | 28 +++- .../left/main/panes/ChatPane.module.scss | 30 ++++ .../main/panes/GiftAuctionPane.module.scss | 36 +++++ .../left/main/panes/GiftAuctionPane.tsx | 136 ++++++++++++++++++ .../main/panes/SuggestionPane.module.scss | 26 ---- .../left/main/panes/SuggestionPane.tsx | 8 +- src/components/main/Main.tsx | 2 + src/components/middle/hooks/useHeaderPane.tsx | 8 +- src/components/modals/ModalContainer.tsx | 3 + .../auction/ActiveGiftAuctionsModal.async.tsx | 14 ++ .../ActiveGiftAuctionsModal.module.scss | 24 ++++ .../gift/auction/ActiveGiftAuctionsModal.tsx | 123 ++++++++++++++++ .../gift/auction/GiftAuctionBidModal.tsx | 10 +- .../gift/transfer/GiftTransferModal.tsx | 1 - src/components/ui/Button.scss | 8 +- src/components/ui/Modal.tsx | 1 - src/components/ui/TextTimer.tsx | 13 +- src/global/actions/api/stars.ts | 17 +++ src/global/actions/apiUpdaters/payments.ts | 5 + src/global/actions/ui/stars.ts | 10 ++ src/global/reducers/gifts.ts | 48 ++++++- src/global/types/actions.ts | 3 + src/global/types/globalState.ts | 1 + src/global/types/tabState.ts | 2 + src/lib/gramjs/tl/apiTl.ts | 1 + src/lib/gramjs/tl/static/api.json | 1 + src/types/language.d.ts | 21 +++ src/util/localization/format.tsx | 12 +- 35 files changed, 580 insertions(+), 80 deletions(-) create mode 100644 src/components/left/main/panes/ChatPane.module.scss create mode 100644 src/components/left/main/panes/GiftAuctionPane.module.scss create mode 100644 src/components/left/main/panes/GiftAuctionPane.tsx create mode 100644 src/components/modals/gift/auction/ActiveGiftAuctionsModal.async.tsx create mode 100644 src/components/modals/gift/auction/ActiveGiftAuctionsModal.module.scss create mode 100644 src/components/modals/gift/auction/ActiveGiftAuctionsModal.tsx diff --git a/src/api/gramjs/apiBuilders/gifts.ts b/src/api/gramjs/apiBuilders/gifts.ts index 5cbb2997a..fca1f844c 100644 --- a/src/api/gramjs/apiBuilders/gifts.ts +++ b/src/api/gramjs/apiBuilders/gifts.ts @@ -442,7 +442,7 @@ export function buildApiStarGiftAuctionUserState( } export function buildApiStarGiftAuctionState( - result: GramJs.payments.StarGiftAuctionState, + result: GramJs.payments.StarGiftAuctionState | GramJs.StarGiftActiveAuctionState, ): ApiStarGiftAuctionState | undefined { const gift = buildApiStarGift(result.gift); if (gift.type !== 'starGift') return undefined; @@ -454,7 +454,7 @@ export function buildApiStarGiftAuctionState( gift, state, userState: buildApiStarGiftAuctionUserState(result.userState), - timeout: result.timeout, + timeout: 'timeout' in result ? result.timeout : undefined, }; } diff --git a/src/api/gramjs/methods/stars.ts b/src/api/gramjs/methods/stars.ts index f15cce018..236d9b12e 100644 --- a/src/api/gramjs/methods/stars.ts +++ b/src/api/gramjs/methods/stars.ts @@ -459,6 +459,20 @@ export async function fetchStarGiftAuctionAcquiredGifts({ }; } +export async function fetchStarGiftActiveAuctions() { + const result = await invokeRequest(new GramJs.payments.GetStarGiftActiveAuctions({ + hash: DEFAULT_PRIMITIVES.BIGINT, + })); + + if (!result || result instanceof GramJs.payments.StarGiftActiveAuctionsNotModified) { + return undefined; + } + + return { + auctions: result.auctions.map(buildApiStarGiftAuctionState).filter(Boolean), + }; +} + export function upgradeStarGift({ inputSavedGift, shouldKeepOriginalDetails, diff --git a/src/api/types/stars.ts b/src/api/types/stars.ts index bdb44d2cc..127e7ae12 100644 --- a/src/api/types/stars.ts +++ b/src/api/types/stars.ts @@ -387,7 +387,7 @@ export interface ApiStarGiftAuctionState { gift: ApiStarGiftRegular; state: ApiTypeStarGiftAuctionState; userState: ApiStarGiftAuctionUserState; - timeout: number; + timeout?: number; } export interface ApiStarGiftAuctionAcquiredGift { diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 59923ea5f..29745f142 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -2508,6 +2508,11 @@ "GiftAuctionWonNotification" = "You won {gift} at the auction!"; "GiftAuctionLearnMoreAboutGifts" = "Learn more about Telegram Gifts >"; "GiftAuctionLearnMoreMenuItem" = "Learn More"; +"GiftAuctionBidPosition" = "Your bid of {amount} is ranked #{position}"; +"GiftAuctionListRaiseBid" = "Raise"; +"GiftAuctionListRound" = "Round {current} of {total}"; +"GiftAuctionActiveTitle" = "Active Auctions"; +"GiftAuctionNoActive" = "You are not participating in any auctions"; "StarGiftInfoTitle" = "Telegram Gifts"; "StarGiftInfoSubtitle" = "Gifts are collectible items you can trade or showcase on your profile."; "StarGiftInfoUniqueTitle" = "Unique"; @@ -2603,3 +2608,9 @@ "SettingsDataClearMediaCache" = "Clear Media Cache"; "SettingsDataClearMediaCacheDescription" = "Deletes locally cached media for this account"; "SettingsDataClearMediaDone" = "Media cache cleared"; +"ChatListAuctionTitle_one" = "Active Auction"; +"ChatListAuctionTitle_other" = "{count} Active Auctions"; +"ChatListAuctionWinning" = "You are winning"; +"ChatListAuctionOutbid" = "You've been outbid"; +"ChatListAuctionMixed" = "You are winning in {winCount} and outbid in {outbidCount}"; +"ChatListAuctionView" = "View"; diff --git a/src/bundles/stars.ts b/src/bundles/stars.ts index 4591f4e2e..84771752e 100644 --- a/src/bundles/stars.ts +++ b/src/bundles/stars.ts @@ -1,5 +1,4 @@ /* eslint-disable @stylistic/max-len */ - export { default as StarsGiftModal } from '../components/modals/stars/gift/StarsGiftModal'; export { default as StarsGiftingPickerModal } from '../components/main/premium/StarsGiftingPickerModal'; export { default as StarsBalanceModal } from '../components/modals/stars/StarsBalanceModal'; @@ -29,3 +28,4 @@ export { default as GiftDescriptionRemoveModal } from '../components/modals/gift export { default as GiftOfferAcceptModal } from '../components/modals/gift/offer/GiftOfferAcceptModal'; export { default as ChatRefundModal } from '../components/modals/stars/chatRefund/ChatRefundModal'; export { default as PriceConfirmModal } from '../components/modals/priceConfirm/PriceConfirmModal'; +export { default as ActiveGiftAuctionsModal } from '../components/modals/gift/auction/ActiveGiftAuctionsModal'; diff --git a/src/components/common/CustomEmoji.tsx b/src/components/common/CustomEmoji.tsx index b62718593..f35cc1029 100644 --- a/src/components/common/CustomEmoji.tsx +++ b/src/components/common/CustomEmoji.tsx @@ -1,9 +1,9 @@ -import type { ElementRef, FC } from '../../lib/teact/teact'; +import type { ElementRef } from '../../lib/teact/teact'; import { memo, useRef, useState } from '../../lib/teact/teact'; import { getGlobal } from '../../global'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; -import { ApiMessageEntityTypes } from '../../api/types'; +import { ApiMessageEntityTypes, type ApiSticker } from '../../api/types'; import { selectIsAlwaysHighPriorityEmoji } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; @@ -21,7 +21,6 @@ import blankImg from '../../assets/blank.png'; type OwnProps = { ref?: ElementRef; - documentId: string; className?: string; style?: string; size?: number; @@ -42,13 +41,20 @@ type OwnProps = { observeIntersectionForPlaying?: ObserveFn; onClick?: NoneToVoidFunction; onAnimationEnd?: NoneToVoidFunction; -}; +} & ({ + documentId: string; + sticker?: undefined; +} | { + sticker: ApiSticker; + documentId?: undefined; +}); const STICKER_SIZE = 20; -const CustomEmoji: FC = ({ +const CustomEmoji = ({ ref, documentId, + sticker, className, style, size = STICKER_SIZE, @@ -69,14 +75,15 @@ const CustomEmoji: FC = ({ observeIntersectionForPlaying, onClick, onAnimationEnd, -}) => { +}: OwnProps) => { let containerRef = useRef(); if (ref) { containerRef = ref; } // An alternative to `withGlobal` to avoid adding numerous global containers - const { customEmoji, canPlay } = useCustomEmoji(documentId); + const { customEmoji: customEmojiFromDocumentId, canPlay } = useCustomEmoji(documentId); + const customEmoji = customEmojiFromDocumentId || sticker; const loopCountRef = useRef(0); const [shouldPlay, setShouldPlay] = useState(true); diff --git a/src/components/common/helpers/gifts.ts b/src/components/common/helpers/gifts.ts index 6f3d19b06..78a7f8bb8 100644 --- a/src/components/common/helpers/gifts.ts +++ b/src/components/common/helpers/gifts.ts @@ -1,4 +1,5 @@ import type { + ApiAuctionBidLevel, ApiStarGift, ApiStarGiftAttribute, ApiStarGiftAttributeBackdrop, @@ -122,6 +123,19 @@ export function preloadGiftAttributeStickers(attributes: ApiStarGiftAttribute[]) }); } +export function getBidAuctionPosition(bidAmount: number, bidDate: number, bidLevels: ApiAuctionBidLevel[]) { + if (!bidLevels.length) return 1; + + for (const level of bidLevels) { + if (level.amount < bidAmount + || (level.amount === bidAmount && level.date >= bidDate)) { + return level.pos; + } + } + + return bidLevels[bidLevels.length - 1].pos + 1; +} + export function getGiftRarityTitle( lang: LangFn, rarity: ApiStarGiftAttributeModel['rarity'], diff --git a/src/components/left/main/panes/ChatListPanes.tsx b/src/components/left/main/panes/ChatListPanes.tsx index ab3db0a63..da5c4d6d6 100644 --- a/src/components/left/main/panes/ChatListPanes.tsx +++ b/src/components/left/main/panes/ChatListPanes.tsx @@ -17,6 +17,7 @@ import { useSignalEffect } from '../../../../hooks/useSignalEffect'; import { applyAnimationState, type PaneState } from '../../../middle/hooks/useHeaderPane'; import FrozenAccountPane from './FrozenAccountPane'; +import GiftAuctionPane from './GiftAuctionPane'; import SuggestionPane from './SuggestionPane'; import UnconfirmedSessionPane from './UnconfirmedSessionPane'; @@ -34,6 +35,7 @@ type StateProps = { }; const TOP_MARGIN = 0.5 * REM; +const ITEM_MARGIN = 0.25 * REM; const BOTTOM_MARGIN = 0.25 * REM; const FALLBACK_PANE_STATE = { height: 0 }; @@ -46,6 +48,7 @@ const ChatListPanes = ({ }: OwnProps & StateProps) => { const [getUnconfirmedSessionHeight, setUnconfirmedSessionHeight] = useSignal(FALLBACK_PANE_STATE); const [getFrozenAccountState, setFrozenAccountState] = useSignal(FALLBACK_PANE_STATE); + const [getGiftAuctionState, setGiftAuctionState] = useSignal(FALLBACK_PANE_STATE); const [getSuggestionState, setSuggestionState] = useSignal(FALLBACK_PANE_STATE); const isFirstRenderRef = useRef(true); @@ -72,20 +75,30 @@ const ChatListPanes = ({ const canShowUnconfirmedSession = !isAccountFrozen && unconfirmedSession; const canShowSuggestions = !isAccountFrozen && !unconfirmedSession && promoData; + const canShowGiftAuctions = !isAccountFrozen; useSignalEffect(() => { const unconfirmedSessionHeight = getUnconfirmedSessionHeight(); const frozenAccountHeight = getFrozenAccountState(); + const giftAuctionHeight = getGiftAuctionState(); const suggestionHeight = getSuggestionState(); // Keep in sync with the order of the panes in the DOM - const stateArray = [unconfirmedSessionHeight, frozenAccountHeight, suggestionHeight]; + const stateArray = [ + { height: TOP_MARGIN, isSpacer: true }, + unconfirmedSessionHeight, + frozenAccountHeight, + giftAuctionHeight, + { height: giftAuctionHeight.height ? ITEM_MARGIN : 0, isSpacer: true }, + suggestionHeight, + { height: BOTTOM_MARGIN, isSpacer: true }, + ]; const isFirstRender = isFirstRenderRef.current; - const panelsHeight = stateArray.reduce((acc, state) => acc + state.height, 0); - const totalHeight = panelsHeight ? panelsHeight + BOTTOM_MARGIN : 0; + const totalHeight = stateArray.reduce((acc, state) => acc + state.height, 0); + const panelsHeight = totalHeight - TOP_MARGIN - BOTTOM_MARGIN; - onHeightChange(totalHeight); + onHeightChange(panelsHeight !== 0 ? totalHeight : 0); const leftColumn = document.getElementById('LeftColumn'); if (!leftColumn) return; @@ -93,7 +106,6 @@ const ChatListPanes = ({ applyAnimationState({ list: stateArray, noTransition: isFirstRender, - topMargin: TOP_MARGIN, zIndexIncrease: true, }); @@ -102,7 +114,7 @@ const ChatListPanes = ({ '--chat-list-panes-height': `${totalHeight}px`, }); }); - }, [getUnconfirmedSessionHeight, getFrozenAccountState, getSuggestionState]); + }, [getUnconfirmedSessionHeight, getFrozenAccountState, getSuggestionState, getGiftAuctionState]); if (!shouldRender) return undefined; @@ -124,6 +136,10 @@ const ChatListPanes = ({ unconfirmedSession={canShowUnconfirmedSession ? unconfirmedSession : undefined} onPaneStateChange={setUnconfirmedSessionHeight} /> + void; +}; + +type StateProps = Pick; + +const GiftAuctionPane = ({ + canShow, + activeGiftAuctionIds, + giftAuctionByGiftId, + onPaneStateChange, +}: OwnProps & StateProps) => { + const { openGiftAuctionBidModal, openActiveGiftAuctionsModal } = getActions(); + const isOpen = canShow && Boolean(activeGiftAuctionIds?.length); + const lang = useLang(); + + const { ref, shouldRender } = useHeaderPane({ + isOpen, + onStateChange: onPaneStateChange, + withResizeObserver: true, + }); + + const [activeAuctions, winningCount, outbidCount] = useMemo(() => { + const auctions = activeGiftAuctionIds?.map((id) => giftAuctionByGiftId?.[id]) + .filter((auc): auc is ApiStarGiftAuctionState => ( + auc?.state.type === 'active' && Boolean(auc.userState.bidAmount)), + ); + + if (!auctions) return [undefined, 0, 0]; + const [winning, outbid] = partition(auctions, (auction) => { + const state = auction.state as ApiStarGiftAuctionStateActive; + const position = getBidAuctionPosition( + auction.userState.bidAmount!, auction.userState.bidDate!, state.bidLevels, + ); + return position <= auction.gift.giftsPerRound!; + }); + return [auctions, winning.length, outbid.length]; + }, [activeGiftAuctionIds, giftAuctionByGiftId]); + const activeAuctionsCount = activeAuctions?.length || 0; + const singleActiveAuction = activeAuctions?.length === 1 ? activeAuctions[0] : undefined; + + function renderSubtitleText() { + if (!winningCount && !outbidCount) return undefined; + if (winningCount && !outbidCount) return lang('ChatListAuctionWinning'); + if (!winningCount && outbidCount) return lang('ChatListAuctionOutbid'); + return lang('ChatListAuctionMixed', { winCount: winningCount, outbidCount }); + } + + const handleClick = useLastCallback(() => { + if (!activeAuctions?.length) return; + if (singleActiveAuction) { + openGiftAuctionBidModal({ auctionGiftId: singleActiveAuction.gift.id }); + return; + } + + openActiveGiftAuctionsModal(); + }); + + if (!shouldRender) return undefined; + + return ( +
+
+ + {activeAuctions?.map((auction) => ( + + ))} + + {lang('ChatListAuctionTitle', { count: activeAuctionsCount }, { pluralValue: activeAuctionsCount })} +
+
+ {renderSubtitleText()} +
+ +
+ ); +}; + +export default memo(withGlobal( + (global): Complete => { + return { + activeGiftAuctionIds: global.activeGiftAuctionIds, + giftAuctionByGiftId: global.giftAuctionByGiftId, + }; + }, +)(GiftAuctionPane)); diff --git a/src/components/left/main/panes/SuggestionPane.module.scss b/src/components/left/main/panes/SuggestionPane.module.scss index 58b1c0734..6c8e7d934 100644 --- a/src/components/left/main/panes/SuggestionPane.module.scss +++ b/src/components/left/main/panes/SuggestionPane.module.scss @@ -1,43 +1,17 @@ -@use "../../../../styles/mixins"; - .root { - @include mixins.chat-list-pane; - - cursor: var(--custom-cursor, pointer); - display: grid; grid-template-columns: 1fr min-content; grid-template-rows: min-content 1fr; - - border-radius: var(--border-radius-default); - - line-height: 1.25; - - background-color: var(--color-background-secondary); - - &:hover { - opacity: 0.85; - } } .title { - --emoji-size: 1.125rem; - --custom-emoji-size: var(--emoji-size); - grid-column: 1 / 2; grid-row: 1 / 2; - font-size: 0.9375rem; - font-weight: var(--font-weight-semibold); } .subtitle { - --emoji-size: 1rem; - --custom-emoji-size: var(--emoji-size); - grid-column: 1 / 3; grid-row: 2 / 3; - font-size: 0.875rem; - color: var(--color-text-secondary); } .closeIcon { diff --git a/src/components/left/main/panes/SuggestionPane.tsx b/src/components/left/main/panes/SuggestionPane.tsx index 85682648e..9713f463a 100644 --- a/src/components/left/main/panes/SuggestionPane.tsx +++ b/src/components/left/main/panes/SuggestionPane.tsx @@ -4,6 +4,7 @@ import { getActions } from '../../../../global'; import type { ApiPromoData } from '../../../../api/types'; import type { RegularLangKey } from '../../../../types/language'; +import buildClassName from '../../../../util/buildClassName'; import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities'; import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev'; @@ -13,6 +14,7 @@ import useHeaderPane, { type PaneState } from '../../../middle/hooks/useHeaderPa import Icon from '../../../common/icons/Icon'; +import paneStyles from './ChatPane.module.scss'; import styles from './SuggestionPane.module.scss'; type OwnProps = { @@ -90,13 +92,13 @@ const SuggestionPane = ({ promoData, onPaneStateChange }: OwnProps) => { return (
-
{title}
-
{message}
+
{title}
+
{message}
); diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index a4a744ade..f397ec93e 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -266,6 +266,7 @@ const Main = ({ loadContentSettings, loadGiftAuction, loadPromoData, + loadActiveGiftAuctions, } = getActions(); if (DEBUG && !DEBUG_isLogged) { @@ -347,6 +348,7 @@ const Main = ({ loadRestrictedEmojiStickers(); loadQuickReplies(); loadTimezones(); + loadActiveGiftAuctions(); } }, [isMasterTab, isSynced, isAppConfigLoaded, isAccountFrozen]); diff --git a/src/components/middle/hooks/useHeaderPane.tsx b/src/components/middle/hooks/useHeaderPane.tsx index dea5b03e1..aca1b7861 100644 --- a/src/components/middle/hooks/useHeaderPane.tsx +++ b/src/components/middle/hooks/useHeaderPane.tsx @@ -21,6 +21,7 @@ export interface PaneState { element?: HTMLElement; height: number; isOpen?: boolean; + isSpacer?: boolean; } // Max slide transition duration @@ -130,18 +131,17 @@ export function applyAnimationState({ list, noTransition = false, zIndexIncrease, - topMargin = 0, }: { list: PaneState[]; noTransition?: boolean; zIndexIncrease?: boolean; - topMargin?: number; }) { let cumulativeHeight = 0; for (let i = 0; i < list.length; i++) { const state = list[i]; const element = state.element; if (!element) { + if (state.isSpacer) cumulativeHeight += state.height; continue; } @@ -149,7 +149,7 @@ export function applyAnimationState({ const apply = () => { setExtraStyles(element, { - transform: `translateY(${state.isOpen ? shiftPx : `calc(${shiftPx} - ${topMargin}px - 100%)`})`, + transform: `translateY(${state.isOpen ? shiftPx : `calc(${shiftPx} - 100%)`})`, zIndex: String(-i), transition: noTransition ? 'none' : '', }); @@ -158,7 +158,7 @@ export function applyAnimationState({ if (!element.dataset.isPanelOpen && state.isOpen && !noTransition) { // Start animation right above its final position setExtraStyles(element, { - transform: `translateY(calc(${shiftPx} - ${topMargin}px - 100%))`, + transform: `translateY(calc(${shiftPx} - 100%))`, zIndex: String(zIndexIncrease ? i : -i), transition: 'none', }); diff --git a/src/components/modals/ModalContainer.tsx b/src/components/modals/ModalContainer.tsx index 316ba849c..c3d68981b 100644 --- a/src/components/modals/ModalContainer.tsx +++ b/src/components/modals/ModalContainer.tsx @@ -20,6 +20,7 @@ import DeleteAccountModal from './deleteAccount/DeleteAccountModal.async'; import EmojiStatusAccessModal from './emojiStatusAccess/EmojiStatusAccessModal.async'; import FrozenAccountModal from './frozenAccount/FrozenAccountModal.async'; import AboutStarGiftModal from './gift/AboutStarGiftModal.async'; +import ActiveGiftAuctionsModal from './gift/auction/ActiveGiftAuctionsModal.async'; import GiftAuctionAcquiredModal from './gift/auction/GiftAuctionAcquiredModal.async'; import GiftAuctionBidModal from './gift/auction/GiftAuctionBidModal.async'; import GiftAuctionChangeRecipientModal from './gift/auction/GiftAuctionChangeRecipientModal.async'; @@ -107,6 +108,7 @@ type ModalKey = keyof Pick { + const { modal } = props; + const ActiveGiftAuctionsModal = useModuleLoader(Bundles.Stars, 'ActiveGiftAuctionsModal', !modal); + + return ActiveGiftAuctionsModal ? : undefined; +}; + +export default ActiveGiftAuctionsModalAsync; diff --git a/src/components/modals/gift/auction/ActiveGiftAuctionsModal.module.scss b/src/components/modals/gift/auction/ActiveGiftAuctionsModal.module.scss new file mode 100644 index 000000000..f45cba421 --- /dev/null +++ b/src/components/modals/gift/auction/ActiveGiftAuctionsModal.module.scss @@ -0,0 +1,24 @@ +.gift { + --custom-emoji-size: 2rem; + + margin-inline-end: 0.5rem; +} + +.timer { + margin-inline-start: 0.25rem; +} + +.noActive { + color: var(--color-text-secondary); + text-align: center; +} + +.content { + --border-radius-default: 0; + + padding: 0 !important; +} + +.dialog { + overflow: hidden !important; +} diff --git a/src/components/modals/gift/auction/ActiveGiftAuctionsModal.tsx b/src/components/modals/gift/auction/ActiveGiftAuctionsModal.tsx new file mode 100644 index 000000000..7ae183126 --- /dev/null +++ b/src/components/modals/gift/auction/ActiveGiftAuctionsModal.tsx @@ -0,0 +1,123 @@ +import { memo, useMemo } from '@teact'; +import { getActions, withGlobal } from '../../../../global'; + +import type { ApiStarGiftAuctionState, ApiStarGiftAuctionStateActive } from '../../../../api/types'; +import type { TabState } from '../../../../global/types'; + +import { formatStarsAsIcon } from '../../../../util/localization/format'; +import { getBidAuctionPosition } from '../../../common/helpers/gifts'; +import { REM } from '../../../common/helpers/mediaDimensions'; + +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; + +import CustomEmoji from '../../../common/CustomEmoji'; +import Button from '../../../ui/Button'; +import ListItem from '../../../ui/ListItem'; +import Modal from '../../../ui/Modal'; +import TextTimer from '../../../ui/TextTimer'; + +import styles from './ActiveGiftAuctionsModal.module.scss'; + +export type OwnProps = { + modal: TabState['activeGiftAuctionsModal']; +}; + +type StateProps = { + activeGiftAuctionIds?: string[]; + giftAuctionByGiftId?: Record; +}; + +const ICON_SIZE = 2 * REM; + +const ActiveGiftAuctionsModal = ({ + modal, activeGiftAuctionIds, giftAuctionByGiftId, +}: OwnProps & StateProps) => { + const { closeActiveGiftAuctionsModal } = getActions(); + const lang = useLang(); + + const activeAuctions = useMemo(() => { + return activeGiftAuctionIds?.map((id) => giftAuctionByGiftId?.[id]) + .filter((auc): auc is ApiStarGiftAuctionState => ( + auc?.state.type === 'active' && Boolean(auc.userState.bidAmount) + )); + }, [activeGiftAuctionIds, giftAuctionByGiftId]); + + return ( + + {activeAuctions?.length + ? activeAuctions.map((auction) => ) + :
{lang('GiftAuctionNoActive')}
} +
+ ); +}; + +function ActiveAuctionItem({ auction }: { auction: ApiStarGiftAuctionState }) { + const lang = useLang(); + const { openGiftAuctionBidModal, closeActiveGiftAuctionsModal } = getActions(); + + const { userState, gift } = auction; + const state = auction.state as ApiStarGiftAuctionStateActive; + + const bidPosition = useMemo(() => { + return getBidAuctionPosition(userState.bidAmount!, userState.bidDate!, state.bidLevels); + }, [userState, state.bidLevels]); + + const handleClick = useLastCallback(() => { + openGiftAuctionBidModal({ auctionGiftId: gift.id }); + closeActiveGiftAuctionsModal(); + }); + + return ( + } + rightElement={( + + )} + multiline + onClick={handleClick} + > +
+ {lang('GiftAuctionListRound', { + current: lang.number(state.currentRound), + total: lang.number(state.totalRounds), + })} +
+
+ {lang('GiftAuctionBidPosition', { + amount: formatStarsAsIcon(lang, userState.bidAmount!, { noStyles: true }), + position: lang.number(bidPosition), + }, { + withNodes: true, + })} +
+
+ ); +} + +export default memo(withGlobal( + (global): Complete => { + const { activeGiftAuctionIds, giftAuctionByGiftId } = global; + return { + activeGiftAuctionIds, + giftAuctionByGiftId, + }; + }, +)(ActiveGiftAuctionsModal)); diff --git a/src/components/modals/gift/auction/GiftAuctionBidModal.tsx b/src/components/modals/gift/auction/GiftAuctionBidModal.tsx index 4978b6575..f1f495de3 100644 --- a/src/components/modals/gift/auction/GiftAuctionBidModal.tsx +++ b/src/components/modals/gift/auction/GiftAuctionBidModal.tsx @@ -10,6 +10,7 @@ import type { TabState } from '../../../../global/types'; import { selectPeer, selectTabState } from '../../../../global/selectors'; import { formatStarsAsIcon } from '../../../../util/localization/format'; +import { getBidAuctionPosition } from '../../../common/helpers/gifts'; import renderText from '../../../common/helpers/renderText'; import { useTransitionActiveKey } from '../../../../hooks/animations/useTransitionActiveKey'; @@ -191,14 +192,7 @@ const GiftAuctionBidModal = ({ const { bidLevels } = activeState; const userBidDate = userState?.bidDate || Number.MAX_SAFE_INTEGER; - for (const level of bidLevels) { - if (level.amount < selectedBidAmount - || (level.amount === selectedBidAmount && level.date >= userBidDate)) { - return level.pos; - } - } - - return bidLevels[bidLevels.length - 1].pos + 1; + return getBidAuctionPosition(selectedBidAmount, userBidDate, bidLevels); }, [selectedBidAmount, activeState, userState?.bidDate]); function renderInfoCards() { diff --git a/src/components/modals/gift/transfer/GiftTransferModal.tsx b/src/components/modals/gift/transfer/GiftTransferModal.tsx index 3d3296d0e..e29449af2 100644 --- a/src/components/modals/gift/transfer/GiftTransferModal.tsx +++ b/src/components/modals/gift/transfer/GiftTransferModal.tsx @@ -112,7 +112,6 @@ const GiftTransferModal = ({ hasCloseButton shouldAdaptToSearch withFixedHeight - ignoreFreeze > itemIds={displayIds} diff --git a/src/components/ui/Button.scss b/src/components/ui/Button.scss index 6ce4391c8..930b94a6c 100644 --- a/src/components/ui/Button.scss +++ b/src/components/ui/Button.scss @@ -370,8 +370,8 @@ font-size: 0.875rem; &.round { - width: 1.875rem; - height: 1.875rem; + width: 1.75rem; + height: 1.75rem; border-radius: 50%; } @@ -380,8 +380,8 @@ } &.pill { - height: 1.875rem; - padding: 0.3125rem 1rem; + height: 1.75rem; + padding: 0.25rem 0.5rem; border-radius: 1rem; font-size: 1rem; } diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 227880a0f..b446dd288 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -45,7 +45,6 @@ export type OwnProps = { dialogRef?: ElementRef; isLowStackPriority?: boolean; dialogContent?: React.ReactNode; - ignoreFreeze?: boolean; moreMenuItems?: TeactNode; onClose: () => void; onCloseAnimationEnd?: () => void; diff --git a/src/components/ui/TextTimer.tsx b/src/components/ui/TextTimer.tsx index 2941d7080..7b5671823 100644 --- a/src/components/ui/TextTimer.tsx +++ b/src/components/ui/TextTimer.tsx @@ -9,6 +9,7 @@ import useForceUpdate from '../../hooks/useForceUpdate'; import AnimatedCounter from '../common/AnimatedCounter'; type OwnProps = { + className?: string; endsAt: number; shouldShowZeroOnEnd?: boolean; onEnd?: NoneToVoidFunction; @@ -16,7 +17,7 @@ type OwnProps = { const UPDATE_FREQUENCY = 500; // Sometimes second gets skipped if using 1000 -const TextTimer = ({ endsAt, shouldShowZeroOnEnd, onEnd }: OwnProps) => { +const TextTimer = ({ className, endsAt, shouldShowZeroOnEnd, onEnd }: OwnProps) => { const forceUpdate = useForceUpdate(); const serverTime = getServerTime(); @@ -35,8 +36,8 @@ const TextTimer = ({ endsAt, shouldShowZeroOnEnd, onEnd }: OwnProps) => { const time = formatMediaDuration(timeLeft); const timeParts = time.split(':'); - const timeCounter = ( - + return ( + {timeParts.map((part, index) => ( <> {index > 0 && ':'} @@ -45,12 +46,6 @@ const TextTimer = ({ endsAt, shouldShowZeroOnEnd, onEnd }: OwnProps) => { ))} ); - - return ( - - {timeCounter} - - ); }; export default TextTimer; diff --git a/src/global/actions/api/stars.ts b/src/global/actions/api/stars.ts index 66946d473..c6bc90e9a 100644 --- a/src/global/actions/api/stars.ts +++ b/src/global/actions/api/stars.ts @@ -612,6 +612,7 @@ addActionHandler('loadGiftAuction', async (global, _actions, payload): Promise => { + const result = await callApi('fetchStarGiftActiveAuctions'); + + if (!result) return; + + global = getGlobal(); + result.auctions.forEach((auction) => { + global = replaceGiftAuction(global, auction); + }); + global = { + ...global, + activeGiftAuctionIds: result.auctions.map((auction) => auction.gift.id), + }; + setGlobal(global); +}); diff --git a/src/global/actions/apiUpdaters/payments.ts b/src/global/actions/apiUpdaters/payments.ts index ad9f5423e..b3cb7eecd 100644 --- a/src/global/actions/apiUpdaters/payments.ts +++ b/src/global/actions/apiUpdaters/payments.ts @@ -260,6 +260,11 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { case 'updateStarGiftAuctionUserState': { const { giftId, userState } = update; + if (!global.giftAuctionByGiftId?.[giftId]) { + actions.loadActiveGiftAuctions(); + return; + } + global = updateGiftAuctionUserState(global, giftId, userState); setGlobal(global); diff --git a/src/global/actions/ui/stars.ts b/src/global/actions/ui/stars.ts index 88611a700..5091095dd 100644 --- a/src/global/actions/ui/stars.ts +++ b/src/global/actions/ui/stars.ts @@ -676,3 +676,13 @@ addActionHandler('resetSelectedGiftCollection', (global, actions, payload): Acti peerId, shouldRefresh: true, tabId: tabState.id, }); }); + +addActionHandler('openActiveGiftAuctionsModal', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + + return updateTabState(global, { + activeGiftAuctionsModal: true, + }, tabId); +}); + +addTabStateResetterAction('closeActiveGiftAuctionsModal', 'activeGiftAuctionsModal'); diff --git a/src/global/reducers/gifts.ts b/src/global/reducers/gifts.ts index edc15ac8d..cd80f30a3 100644 --- a/src/global/reducers/gifts.ts +++ b/src/global/reducers/gifts.ts @@ -7,7 +7,7 @@ import type { import type { GlobalState } from '../types'; import { getCurrentTabId } from '../../util/establishMultitabRole'; -import { omit } from '../../util/iteratees'; +import { omit, unique } from '../../util/iteratees'; import { updateTabState } from './tabs'; export function removeGiftInfoOriginalDetails( @@ -74,7 +74,11 @@ export function updateGiftAuction( ...global, giftAuctionByGiftId: { ...global.giftAuctionByGiftId, - [giftId]: auctionState, + [giftId]: { + ...auctionState, + // Keep timeout in case of updates from active auction list, as those do not have this field + timeout: auctionState.timeout || currentAuction.timeout, + }, }, }; } @@ -123,6 +127,21 @@ export function updateGiftAuctionUserState( return global; } + const currentBidDate = giftAuction.userState.bidDate; + if (userState.bidDate !== currentBidDate) { + if (userState.bidDate) { + global = { + ...global, + activeGiftAuctionIds: unique([...(global.activeGiftAuctionIds || []), giftId]), + }; + } else { + global = { + ...global, + activeGiftAuctionIds: global.activeGiftAuctionIds?.filter((id) => id !== giftId), + }; + } + } + return { ...global, giftAuctionByGiftId: { @@ -140,11 +159,32 @@ export function replaceGiftAuction( auctionState: ApiStarGiftAuctionState, ): T { const giftId = auctionState.gift.id; + + const previousAuction = global.giftAuctionByGiftId?.[giftId]; + if (previousAuction) { + if (previousAuction.userState.bidDate !== auctionState.userState.bidDate) { + if (auctionState.userState.bidDate) { + global = { + ...global, + activeGiftAuctionIds: unique([...(global.activeGiftAuctionIds || []), giftId]), + }; + } else { + global = { + ...global, + activeGiftAuctionIds: global.activeGiftAuctionIds?.filter((id) => id !== giftId), + }; + } + } + } + return { ...global, giftAuctionByGiftId: { ...global.giftAuctionByGiftId, - [giftId]: auctionState, + [giftId]: { + ...auctionState, + timeout: auctionState.timeout || previousAuction?.timeout, + }, }, }; } @@ -158,9 +198,11 @@ export function removeGiftAuction( } const rest = omit(global.giftAuctionByGiftId, [giftId]); + const activeGiftAuctionIds = global.activeGiftAuctionIds?.filter((id) => id !== giftId); return { ...global, giftAuctionByGiftId: Object.keys(rest).length ? rest : undefined, + activeGiftAuctionIds, }; } diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 832840cab..45def6392 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -2714,6 +2714,9 @@ export interface ActionPayloads { gift: ApiStarGiftUnique; } & WithTabId; closeGiftInfoValueModal: WithTabId | undefined; + loadActiveGiftAuctions: undefined; + openActiveGiftAuctionsModal: WithTabId | undefined; + closeActiveGiftAuctionsModal: WithTabId | undefined; openGiftAuctionModal: { gift: ApiStarGiftRegular; } & WithTabId; diff --git a/src/global/types/globalState.ts b/src/global/types/globalState.ts index fa2eac747..1beab605f 100644 --- a/src/global/types/globalState.ts +++ b/src/global/types/globalState.ts @@ -331,6 +331,7 @@ export type GlobalState = { starGiftCollections?: { byPeerId: Record; }; + activeGiftAuctionIds?: string[]; giftAuctionByGiftId?: Record; stickers: { diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index 10f0948a7..c0bc50547 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -937,6 +937,8 @@ export type TabState = { acquiredGifts?: ApiStarGiftAuctionAcquiredGift[]; }; + activeGiftAuctionsModal?: true; + starGiftPriceDecreaseInfoModal?: { prices: ApiStarGiftUpgradePrice[]; currentPrice: number; diff --git a/src/lib/gramjs/tl/apiTl.ts b/src/lib/gramjs/tl/apiTl.ts index 02b7eb057..dcbebb4be 100644 --- a/src/lib/gramjs/tl/apiTl.ts +++ b/src/lib/gramjs/tl/apiTl.ts @@ -1895,6 +1895,7 @@ payments.getUniqueStarGiftValueInfo#4365af6b slug:string = payments.UniqueStarGi payments.checkCanSendGift#c0c4edc9 gift_id:long = payments.CheckCanSendGiftResult; payments.getStarGiftAuctionState#5c9ff4d6 auction:InputStarGiftAuction version:int = payments.StarGiftAuctionState; payments.getStarGiftAuctionAcquiredGifts#6ba2cbec gift_id:long = payments.StarGiftAuctionAcquiredGifts; +payments.getStarGiftActiveAuctions#a5d0514d hash:long = payments.StarGiftActiveAuctions; payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id:int = Updates; 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; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index b15214b6f..b707c67eb 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -355,6 +355,7 @@ "payments.getStarGiftCollections", "payments.getStarGiftAuctionState", "payments.getStarGiftAuctionAcquiredGifts", + "payments.getStarGiftActiveAuctions", "payments.resolveStarGiftOffer", "langpack.getLangPack", "langpack.getStrings", diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 7b8fc6bea..d4bf564ab 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1844,6 +1844,9 @@ export interface LangPair { 'GiftAuctionTapToBidMore': undefined; 'GiftAuctionLearnMoreAboutGifts': undefined; 'GiftAuctionLearnMoreMenuItem': undefined; + 'GiftAuctionListRaiseBid': undefined; + 'GiftAuctionActiveTitle': undefined; + 'GiftAuctionNoActive': undefined; 'StarGiftInfoTitle': undefined; 'StarGiftInfoSubtitle': undefined; 'StarGiftInfoUniqueTitle': undefined; @@ -1913,6 +1916,9 @@ export interface LangPair { 'SettingsDataClearMediaCache': undefined; 'SettingsDataClearMediaCacheDescription': undefined; 'SettingsDataClearMediaDone': undefined; + 'ChatListAuctionWinning': undefined; + 'ChatListAuctionOutbid': undefined; + 'ChatListAuctionView': undefined; } export interface LangPairWithVariables { @@ -3312,6 +3318,14 @@ export interface LangPairWithVariables { 'GiftAuctionWonNotification': { 'gift': V; }; + 'GiftAuctionBidPosition': { + 'amount': V; + 'position': V; + }; + 'GiftAuctionListRound': { + 'current': V; + 'total': V; + }; 'SettingsPasskeyUsedAt': { 'date': V; }; @@ -3346,6 +3360,10 @@ export interface LangPairWithVariables { 'status': V; 'onlineCount': V; }; + 'ChatListAuctionMixed': { + 'winCount': V; + 'outbidCount': V; + }; } export interface LangPairPlural { @@ -3785,6 +3803,9 @@ export interface LangPairPluralWithVariables { 'AttachmentSendFile': { 'count': V; }; + 'ChatListAuctionTitle': { + 'count': V; + }; } export type RegularLangKey = keyof LangPair; export type RegularLangKeyWithVariables = keyof LangPairWithVariables; diff --git a/src/util/localization/format.tsx b/src/util/localization/format.tsx index b2bf323a5..2d2dc4348 100644 --- a/src/util/localization/format.tsx +++ b/src/util/localization/format.tsx @@ -49,11 +49,15 @@ export function formatTonAsIcon( } export function formatStarsAsIcon(lang: LangFn, amount: number | string, options?: { - asFont?: boolean; className?: string; containerClassName?: string; }) { - const { asFont, className, containerClassName } = options || {}; + asFont?: boolean; + className?: string; + containerClassName?: string; + noStyles?: boolean; +}) { + const { asFont, className, containerClassName, noStyles } = options || {}; const icon = asFont - ? - : ; + ? + : ; if (containerClassName) { return (