Chat List: Show active auctions (#6641)

This commit is contained in:
zubiden 2026-02-22 23:42:54 +01:00 committed by Alexander Zinchuk
parent 31eaf0e5dd
commit d0ba0c2530
35 changed files with 580 additions and 80 deletions

View File

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

View File

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

View File

@ -387,7 +387,7 @@ export interface ApiStarGiftAuctionState {
gift: ApiStarGiftRegular;
state: ApiTypeStarGiftAuctionState;
userState: ApiStarGiftAuctionUserState;
timeout: number;
timeout?: number;
}
export interface ApiStarGiftAuctionAcquiredGift {

View File

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

View File

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

View File

@ -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<HTMLDivElement>;
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<OwnProps> = ({
const CustomEmoji = ({
ref,
documentId,
sticker,
className,
style,
size = STICKER_SIZE,
@ -69,14 +75,15 @@ const CustomEmoji: FC<OwnProps> = ({
observeIntersectionForPlaying,
onClick,
onAnimationEnd,
}) => {
}: OwnProps) => {
let containerRef = useRef<HTMLDivElement>();
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);

View File

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

View File

@ -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<PaneState>(FALLBACK_PANE_STATE);
const [getFrozenAccountState, setFrozenAccountState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const [getGiftAuctionState, setGiftAuctionState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const [getSuggestionState, setSuggestionState] = useSignal<PaneState>(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}
/>
<GiftAuctionPane
canShow={canShowGiftAuctions}
onPaneStateChange={setGiftAuctionState}
/>
<SuggestionPane
promoData={canShowSuggestions ? promoData : undefined}
onPaneStateChange={setSuggestionState}

View File

@ -0,0 +1,30 @@
@use "../../../../styles/mixins";
.pane {
@include mixins.chat-list-pane;
cursor: var(--custom-cursor, pointer);
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);
font-size: 0.9375rem;
font-weight: var(--font-weight-semibold);
}
.subtitle {
--emoji-size: 1rem;
--custom-emoji-size: var(--emoji-size);
font-size: 0.875rem;
color: var(--color-text-secondary);
}

View File

@ -0,0 +1,36 @@
.root {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto 1fr;
}
.title {
grid-column: 1 / 2;
grid-row: 1 / 2;
}
.giftEmojis {
margin-inline-end: 0.25rem;
}
.subtitle {
grid-column: 1 / 3;
grid-row: 2 / 3;
}
.button {
grid-column: 2 / 3;
grid-row: 1 / 3;
place-self: center;
font-size: 0.875rem !important;
line-height: 1.5;
}
.buttonIcon {
font-size: 0.875rem !important;
}
.outbid {
color: var(--color-error);
}

View File

@ -0,0 +1,136 @@
import { memo, useMemo } from '@teact';
import { getActions, withGlobal } from '../../../../global';
import type { ApiStarGiftAuctionState, ApiStarGiftAuctionStateActive } from '../../../../api/types';
import type { GlobalState } from '../../../../global/types';
import buildClassName from '../../../../util/buildClassName';
import { partition } from '../../../../util/iteratees';
import { getBidAuctionPosition } from '../../../common/helpers/gifts';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import useHeaderPane, { type PaneState } from '../../../middle/hooks/useHeaderPane';
import CustomEmoji from '../../../common/CustomEmoji';
import Button from '../../../ui/Button';
import TextTimer from '../../../ui/TextTimer';
import paneStyles from './ChatPane.module.scss';
import styles from './GiftAuctionPane.module.scss';
type OwnProps = {
canShow?: boolean;
onPaneStateChange: (state: PaneState) => void;
};
type StateProps = Pick<GlobalState, 'activeGiftAuctionIds' | 'giftAuctionByGiftId'>;
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 (
<div
ref={ref}
className={buildClassName(paneStyles.pane, styles.root)}
role="button"
tabIndex={0}
onClick={handleClick}
>
<div className={buildClassName(paneStyles.title, styles.title)}>
<span className={styles.giftEmojis}>
{activeAuctions?.map((auction) => (
<CustomEmoji
key={auction.gift.id}
sticker={auction.gift.sticker}
loopLimit={1}
/>
))}
</span>
{lang('ChatListAuctionTitle', { count: activeAuctionsCount }, { pluralValue: activeAuctionsCount })}
</div>
<div
className={buildClassName(
paneStyles.subtitle, styles.subtitle, !winningCount && outbidCount && styles.outbid,
)}
>
{renderSubtitleText()}
</div>
<Button
className={styles.button}
iconName={singleActiveAuction ? 'auction-filled' : undefined}
iconClassName={styles.buttonIcon}
size="tiny"
pill
onClick={handleClick}
>
{singleActiveAuction ? (
<TextTimer
endsAt={(singleActiveAuction.state as ApiStarGiftAuctionStateActive).nextRoundAt}
shouldShowZeroOnEnd
/>
) : lang('ChatListAuctionView')}
</Button>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
return {
activeGiftAuctionIds: global.activeGiftAuctionIds,
giftAuctionByGiftId: global.giftAuctionByGiftId,
};
},
)(GiftAuctionPane));

View File

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

View File

@ -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 (
<div
ref={ref}
className={styles.root}
className={buildClassName(paneStyles.pane, styles.root)}
role="button"
tabIndex={0}
onClick={handleClick}
>
<div className={styles.title}>{title}</div>
<div className={styles.subtitle}>{message}</div>
<div className={buildClassName(paneStyles.title, styles.title)}>{title}</div>
<div className={buildClassName(paneStyles.subtitle, styles.subtitle)}>{message}</div>
<Icon name="close" className={styles.closeIcon} onClick={handleDismiss} />
</div>
);

View File

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

View File

@ -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',
});

View File

@ -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<TabState,
'giftAuctionInfoModal' |
'giftAuctionChangeRecipientModal' |
'giftAuctionAcquiredModal' |
'activeGiftAuctionsModal' |
'starGiftPriceDecreaseInfoModal' |
'aboutStarGiftModal' |
'monetizationVerificationModal' |
@ -182,6 +184,7 @@ const MODALS: ModalRegistry = {
giftAuctionInfoModal: GiftAuctionInfoModal,
giftAuctionChangeRecipientModal: GiftAuctionChangeRecipientModal,
giftAuctionAcquiredModal: GiftAuctionAcquiredModal,
activeGiftAuctionsModal: ActiveGiftAuctionsModal,
starGiftPriceDecreaseInfoModal: StarGiftPriceDecreaseInfoModal,
aboutStarGiftModal: AboutStarGiftModal,
monetizationVerificationModal: VerificationMonetizationModal,

View File

@ -0,0 +1,14 @@
import type { OwnProps } from './ActiveGiftAuctionsModal';
import { Bundles } from '../../../../util/moduleLoader';
import useModuleLoader from '../../../../hooks/useModuleLoader';
const ActiveGiftAuctionsModalAsync = (props: OwnProps) => {
const { modal } = props;
const ActiveGiftAuctionsModal = useModuleLoader(Bundles.Stars, 'ActiveGiftAuctionsModal', !modal);
return ActiveGiftAuctionsModal ? <ActiveGiftAuctionsModal {...props} /> : undefined;
};
export default ActiveGiftAuctionsModalAsync;

View File

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

View File

@ -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<string, ApiStarGiftAuctionState>;
};
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 (
<Modal
isOpen={Boolean(modal)}
onClose={closeActiveGiftAuctionsModal}
hasCloseButton
isCondensedHeader
title={lang('GiftAuctionActiveTitle')}
dialogClassName={styles.dialog}
contentClassName={styles.content}
>
{activeAuctions?.length
? activeAuctions.map((auction) => <ActiveAuctionItem key={auction.gift.id} auction={auction} />)
: <div className={styles.noActive}>{lang('GiftAuctionNoActive')}</div>}
</Modal>
);
};
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 (
<ListItem
leftElement={<CustomEmoji className={styles.gift} sticker={gift.sticker} size={ICON_SIZE} loopLimit={1} />}
rightElement={(
<Button
size="tiny"
pill
fluid
iconName="auction-filled"
onClick={handleClick}
>
{lang('GiftAuctionListRaiseBid')}
<TextTimer className={styles.timer} endsAt={state.nextRoundAt} shouldShowZeroOnEnd />
</Button>
)}
multiline
onClick={handleClick}
>
<div className="title">
{lang('GiftAuctionListRound', {
current: lang.number(state.currentRound),
total: lang.number(state.totalRounds),
})}
</div>
<div className="subtitle">
{lang('GiftAuctionBidPosition', {
amount: formatStarsAsIcon(lang, userState.bidAmount!, { noStyles: true }),
position: lang.number(bidPosition),
}, {
withNodes: true,
})}
</div>
</ListItem>
);
}
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
const { activeGiftAuctionIds, giftAuctionByGiftId } = global;
return {
activeGiftAuctionIds,
giftAuctionByGiftId,
};
},
)(ActiveGiftAuctionsModal));

