Management / Boosts: Add giveaway and gift tabs, various fixes (#4453)

Co-authored-by: zubiden <19638254+zubiden@users.noreply.github.com>
This commit is contained in:
Alexander Zinchuk 2024-05-03 14:38:04 +02:00
parent 3a1c87fcb0
commit 8b31f33685
16 changed files with 357 additions and 108 deletions

View File

@ -2,6 +2,7 @@ import { Api as GramJs } from '../../../lib/gramjs';
import type { ApiPremiumSection } from '../../../global/types'; import type { ApiPremiumSection } from '../../../global/types';
import type { import type {
ApiBoost,
ApiBoostsStatus, ApiBoostsStatus,
ApiCheckedGiftCode, ApiCheckedGiftCode,
ApiGiveawayInfo, ApiGiveawayInfo,
@ -223,6 +224,24 @@ export function buildApiBoostsStatus(boostStatus: GramJs.premium.BoostsStatus):
}; };
} }
export function buildApiBoost(boost: GramJs.Boost): ApiBoost {
const {
userId,
multiplier,
expires,
giveaway,
gift,
} = boost;
return {
userId: userId && buildApiPeerId(userId, 'user'),
multiplier,
expires,
isFromGiveaway: giveaway,
isGift: gift,
};
}
export function buildApiMyBoost(myBoost: GramJs.MyBoost): ApiMyBoost { export function buildApiMyBoost(myBoost: GramJs.MyBoost): ApiMyBoost {
const { const {
date, expires, slot, cooldownUntilDate, peer, date, expires, slot, cooldownUntilDate, peer,

View File

@ -6,9 +6,9 @@ import type {
OnApiUpdate, OnApiUpdate,
} from '../../types'; } from '../../types';
import { buildCollectionByCallback } from '../../../util/iteratees';
import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { import {
buildApiBoost,
buildApiBoostsStatus, buildApiBoostsStatus,
buildApiCheckedGiftCode, buildApiCheckedGiftCode,
buildApiGiveawayInfo, buildApiGiveawayInfo,
@ -266,15 +266,18 @@ export async function fetchBoostStatus({
export async function fetchBoostList({ export async function fetchBoostList({
chat, chat,
isGifts,
offset = '', offset = '',
limit, limit,
}: { }: {
chat: ApiChat; chat: ApiChat;
isGifts?: boolean;
offset?: string; offset?: string;
limit?: number; limit?: number;
}) { }) {
const result = await invokeRequest(new GramJs.premium.GetBoostsList({ const result = await invokeRequest(new GramJs.premium.GetBoostsList({
peer: buildInputPeer(chat.id, chat.accessHash), peer: buildInputPeer(chat.id, chat.accessHash),
gifts: isGifts || undefined,
offset, offset,
limit, limit,
})); }));
@ -287,17 +290,12 @@ export async function fetchBoostList({
const users = result.users.map(buildApiUser).filter(Boolean); const users = result.users.map(buildApiUser).filter(Boolean);
const userBoosts = result.boosts.filter((boost) => boost.userId); const boostList = result.boosts.map(buildApiBoost);
const boosterIds = userBoosts.map((boost) => boost.userId!.toString());
const boosters = buildCollectionByCallback(userBoosts, (boost) => (
[boost.userId!.toString(), boost.expires]
));
return { return {
count: result.count, count: result.count,
boostList,
users, users,
boosters,
boosterIds,
nextOffset: result.nextOffset, nextOffset: result.nextOffset,
}; };
} }

View File

@ -132,6 +132,14 @@ export type ApiMyBoost = {
cooldownUntil?: number; cooldownUntil?: number;
}; };
export type ApiBoost = {
userId?: string;
multiplier?: number;
expires: number;
isFromGiveaway?: boolean;
isGift?: boolean;
};
export type ApiGiveawayInfoActive = { export type ApiGiveawayInfoActive = {
type: 'active'; type: 'active';
isParticipating?: true; isParticipating?: true;

View File

@ -257,4 +257,8 @@
&.hidden-user { &.hidden-user {
--color-user: var(--color-deleted-account); --color-user: var(--color-deleted-account);
} }
&.unknown-user {
background: var(--premium-gradient);
}
} }

View File

@ -53,6 +53,7 @@ type OwnProps = {
photo?: ApiPhoto; photo?: ApiPhoto;
text?: string; text?: string;
isSavedMessages?: boolean; isSavedMessages?: boolean;
isUnknownUser?: boolean;
isSavedDialog?: boolean; isSavedDialog?: boolean;
withVideo?: boolean; withVideo?: boolean;
withStory?: boolean; withStory?: boolean;
@ -77,6 +78,7 @@ const Avatar: FC<OwnProps> = ({
text, text,
isSavedMessages, isSavedMessages,
isSavedDialog, isSavedDialog,
isUnknownUser,
withVideo, withVideo,
withStory, withStory,
forPremiumPromo, forPremiumPromo,
@ -120,6 +122,10 @@ const Avatar: FC<OwnProps> = ({
} }
const specialIcon = useMemo(() => { const specialIcon = useMemo(() => {
if (isUnknownUser) {
return 'user';
}
if (isSavedMessages) { if (isSavedMessages) {
return isSavedDialog ? 'my-notes' : 'avatar-saved-messages'; return isSavedDialog ? 'my-notes' : 'avatar-saved-messages';
} }
@ -137,7 +143,7 @@ const Avatar: FC<OwnProps> = ({
} }
return undefined; return undefined;
}, [isAnonymousForwards, isDeleted, isSavedDialog, isReplies, isSavedMessages]); }, [isUnknownUser, isSavedMessages, isDeleted, isReplies, isAnonymousForwards, isSavedDialog]);
const imgBlobUrl = useMedia(imageHash, false, ApiMediaFormat.BlobUrl); const imgBlobUrl = useMedia(imageHash, false, ApiMediaFormat.BlobUrl);
const videoBlobUrl = useMedia(videoHash, !shouldLoadVideo, ApiMediaFormat.BlobUrl); const videoBlobUrl = useMedia(videoHash, !shouldLoadVideo, ApiMediaFormat.BlobUrl);
@ -215,6 +221,7 @@ const Avatar: FC<OwnProps> = ({
`Avatar size-${size}`, `Avatar size-${size}`,
className, className,
getPeerColorClass(peer), getPeerColorClass(peer),
isUnknownUser && 'unknown-user',
!peer && text && 'hidden-user', !peer && text && 'hidden-user',
isSavedMessages && 'saved-messages', isSavedMessages && 'saved-messages',
isAnonymousForwards && 'anonymous-forwards', isAnonymousForwards && 'anonymous-forwards',

View File

@ -25,7 +25,7 @@ import VerifiedIcon from './VerifiedIcon';
import styles from './FullNameTitle.module.scss'; import styles from './FullNameTitle.module.scss';
type OwnProps = { type OwnProps = {
peer: ApiPeer; peer?: ApiPeer;
className?: string; className?: string;
noVerified?: boolean; noVerified?: boolean;
noFake?: boolean; noFake?: boolean;
@ -34,9 +34,11 @@ type OwnProps = {
isSavedMessages?: boolean; isSavedMessages?: boolean;
isSavedDialog?: boolean; isSavedDialog?: boolean;
noLoopLimit?: boolean; noLoopLimit?: boolean;
isUnknownUser?: boolean;
canCopyTitle?: boolean; canCopyTitle?: boolean;
onEmojiStatusClick?: NoneToVoidFunction; onEmojiStatusClick?: NoneToVoidFunction;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
iconElement?: React.ReactNode;
}; };
const FullNameTitle: FC<OwnProps> = ({ const FullNameTitle: FC<OwnProps> = ({
@ -52,13 +54,26 @@ const FullNameTitle: FC<OwnProps> = ({
canCopyTitle, canCopyTitle,
onEmojiStatusClick, onEmojiStatusClick,
observeIntersection, observeIntersection,
iconElement,
isUnknownUser,
}) => { }) => {
const lang = useLang(); const lang = useLang();
const { showNotification } = getActions(); const { showNotification } = getActions();
const isUser = isUserId(peer.id); const isUser = peer && isUserId(peer.id);
const title = isUser ? getUserFullName(peer as ApiUser) : getChatTitle(lang, peer as ApiChat);
const isPremium = isUser && (peer as ApiUser).isPremium; const isPremium = isUser && (peer as ApiUser).isPremium;
const title = useMemo(() => {
if (isUnknownUser) {
return lang('BoostingToBeDistributed');
}
if (peer && isUserId(peer.id)) {
return getUserFullName(peer as ApiUser);
}
return peer && getChatTitle(lang, peer as ApiChat);
}, [isUnknownUser, lang, peer]);
const handleTitleClick = useLastCallback((e) => { const handleTitleClick = useLastCallback((e) => {
if (!title || !canCopyTitle) { if (!title || !canCopyTitle) {
return; return;
@ -74,16 +89,16 @@ const FullNameTitle: FC<OwnProps> = ({
return lang(isSavedDialog ? 'MyNotes' : 'SavedMessages'); return lang(isSavedDialog ? 'MyNotes' : 'SavedMessages');
} }
if (isAnonymousForwardsChat(peer.id)) { if (peer && isAnonymousForwardsChat(peer.id)) {
return lang('AnonymousForward'); return lang('AnonymousForward');
} }
if (isChatWithRepliesBot(peer.id)) { if (peer && isChatWithRepliesBot(peer.id)) {
return lang('RepliesTitle'); return lang('RepliesTitle');
} }
return undefined; return undefined;
}, [isSavedDialog, isSavedMessages, lang, peer.id]); }, [isSavedDialog, isSavedMessages, lang, peer]);
if (specialTitle) { if (specialTitle) {
return ( return (
@ -103,18 +118,23 @@ const FullNameTitle: FC<OwnProps> = ({
> >
{renderText(title || '')} {renderText(title || '')}
</h3> </h3>
{!noVerified && peer.isVerified && <VerifiedIcon />} {!iconElement && peer && (
{!noFake && peer.fakeType && <FakeIcon fakeType={peer.fakeType} />} <>
{withEmojiStatus && peer.emojiStatus && ( {!noVerified && peer?.isVerified && <VerifiedIcon />}
<CustomEmoji {!noFake && peer?.fakeType && <FakeIcon fakeType={peer.fakeType} />}
documentId={peer.emojiStatus.documentId} {withEmojiStatus && peer.emojiStatus && (
size={emojiStatusSize} <CustomEmoji
loopLimit={!noLoopLimit ? EMOJI_STATUS_LOOP_LIMIT : undefined} documentId={peer.emojiStatus.documentId}
observeIntersectionForLoading={observeIntersection} size={emojiStatusSize}
onClick={onEmojiStatusClick} loopLimit={!noLoopLimit ? EMOJI_STATUS_LOOP_LIMIT : undefined}
/> observeIntersectionForLoading={observeIntersection}
onClick={onEmojiStatusClick}
/>
)}
{withEmojiStatus && !peer.emojiStatus && isPremium && <PremiumIcon />}
</>
)} )}
{withEmojiStatus && !peer.emojiStatus && isPremium && <PremiumIcon />} {iconElement}
</div> </div>
); );
}; };

View File

@ -3,9 +3,8 @@
display: flex; display: flex;
position: relative; position: relative;
height: 2rem; height: 2rem;
background: #F1F3F5; background: var(--color-background-menu-separator);
border-radius: 0.625rem; border-radius: 0.625rem;
color: black;
} }
.withBadge { .withBadge {

View File

@ -9,7 +9,9 @@ import type { StoryViewerOrigin } from '../../types';
import type { IconName } from '../../types/icons'; import type { IconName } from '../../types/icons';
import { MediaViewerOrigin } from '../../types'; import { MediaViewerOrigin } from '../../types';
import { getMainUsername, getUserStatus, isUserOnline } from '../../global/helpers'; import {
getMainUsername, getUserStatus, isUserOnline,
} from '../../global/helpers';
import { selectChatMessages, selectUser, selectUserStatus } from '../../global/selectors'; import { selectChatMessages, selectUser, selectUserStatus } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
@ -36,10 +38,13 @@ type OwnProps = {
withMediaViewer?: boolean; withMediaViewer?: boolean;
withUsername?: boolean; withUsername?: boolean;
withStory?: boolean; withStory?: boolean;
isUnknownUser?: boolean;
withFullInfo?: boolean; withFullInfo?: boolean;
withUpdatingStatus?: boolean; withUpdatingStatus?: boolean;
storyViewerOrigin?: StoryViewerOrigin; storyViewerOrigin?: StoryViewerOrigin;
noEmojiStatus?: boolean; noEmojiStatus?: boolean;
noFake?: boolean;
noVerified?: boolean;
emojiStatusSize?: number; emojiStatusSize?: number;
noStatusOrTyping?: boolean; noStatusOrTyping?: boolean;
noRtl?: boolean; noRtl?: boolean;
@ -47,6 +52,8 @@ type OwnProps = {
isSavedDialog?: boolean; isSavedDialog?: boolean;
className?: string; className?: string;
onEmojiStatusClick?: NoneToVoidFunction; onEmojiStatusClick?: NoneToVoidFunction;
iconElement?: React.ReactNode;
rightElement?: React.ReactNode;
}; };
type StateProps = type StateProps =
@ -73,6 +80,9 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
emojiStatusSize, emojiStatusSize,
noStatusOrTyping, noStatusOrTyping,
noEmojiStatus, noEmojiStatus,
noFake,
noVerified,
isUnknownUser,
noRtl, noRtl,
user, user,
userStatus, userStatus,
@ -86,6 +96,8 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
storyViewerOrigin, storyViewerOrigin,
isSynced, isSynced,
onEmojiStatusClick, onEmojiStatusClick,
iconElement,
rightElement,
}) => { }) => {
const { const {
loadFullUser, loadFullUser,
@ -119,7 +131,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
const mainUsername = useMemo(() => user && withUsername && getMainUsername(user), [user, withUsername]); const mainUsername = useMemo(() => user && withUsername && getMainUsername(user), [user, withUsername]);
if (!user) { if (!user && !isUnknownUser) {
return undefined; return undefined;
} }
@ -183,11 +195,15 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
return ( return (
<FullNameTitle <FullNameTitle
peer={user!} peer={user!}
noFake={noFake}
noVerified={noVerified}
withEmojiStatus={!noEmojiStatus} withEmojiStatus={!noEmojiStatus}
emojiStatusSize={emojiStatusSize} emojiStatusSize={emojiStatusSize}
isSavedMessages={isSavedMessages} isSavedMessages={isSavedMessages}
isSavedDialog={isSavedDialog} isSavedDialog={isSavedDialog}
onEmojiStatusClick={onEmojiStatusClick} onEmojiStatusClick={onEmojiStatusClick}
isUnknownUser={isUnknownUser}
iconElement={iconElement}
/> />
); );
} }
@ -204,11 +220,12 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
/> />
)} )}
<Avatar <Avatar
key={user.id} key={user?.id}
size={avatarSize} size={avatarSize}
peer={user} peer={user}
className={buildClassName(isSavedDialog && 'overlay-avatar')} className={buildClassName(isSavedDialog && 'overlay-avatar')}
isSavedMessages={isSavedMessages} isSavedMessages={isSavedMessages}
isUnknownUser={isUnknownUser}
isSavedDialog={isSavedDialog} isSavedDialog={isSavedDialog}
withStory={withStory} withStory={withStory}
storyViewerOrigin={storyViewerOrigin} storyViewerOrigin={storyViewerOrigin}
@ -220,6 +237,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
{(status || (!isSavedMessages && !noStatusOrTyping)) && renderStatusOrTyping()} {(status || (!isSavedMessages && !noStatusOrTyping)) && renderStatusOrTyping()}
</div> </div>
{ripple && <RippleEffect />} {ripple && <RippleEffect />}
{rightElement}
</div> </div>
); );
}; };

View File

@ -18,7 +18,7 @@ import mastercardIconPath from '../../assets/mastercard.svg';
import mirIconPath from '../../assets/mir.svg'; import mirIconPath from '../../assets/mir.svg';
import visaIconPath from '../../assets/visa.svg'; import visaIconPath from '../../assets/visa.svg';
const CARD_NUMBER_MAX_LENGTH = 23; const CARD_NUMBER_MAX_LENGTH = 19;
export type OwnProps = { export type OwnProps = {
value: string; value: string;

View File

@ -529,7 +529,7 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
switch (step) { switch (step) {
case PaymentStep.Checkout: case PaymentStep.Checkout:
return Boolean(invoice?.isRecurring && !isTosAccepted); return !isTosAccepted;
case PaymentStep.PaymentInfo: case PaymentStep.PaymentInfo:
return Boolean( return Boolean(

View File

@ -9,6 +9,7 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-align: center; text-align: center;
font-size: 0.9375rem; font-size: 0.9375rem;
padding: 0.75rem 0 0 0;
} }
.section-header { .section-header {
@ -17,7 +18,7 @@
} }
.section { .section {
padding: 1rem; padding: 0.625rem;
@include mixins.side-panel-section; @include mixins.side-panel-section;
} }
@ -72,18 +73,36 @@
} }
.floatingBadgeButtonColor { .floatingBadgeButtonColor {
padding: 0.25rem 0.75rem 0.375rem 0.5625rem; padding: 0 0.5rem 0 0.375rem;
border-radius: 1rem; border-radius: 1rem;
background-color: var(--color-primary-opacity); background-color: var(--color-primary-opacity);
margin-left: 0.5rem; margin-left: 0.5rem;
} }
.floatingBadgeWarning {
color: var(--color-orange);
background: var( --color-light-coral);
}
.floatingBadgeButton {
padding: 0.125rem 0.75rem 0.125rem 0.5625rem;
}
.floatingBadgeIcon { .floatingBadgeIcon {
font-size: 1.125rem; font-size: 0.875rem;
margin-right: 0.1875rem; margin-right: 0.125rem;
} }
.floatingBadgeValue { .floatingBadgeValue {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
} }
.content {
padding: 0.5rem 0;
}
.boostSection {
display: flex;
flex-direction: column-reverse;
}

View File

@ -1,12 +1,20 @@
import React, { memo, useMemo } from '../../../lib/teact/teact'; import React, {
memo, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiBoostStatistics, ApiPrepaidGiveaway } from '../../../api/types'; import type { ApiBoostStatistics, ApiPrepaidGiveaway } from '../../../api/types';
import type { TabState } from '../../../global/types'; import type { TabState } from '../../../global/types';
import { GIVEAWAY_BOOST_PER_PREMIUM } from '../../../config'; import {
GIVEAWAY_BOOST_PER_PREMIUM,
} from '../../../config';
import { isChatChannel } from '../../../global/helpers'; import { isChatChannel } from '../../../global/helpers';
import { selectChat, selectIsGiveawayGiftsPurchaseAvailable, selectTabState } from '../../../global/selectors'; import {
selectChat,
selectIsGiveawayGiftsPurchaseAvailable,
selectTabState,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { formatDateAtTime } from '../../../util/date/dateFormat'; import { formatDateAtTime } from '../../../util/date/dateFormat';
import { getBoostProgressInfo } from '../../common/helpers/boostInfo'; import { getBoostProgressInfo } from '../../common/helpers/boostInfo';
@ -21,6 +29,8 @@ import PrivateChatInfo from '../../common/PrivateChatInfo';
import ListItem from '../../ui/ListItem'; import ListItem from '../../ui/ListItem';
import Loading from '../../ui/Loading'; import Loading from '../../ui/Loading';
import Spinner from '../../ui/Spinner'; import Spinner from '../../ui/Spinner';
import TabList from '../../ui/TabList';
import Transition from '../../ui/Transition';
import StatisticsOverview from './StatisticsOverview'; import StatisticsOverview from './StatisticsOverview';
import styles from './BoostStatistics.module.scss'; import styles from './BoostStatistics.module.scss';
@ -51,13 +61,20 @@ const BoostStatistics = ({
isChannel, isChannel,
}: StateProps) => { }: StateProps) => {
const { const {
openChat, loadMoreBoosters, closeBoostStatistics, openGiveawayModal, openChat, loadMoreBoosters, closeBoostStatistics, openGiveawayModal, showNotification,
} = getActions(); } = getActions();
const lang = useLang(); const lang = useLang();
// eslint-disable-next-line no-null/no-null
const transitionRef = useRef<HTMLDivElement>(null);
const isLoaded = boostStatistics?.boostStatus; const isLoaded = boostStatistics?.boostStatus;
const status = isLoaded ? boostStatistics.boostStatus : undefined; const status = isLoaded ? boostStatistics.boostStatus : undefined;
const isGiftListEqual = boostStatistics && boostStatistics?.boosts?.count
=== boostStatistics?.giftedBoosts?.count;
const shouldDisplayGiftList = !isGiftListEqual && boostStatistics?.giftedBoosts
&& boostStatistics?.giftedBoosts?.list?.length > 0;
const { const {
currentLevel, currentLevel,
hasNextLevel, hasNextLevel,
@ -90,30 +107,140 @@ const BoostStatistics = ({
} satisfies ApiBoostStatistics; } satisfies ApiBoostStatistics;
}, [status, boosts, currentLevel, remainingBoosts]); }, [status, boosts, currentLevel, remainingBoosts]);
const boostersToLoadCount = useMemo(() => { const tabs = useMemo(() => {
if (!boostStatistics?.count) return undefined; if (shouldDisplayGiftList) {
const loadedCount = boostStatistics.boosterIds?.length || 0; return [
const totalCount = boostStatistics.count; { type: 'boostList', title: lang('BoostingBoostsCount', boostStatistics?.boosts?.count) },
return totalCount - loadedCount; { type: 'giftedBoostList', title: lang('BoostingGiftsCount', boostStatistics?.giftedBoosts?.count) },
];
}
return [];
}, [shouldDisplayGiftList, boostStatistics?.boosts?.count, boostStatistics?.giftedBoosts?.count, lang]);
const initialTab = useMemo(() => {
return boostStatistics?.boosts && boostStatistics.boosts?.list.length > 0 ? 1 : 0;
}, [boostStatistics]); }, [boostStatistics]);
const handleBoosterClick = useLastCallback((userId: string) => { const [activeTab, setActiveTab] = useState(initialTab);
const renderingActiveTab = activeTab > tabs.length - 1 ? tabs.length - 1 : activeTab;
const tabType = tabs[renderingActiveTab]?.type;
const activeKey = tabs.findIndex(({ type }) => type === tabType);
const boostersToLoadCount = useMemo(() => {
if (!boostStatistics) return undefined;
const list = shouldDisplayGiftList ? (tabType === 'boostList'
? boostStatistics.boosts : boostStatistics.giftedBoosts) : boostStatistics.boosts;
if (!list?.count) return undefined;
const loadedBoostsCount = list.list.reduce((total, boost) => {
return total + (boost.multiplier || 1);
}, 0);
const totalCount = list.count;
const toLoadCount = totalCount - loadedBoostsCount;
return toLoadCount > 0 ? toLoadCount : undefined;
}, [shouldDisplayGiftList, boostStatistics, tabType]);
const renderBoostIcon = useLastCallback((multiplier: number) => (
<div className={styles.quantity}>
<div className={buildClassName(styles.floatingBadge, styles.floatingBadgeButtonColor)}>
<Icon name="boost" className={styles.floatingBadgeIcon} />
<div className={styles.floatingBadgeValue}>{multiplier}</div>
</div>
</div>
));
const renderBoostTypeIcon = useLastCallback((boost) => {
if (!boost.isFromGiveaway && !boost.isGift) {
return undefined;
}
return (
<div className={styles.quantity}>
<div className={buildClassName(styles.floatingBadge,
!boost.giveaway && styles.floatingBadgeWarning,
styles.floatingBadgeButtonColor,
styles.floatingBadgeButton)}
>
<Icon name="gift" className={styles.floatingBadgeIcon} />
<div className={styles.floatingBadgeValue}>{lang(boost.giveaway
? 'lng_prizes_results_link' : 'BoostingGift')}
</div>
</div>
</div>
);
});
const handleBoosterClick = useLastCallback((userId?: string) => {
if (!userId) {
showNotification({
message: lang('BoostingRecipientWillBeSelected'),
});
return;
}
openChat({ id: userId }); openChat({ id: userId });
closeBoostStatistics(); closeBoostStatistics();
}); });
const renderBoostList = useLastCallback((boost) => {
return (
<ListItem
className="chat-item-clickable"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleBoosterClick(boost.userId)}
>
<PrivateChatInfo
className={styles.user}
userId={boost.userId}
status={lang('BoostExpireOn', formatDateAtTime(lang, boost.expires * 1000))}
noEmojiStatus
forceShowSelf
noFake
noVerified
isUnknownUser={!boost.userId}
iconElement={boost.multiplier ? renderBoostIcon(boost.multiplier) : undefined}
rightElement={renderBoostTypeIcon(boost)}
/>
</ListItem>
);
});
const handleGiveawayClick = useLastCallback(() => { const handleGiveawayClick = useLastCallback(() => {
openGiveawayModal({ chatId }); openGiveawayModal({ chatId });
}); });
const handleLoadMore = useLastCallback(() => { const handleLoadMore = useLastCallback(() => {
loadMoreBoosters(); loadMoreBoosters({ isGifts: tabType === 'giftedBoostList' });
}); });
const launchPrepaidGiveawayHandler = useLastCallback((prepaidGiveaway: ApiPrepaidGiveaway) => { const launchPrepaidGiveawayHandler = useLastCallback((prepaidGiveaway: ApiPrepaidGiveaway) => {
openGiveawayModal({ chatId, prepaidGiveaway }); openGiveawayModal({ chatId, prepaidGiveaway });
}); });
function renderContent() {
let listToRender;
if (tabType === 'boostList') {
listToRender = boostStatistics?.boosts?.list;
} else if (tabType === 'giftedBoostList') {
listToRender = boostStatistics?.giftedBoosts?.list;
}
if (listToRender && !listToRender?.length) {
return undefined;
}
return (
<div className={styles.content}>
{listToRender?.map((boost) => renderBoostList(boost))}
</div>
);
}
return ( return (
<div className={buildClassName(styles.root, 'custom-scroll')}> <div className={buildClassName(styles.root, 'custom-scroll')}>
{!isLoaded && <Loading />} {!isLoaded && <Loading />}
@ -152,7 +279,10 @@ const BoostStatistics = ({
<p className={styles.month}>{lang('PrepaidGiveawayMonths', prepaidGiveaway.months)}</p> <p className={styles.month}>{lang('PrepaidGiveawayMonths', prepaidGiveaway.months)}</p>
</div> </div>
<div className={styles.quantity}> <div className={styles.quantity}>
<div className={buildClassName(styles.floatingBadge, styles.floatingBadgeButtonColor)}> <div className={buildClassName(styles.floatingBadge,
styles.floatingBadgeButtonColor,
styles.floatingBadgeButton)}
>
<Icon name="boost" className={styles.floatingBadgeIcon} /> <Icon name="boost" className={styles.floatingBadgeIcon} />
<div className={styles.floatingBadgeValue} dir={lang.isRtl ? 'rtl' : undefined}> <div className={styles.floatingBadgeValue} dir={lang.isRtl ? 'rtl' : undefined}>
{prepaidGiveaway.quantity * (giveawayBoostsPerPremium ?? GIVEAWAY_BOOST_PER_PREMIUM)} {prepaidGiveaway.quantity * (giveawayBoostsPerPremium ?? GIVEAWAY_BOOST_PER_PREMIUM)}
@ -165,46 +295,52 @@ const BoostStatistics = ({
<p className="text-muted hint" key="links-hint">{lang('BoostingSelectPaidGiveaway')}</p> <p className="text-muted hint" key="links-hint">{lang('BoostingSelectPaidGiveaway')}</p>
</div> </div>
)} )}
{isChannel && ( <div className={styles.section}>
<div className={styles.section}> {shouldDisplayGiftList ? (
<h4 className={styles.sectionHeader} dir={lang.isRtl ? 'rtl' : undefined}> <div
{lang('Boosters')} className={styles.boostSection}
</h4> >
{!boostStatistics.boosterIds?.length && ( <Transition
<div className={styles.noResults}>{lang('NoBoostersHint')}</div> key={activeKey}
)} ref={transitionRef}
{boostStatistics.boosterIds?.map((userId) => ( name={lang.isRtl ? 'slideOptimizedRtl' : 'slideOptimized'}
<ListItem activeKey={activeKey}
key={userId} renderCount={tabs.length}
className="chat-item-clickable" shouldRestoreHeight
// eslint-disable-next-line react/jsx-no-bind className="shared-media-transition"
onClick={() => handleBoosterClick(userId)}
> >
<PrivateChatInfo {renderContent()}
className={styles.user} </Transition>
forceShowSelf <TabList big activeTab={renderingActiveTab} tabs={tabs} onSwitchTab={setActiveTab} />
userId={userId} </div>
status={lang('BoostExpireOn', formatDateAtTime(lang, boostStatistics.boosters![userId] * 1000))} ) : (
/> <>
</ListItem> <h4 className={styles.sectionHeader} dir={lang.isRtl ? 'rtl' : undefined}>
))} {lang('BoostingBoostsCount', boostStatistics?.boosts?.count)}
{Boolean(boostersToLoadCount) && ( </h4>
<ListItem {!boostStatistics?.boosts?.list?.length && (
key="load-more" <div className={styles.noResults}>{lang(isChannel ? 'NoBoostersHint' : 'NoBoostersGroupHint')}
className={styles.showMore} </div>
disabled={boostStatistics?.isLoadingBoosters} )}
onClick={handleLoadMore} {boostStatistics?.boosts?.list?.map((boost) => renderBoostList(boost))}
> </>
{boostStatistics?.isLoadingBoosters ? ( )}
<Spinner className={styles.loadMoreSpinner} /> {Boolean(boostersToLoadCount) && (
) : ( <ListItem
<Icon name="down" className={styles.down} /> key="load-more"
)} className={styles.showMore}
{lang('ShowVotes', boostersToLoadCount)} disabled={boostStatistics?.isLoadingBoosters}
</ListItem> onClick={handleLoadMore}
)} >
</div> {boostStatistics?.isLoadingBoosters ? (
)} <Spinner className={styles.loadMoreSpinner} />
) : (
<Icon name="down" className={styles.down} />
)}
{lang('ShowVotes', boostersToLoadCount)}
</ListItem>
)}
</div>
<LinkField className={styles.section} link={status!.boostUrl} withShare title={lang('LinkForBoosting')} /> <LinkField className={styles.section} link={status!.boostUrl} withShare title={lang('LinkForBoosting')} />
{isGiveawayAvailable && ( {isGiveawayAvailable && (
<div className={styles.section}> <div className={styles.section}>

View File

@ -17,7 +17,6 @@
&.big { &.big {
font-size: 1rem; font-size: 1rem;
--border-radius-messages-small: 0;
} }
&::-webkit-scrollbar { &::-webkit-scrollbar {

View File

@ -5,7 +5,7 @@ import { PaymentStep } from '../../../types';
import { DEBUG_PAYMENT_SMART_GLOCAL } from '../../../config'; import { DEBUG_PAYMENT_SMART_GLOCAL } from '../../../config';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { buildCollectionByKey, unique } from '../../../util/iteratees'; import { buildCollectionByKey } from '../../../util/iteratees';
import * as langProvider from '../../../util/langProvider'; import * as langProvider from '../../../util/langProvider';
import { getStripeError } from '../../../util/payments/stripe'; import { getStripeError } from '../../../util/payments/stripe';
import { buildQueryString } from '../../../util/requestQuery'; import { buildQueryString } from '../../../util/requestQuery';
@ -578,13 +578,15 @@ addActionHandler('openBoostStatistics', async (global, actions, payload): Promis
}, tabId); }, tabId);
setGlobal(global); setGlobal(global);
const [boostsListResult, boostStatusResult] = await Promise.all([ const [boostListResult, boostListGiftResult,
boostStatusResult] = await Promise.all([
callApi('fetchBoostList', { chat }), callApi('fetchBoostList', { chat }),
callApi('fetchBoostList', { chat, isGifts: true }),
callApi('fetchBoostStatus', { chat }), callApi('fetchBoostStatus', { chat }),
]); ]);
global = getGlobal(); global = getGlobal();
if (!boostsListResult || !boostStatusResult) { if (!boostListResult || !boostListGiftResult || !boostStatusResult) {
global = updateTabState(global, { global = updateTabState(global, {
boostStatistics: undefined, boostStatistics: undefined,
}, tabId); }, tabId);
@ -592,22 +594,28 @@ addActionHandler('openBoostStatistics', async (global, actions, payload): Promis
return; return;
} }
global = addUsers(global, buildCollectionByKey(boostsListResult.users, 'id')); const totalBoostUserList = [...boostListResult.users, ...boostListGiftResult.users];
global = addUsers(global, buildCollectionByKey(totalBoostUserList, 'id'));
global = updateTabState(global, { global = updateTabState(global, {
boostStatistics: { boostStatistics: {
chatId, chatId,
boostStatus: boostStatusResult, boostStatus: boostStatusResult,
boosters: boostsListResult.boosters, nextOffset: boostListResult.nextOffset,
boosterIds: boostsListResult.boosterIds, boosts: {
count: boostsListResult.count, count: boostListResult.count,
nextOffset: boostsListResult.nextOffset, list: boostListResult.boostList,
},
giftedBoosts: {
count: boostListGiftResult?.count,
list: boostListGiftResult?.boostList,
},
}, },
}, tabId); }, tabId);
setGlobal(global); setGlobal(global);
}); });
addActionHandler('loadMoreBoosters', async (global, actions, payload): Promise<void> => { addActionHandler('loadMoreBoosters', async (global, actions, payload): Promise<void> => {
const { tabId = getCurrentTabId() } = payload || {}; const { isGifts, tabId = getCurrentTabId() } = payload || {};
let tabState = selectTabState(global, tabId); let tabState = selectTabState(global, tabId);
if (!tabState.boostStatistics) return; if (!tabState.boostStatistics) return;
@ -625,6 +633,7 @@ addActionHandler('loadMoreBoosters', async (global, actions, payload): Promise<v
const result = await callApi('fetchBoostList', { const result = await callApi('fetchBoostList', {
chat, chat,
offset: tabState.boostStatistics.nextOffset, offset: tabState.boostStatistics.nextOffset,
isGifts,
}); });
if (!result) return; if (!result) return;
@ -634,17 +643,19 @@ addActionHandler('loadMoreBoosters', async (global, actions, payload): Promise<v
tabState = selectTabState(global, tabId); tabState = selectTabState(global, tabId);
if (!tabState.boostStatistics) return; if (!tabState.boostStatistics) return;
const updatedBoostList = (isGifts
? tabState.boostStatistics.giftedBoosts?.list || []
: tabState.boostStatistics.boosts?.list || []).concat(result.boostList);
global = updateTabState(global, { global = updateTabState(global, {
boostStatistics: { boostStatistics: {
...tabState.boostStatistics, ...tabState.boostStatistics,
boosters: {
...tabState.boostStatistics.boosters,
...result.boosters,
},
boosterIds: unique([...tabState.boostStatistics.boosterIds || [], ...result.boosterIds]),
count: result.count,
nextOffset: result.nextOffset, nextOffset: result.nextOffset,
isLoadingBoosters: false, isLoadingBoosters: false,
[isGifts ? 'giftedBoosts' : 'boosts']: {
count: result.count,
list: updatedBoostList,
},
}, },
}, tabId); }, tabId);
setGlobal(global); setGlobal(global);

View File

@ -3,6 +3,7 @@ import type {
ApiAttachBot, ApiAttachBot,
ApiAttachment, ApiAttachment,
ApiAvailableReaction, ApiAvailableReaction,
ApiBoost,
ApiBoostsStatus, ApiBoostsStatus,
ApiChannelStatistics, ApiChannelStatistics,
ApiChat, ApiChat,
@ -694,12 +695,17 @@ export type TabState = {
boostStatistics?: { boostStatistics?: {
chatId: string; chatId: string;
boosters?: Record<string, number>;
boosterIds?: string[];
boostStatus?: ApiBoostsStatus; boostStatus?: ApiBoostsStatus;
isLoadingBoosters?: boolean; isLoadingBoosters?: boolean;
nextOffset?: string; nextOffset?: string;
count?: number; boosts?: {
count: number;
list: ApiBoost[];
};
giftedBoosts?: {
count: number;
list: ApiBoost[];
};
}; };
giftCodeModal?: { giftCodeModal?: {
@ -2356,7 +2362,7 @@ export interface ActionPayloads {
chatId: string; chatId: string;
} & WithTabId; } & WithTabId;
closeBoostStatistics: WithTabId | undefined; closeBoostStatistics: WithTabId | undefined;
loadMoreBoosters: WithTabId | undefined; loadMoreBoosters: { isGifts?: boolean } & WithTabId | undefined;
applyBoost: { applyBoost: {
slots: number[]; slots: number[];
chatId: string; chatId: string;

View File

@ -30,6 +30,8 @@ $color-error: #e53935;
$color-warning: #fb8c00; $color-warning: #fb8c00;
$color-yellow: #fdd764; $color-yellow: #fdd764;
$color-orange: #d08a31;
$color-light-coral: #d08a3133;
$color-white: #ffffff; $color-white: #ffffff;
$color-black: #000000; $color-black: #000000;
@ -115,6 +117,9 @@ $color-message-story-mention-to: #74bcff;
--color-yellow: #{$color-yellow}; --color-yellow: #{$color-yellow};
--color-orange: #{$color-orange};
--color-light-coral: #{$color-light-coral};
--color-links: #{$color-links}; --color-links: #{$color-links};
--color-own-links: #{$color-white}; --color-own-links: #{$color-white};