View File

@ -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() {

View File

@ -112,7 +112,6 @@ const GiftTransferModal = ({
hasCloseButton
shouldAdaptToSearch
withFixedHeight
ignoreFreeze
>
<PeerPicker<Categories>
itemIds={displayIds}

View File

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

View File

@ -45,7 +45,6 @@ export type OwnProps = {
dialogRef?: ElementRef<HTMLDivElement>;
isLowStackPriority?: boolean;
dialogContent?: React.ReactNode;
ignoreFreeze?: boolean;
moreMenuItems?: TeactNode;
onClose: () => void;
onCloseAnimationEnd?: () => void;

View File

@ -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 = (
<span style="font-variant-numeric: tabular-nums;">
return (
<span className={className} style="font-variant-numeric: tabular-nums;">
{timeParts.map((part, index) => (
<>
{index > 0 && ':'}
@ -45,12 +46,6 @@ const TextTimer = ({ endsAt, shouldShowZeroOnEnd, onEnd }: OwnProps) => {
))}
</span>
);
return (
<span>
{timeCounter}
</span>
);
};
export default TextTimer;

View File

@ -612,6 +612,7 @@ addActionHandler('loadGiftAuction', async (global, _actions, payload): Promise<v
global = getGlobal();
global = replaceGiftAuction(global, auctionState);
setGlobal(global);
});
@ -735,3 +736,19 @@ addActionHandler('declineStarGiftOffer', async (global, actions, payload): Promi
shouldDecline: true,
});
});
addActionHandler('loadActiveGiftAuctions', async (global, actions, payload): Promise<void> => {
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);
});

View File

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

View File

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

View File

@ -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<T extends GlobalState>(
@ -74,7 +74,11 @@ export function updateGiftAuction<T extends GlobalState>(
...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<T extends GlobalState>(
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<T extends GlobalState>(
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<T extends GlobalState>(
}
const rest = omit(global.giftAuctionByGiftId, [giftId]);
const activeGiftAuctionIds = global.activeGiftAuctionIds?.filter((id) => id !== giftId);
return {
...global,
giftAuctionByGiftId: Object.keys(rest).length ? rest : undefined,
activeGiftAuctionIds,
};
}

View File

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

View File

@ -331,6 +331,7 @@ export type GlobalState = {
starGiftCollections?: {
byPeerId: Record<string, ApiStarGiftCollection[]>;
};
activeGiftAuctionIds?: string[];
giftAuctionByGiftId?: Record<string, ApiStarGiftAuctionState>;
stickers: {

View File

@ -937,6 +937,8 @@ export type TabState = {
acquiredGifts?: ApiStarGiftAuctionAcquiredGift[];
};
activeGiftAuctionsModal?: true;
starGiftPriceDecreaseInfoModal?: {
prices: ApiStarGiftUpgradePrice[];
currentPrice: number;

View File

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

View File

@ -355,6 +355,7 @@
"payments.getStarGiftCollections",
"payments.getStarGiftAuctionState",
"payments.getStarGiftAuctionAcquiredGifts",
"payments.getStarGiftActiveAuctions",
"payments.resolveStarGiftOffer",
"langpack.getLangPack",
"langpack.getStrings",

View File

@ -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<V = LangVariable> {
@ -3312,6 +3318,14 @@ export interface LangPairWithVariables<V = LangVariable> {
'GiftAuctionWonNotification': {
'gift': V;
};
'GiftAuctionBidPosition': {
'amount': V;
'position': V;
};
'GiftAuctionListRound': {
'current': V;
'total': V;
};
'SettingsPasskeyUsedAt': {
'date': V;
};
@ -3346,6 +3360,10 @@ export interface LangPairWithVariables<V = LangVariable> {
'status': V;
'onlineCount': V;
};
'ChatListAuctionMixed': {
'winCount': V;
'outbidCount': V;
};
}
export interface LangPairPlural {
@ -3785,6 +3803,9 @@ export interface LangPairPluralWithVariables<V = LangVariable> {
'AttachmentSendFile': {
'count': V;
};
'ChatListAuctionTitle': {
'count': V;
};
}
export type RegularLangKey = keyof LangPair;
export type RegularLangKeyWithVariables = keyof LangPairWithVariables;

View File

@ -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
? <Icon name="star" className={buildClassName('star-amount-icon', className)} />
: <StarIcon type="gold" className={buildClassName('star-amount-icon', className)} size="adaptive" />;
? <Icon name="star" className={buildClassName(!noStyles && 'star-amount-icon', className)} />
: <StarIcon type="gold" className={buildClassName(!noStyles && 'star-amount-icon', className)} size="adaptive" />;
if (containerClassName) {
return (