Boosts: Support admin view (#3912)

This commit is contained in:
Alexander Zinchuk 2023-10-27 12:50:04 +02:00
parent 3e59a07012
commit dd2bd5ddfe
42 changed files with 581 additions and 128 deletions

View File

@ -163,6 +163,8 @@ function buildStatisticsOverview({ current, previous }: GramJs.StatsAbsValueAndP
export function buildStatisticsPercentage(data: GramJs.StatsPercentValue): StatisticsOverviewPercentage {
return {
part: data.part,
total: data.total,
percentage: ((data.part / data.total) * 100).toFixed(2),
};
}

View File

@ -3,7 +3,12 @@ import { Api as GramJs, errors } from '../../../lib/gramjs';
import type {
ApiApplyBoostInfo,
ApiBoostsStatus,
ApiMediaArea, ApiMediaAreaCoordinates, ApiMessage, ApiStealthMode, ApiStoryView, ApiTypeStory,
ApiMediaArea,
ApiMediaAreaCoordinates,
ApiMessage,
ApiStealthMode,
ApiStoryView,
ApiTypeStory,
} from '../../types';
import { buildCollectionByCallback } from '../../../util/iteratees';
@ -215,6 +220,6 @@ export function buildApiBoostsStatus(boostStatus: GramJs.stories.BoostsStatus):
hasMyBoost: Boolean(myBoost),
boostUrl,
nextLevelBoosts,
...(premiumAudience && { premiumAudience: buildStatisticsPercentage(premiumAudience) }),
...(premiumAudience && { premiumSubscribers: buildStatisticsPercentage(premiumAudience) }),
};
}

View File

@ -494,3 +494,40 @@ export async function fetchBoostsStatus({
return buildApiBoostsStatus(result);
}
export async function fetchBoostersList({
chat,
offset = '',
limit,
}: {
chat: ApiChat;
offset?: string;
limit?: number;
}) {
const result = await invokeRequest(new GramJs.stories.GetBoostersList({
peer: buildInputPeer(chat.id, chat.accessHash),
offset,
limit,
}));
if (!result) {
return undefined;
}
addEntitiesToLocalDb(result.users);
const users = result.users.map(buildApiUser).filter(Boolean);
const boosterIds = result.boosters.map((booster) => booster.userId.toString());
const boosters = buildCollectionByCallback(result.boosters, (booster) => (
[booster.userId.toString(), booster.expires]
));
return {
count: result.count,
users,
boosters,
boosterIds,
nextOffset: result.nextOffset,
};
}

View File

@ -39,6 +39,13 @@ export interface ApiMessageStatistics {
publicForwardsData?: ApiMessagePublicForward[];
}
export interface ApiBoostStatistics {
level: number;
boosts: number;
premiumSubscribers: StatisticsOverviewPercentage;
remainingBoosts: number;
}
export interface ApiMessagePublicForward {
messageId: number;
views?: number;
@ -76,6 +83,8 @@ export interface StatisticsOverviewItem {
}
export interface StatisticsOverviewPercentage {
part: number;
total: number;
percentage: string;
}

View File

@ -137,5 +137,5 @@ export type ApiBoostsStatus = {
nextLevelBoosts?: number;
hasMyBoost?: boolean;
boostUrl: string;
premiumAudience?: StatisticsOverviewPercentage;
premiumSubscribers?: StatisticsOverviewPercentage;
};

View File

@ -17,12 +17,7 @@
z-index: 1;
}
.buttons {
display: flex;
gap: 1rem;
}
.button {
width: auto;
flex: 1 0 auto;
.title {
font-weight: 500;
color: var(--color-text-secondary);
}

View File

@ -18,15 +18,17 @@ import styles from './InviteLink.module.scss';
type OwnProps = {
title?: string;
inviteLink: string;
onRevoke?: VoidFunction;
isDisabled?: boolean;
className?: string;
onRevoke?: VoidFunction;
};
const InviteLink: FC<OwnProps> = ({
title,
inviteLink,
onRevoke,
isDisabled,
className,
onRevoke,
}) => {
const lang = useLang();
const { showNotification, openChatWithDraft } = getActions();
@ -66,8 +68,8 @@ const InviteLink: FC<OwnProps> = ({
}, [isMobile, lang]);
return (
<div className="settings-item">
<p className="text-muted">
<div className={className}>
<p className={styles.title}>
{lang(title || 'InviteLink.InviteLink')}
</p>
<div className={styles.primaryLink}>
@ -88,24 +90,13 @@ const InviteLink: FC<OwnProps> = ({
)}
</DropdownMenu>
</div>
<div className={styles.buttons}>
<Button
onClick={handleCopyPrimaryClicked}
className={styles.button}
size="smaller"
disabled={isDisabled}
>
{lang('FolderLinkScreen.LinkActionCopy')}
</Button>
<Button
onClick={handleShare}
className={styles.button}
size="smaller"
disabled={isDisabled}
>
{lang('FolderLinkScreen.LinkActionShare')}
</Button>
</div>
<Button
size="smaller"
disabled={isDisabled}
onClick={handleShare}
>
{lang('FolderLinkScreen.LinkActionShare')}
</Button>
</div>
);
};

View File

@ -1,8 +1,10 @@
@import "../../styles/mixins";
.container {
background-color: var(--color-background);
padding: 1.5rem 1.5rem 0;
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
margin-bottom: 0.625rem;
@include side-panel-section;
}
.header {

View File

@ -36,16 +36,12 @@
}
}
.floating-badge {
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
position: relative;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
background-color: #7E85FF;
.floating-badge-wrapper {
animation: rotate-in 0.5s ease-in-out;
border-radius: 0.5rem;
height: 2.6875rem;
position: relative;
overflow: hidden;
}
@keyframes rotate-in {
@ -63,9 +59,24 @@
}
}
.floating-badge {
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
background-color: #7E85FF;
position: relative;
z-index: 1;
}
.floating-badge-triangle {
display: inline-block;
position: absolute;
bottom: -15px;
bottom: -5px;
left: calc(var(--tail-position, 0.5) * 100%);
transform: translateX(-50%);
}
.floating-badge-icon {

View File

@ -21,6 +21,8 @@ type OwnProps = {
className?: string;
};
const PROGRESS_LOCK = 0.1;
const LimitPreview: FC<OwnProps> = ({
leftText,
rightText,
@ -34,6 +36,8 @@ const LimitPreview: FC<OwnProps> = ({
const hasFloatingBadge = Boolean(floatingBadgeIcon || floatingBadgeText);
const isProgressFull = Boolean(progress) && progress > 0.99;
const tailPosition = progress && (progress < PROGRESS_LOCK ? 0 : progress > 1 - PROGRESS_LOCK ? 1 : 0.5);
return (
<div
className={buildClassName(
@ -41,18 +45,25 @@ const LimitPreview: FC<OwnProps> = ({
hasFloatingBadge && styles.withBadge,
className,
)}
style={buildStyle(progress !== undefined && `--progress: ${progress}`)}
style={buildStyle(
progress !== undefined && `--progress: ${progress}`,
tailPosition !== undefined && `--tail-position: ${tailPosition}`,
)}
>
{hasFloatingBadge && (
<div className={styles.badgeContainer}>
<div className={styles.floatingBadge}>
{floatingBadgeIcon && <Icon name={floatingBadgeIcon} className={styles.floatingBadgeIcon} />}
{floatingBadgeText && (
<div className={styles.floatingBadgeValue} dir={lang.isRtl ? 'rtl' : undefined}>{floatingBadgeText}</div>
)}
<div className={styles.floatingBadgeWrapper}>
<div className={styles.floatingBadge}>
{floatingBadgeIcon && <Icon name={floatingBadgeIcon} className={styles.floatingBadgeIcon} />}
{floatingBadgeText && (
<div className={styles.floatingBadgeValue} dir={lang.isRtl ? 'rtl' : undefined}>
{floatingBadgeText}
</div>
)}
</div>
<div className={styles.floatingBadgeTriangle}>
<svg width="26" height="9" viewBox="0 0 26 9" fill="none">
<path d="M0 0H26H24.4853C22.894 0 21.3679 0.632141 20.2426 1.75736L14.4142 7.58579C13.6332 8.36684 12.3668 8.36683 11.5858 7.58579L5.75736 1.75736C4.63214 0.632139 3.10602 0 1.51472 0H0Z" fill="#7E85FF" />
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<path d="m 28,4 v 9 c 0.0089,7.283278 -3.302215,5.319646 -6.750951,8.589815 l -5.8284,5.82843 c -0.781,0.78105 -2.0474,0.78104 -2.8284,0 L 6.7638083,21.589815 C 2.8288652,17.959047 0.04527024,20.332086 0,13 V 4 C 0,4 0.00150581,0.97697493 3,1 5.3786658,1.018266 22.594519,0.9142007 25,1 c 2.992326,0.1067311 3,3 3,3 z" fill="#7E85FF" />
</svg>
</div>
</div>

View File

@ -43,6 +43,7 @@ type OwnProps = {
noStatusOrTyping?: boolean;
noRtl?: boolean;
adminMember?: ApiChatMember;
className?: string;
onEmojiStatusClick?: NoneToVoidFunction;
};
@ -75,8 +76,9 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
areMessagesLoaded,
adminMember,
ripple,
onEmojiStatusClick,
className,
storyViewerOrigin,
onEmojiStatusClick,
}) => {
const {
loadFullUser,
@ -180,7 +182,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
}
return (
<div className="ChatInfo" dir={!noRtl && lang.isRtl ? 'rtl' : undefined}>
<div className={buildClassName('ChatInfo', className)} dir={!noRtl && lang.isRtl ? 'rtl' : undefined}>
<Avatar
key={user.id}
size={avatarSize}

View File

@ -1,6 +1,6 @@
import type { ApiBoostsStatus } from '../../../api/types';
export function getBoostProgressInfo(boostInfo: ApiBoostsStatus) {
export function getBoostProgressInfo(boostInfo: ApiBoostsStatus, freezeOnLevelUp?: boolean) {
const {
level, boosts, currentLevelBoosts, nextLevelBoosts, hasMyBoost,
} = boostInfo;
@ -8,7 +8,7 @@ export function getBoostProgressInfo(boostInfo: ApiBoostsStatus) {
const currentLevel = level;
const hasNextLevel = Boolean(nextLevelBoosts);
const isJustUpgraded = boosts === currentLevelBoosts && hasMyBoost;
const isJustUpgraded = freezeOnLevelUp && boosts === currentLevelBoosts && hasMyBoost;
const levelProgress = (!nextLevelBoosts || isJustUpgraded) ? 1
: (boosts - currentLevelBoosts) / (nextLevelBoosts - currentLevelBoosts);

View File

@ -1,3 +1,5 @@
@import "../../../styles/mixins";
#Settings {
height: 100%;
@ -78,10 +80,10 @@
align-items: center;
padding: 0 1.5rem 1rem;
text-align: center;
background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
margin-bottom: 0.625rem;
@include side-panel-section;
&.no-border {
margin-bottom: 0;
box-shadow: none;
@ -107,20 +109,12 @@
.settings-main-menu {
padding: 0.5rem;
background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
border-bottom: 0.625rem solid var(--color-background-secondary);
&:last-child {
border-bottom: none;
box-shadow: none;
}
@include side-panel-section;
> .ChatExtra {
padding: 0 0.5rem 0.3125rem;
margin: 0 -0.5rem 0.625rem;
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
border-bottom: 0.625rem solid var(--color-background-secondary);
@include side-panel-section;
.ListItem.narrow {
margin-bottom: 0.25rem;
@ -130,15 +124,8 @@
.settings-item-simple,
.settings-item {
background-color: var(--color-background);
padding: 1.5rem 1.5rem 1rem;
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
border-bottom: 0.625rem solid var(--color-background-secondary);
&:last-child {
border-bottom: none;
box-shadow: none;
}
@include side-panel-section;
}
.settings-item {

View File

@ -1,11 +1,12 @@
@import "../../../styles/mixins";
.SettingsGeneralBackground {
.settings-wallpapers {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 1fr;
grid-gap: 0.0625rem;
background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
@include side-panel-section;
}
.Loading {

View File

@ -1,3 +1,5 @@
@import "../../../styles/mixins";
.SettingsGeneralBackgroundColor {
&:not(.is-dragging) .handle {
transition: transform 300ms ease;
@ -69,8 +71,7 @@
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 1fr;
grid-gap: 0.0625rem;
background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
@include side-panel-section;
}
.predefined-color {

View File

@ -160,6 +160,7 @@ const SettingsShareChatlist: FC<OwnProps & StateProps> = ({
</div>
<InviteLink
className="settings-item"
inviteLink={!url ? lang('Loading') : url}
onRevoke={handleRevoke}
isDisabled={!chatsCount || isTouched}

View File

@ -11,6 +11,7 @@ import { ManagementScreens } from '../../types';
import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterdom';
import {
getHasAdminRight,
isChatBasicGroup, isChatChannel, isChatSuperGroup, isUserId,
} from '../../global/helpers';
import {
@ -63,6 +64,7 @@ interface StateProps {
canCall?: boolean;
canMute?: boolean;
canViewStatistics?: boolean;
canViewBoosts?: boolean;
canLeave?: boolean;
canEnterVoiceChat?: boolean;
canCreateVoiceChat?: boolean;
@ -95,6 +97,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
canCall,
canMute,
canViewStatistics,
canViewBoosts,
canLeave,
canEnterVoiceChat,
canCreateVoiceChat,
@ -425,6 +428,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
canCall={canCall}
canMute={canMute}
canViewStatistics={canViewStatistics}
canViewBoosts={canViewBoosts}
canLeave={canLeave}
canEnterVoiceChat={canEnterVoiceChat}
canCreateVoiceChat={canCreateVoiceChat}
@ -486,6 +490,7 @@ export default memo(withGlobal<OwnProps>(
const canCreateVoiceChat = ARE_CALLS_SUPPORTED && isMainThread && !chat.isCallActive
&& (chat.adminRights?.manageCall || (chat.isCreator && isChatBasicGroup(chat)));
const canViewStatistics = isMainThread && chatFullInfo?.canViewStatistics;
const canViewBoosts = isMainThread && isChannel && (canViewStatistics || getHasAdminRight(chat, 'postStories'));
const pendingJoinRequests = isMainThread ? chatFullInfo?.requestsPending : undefined;
const shouldJoinToSend = Boolean(chat?.isNotJoined && chat.isJoinToSend);
const shouldSendJoinRequest = Boolean(chat?.isNotJoined && chat.isJoinRequest);
@ -505,6 +510,7 @@ export default memo(withGlobal<OwnProps>(
canCall,
canMute,
canViewStatistics,
canViewBoosts,
canLeave,
canEnterVoiceChat,
canCreateVoiceChat,

View File

@ -81,6 +81,7 @@ export type OwnProps = {
canCall?: boolean;
canMute?: boolean;
canViewStatistics?: boolean;
canViewBoosts?: boolean;
withForumActions?: boolean;
canLeave?: boolean;
canEnterVoiceChat?: boolean;
@ -137,6 +138,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
canCall,
canMute,
canViewStatistics,
canViewBoosts,
pendingJoinRequests,
canLeave,
canEnterVoiceChat,
@ -174,6 +176,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
openAddContactDialog,
requestMasterAndRequestCall,
toggleStatistics,
openBoostStatistics,
openGiftPremiumModal,
openChatWithInfo,
openCreateTopicPanel,
@ -332,6 +335,12 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu();
});
const handleBoostClick = useLastCallback(() => {
openBoostStatistics({ chatId });
setShouldCloseFast(!isRightColumnShown);
closeMenu();
});
const handleEnableTranslations = useLastCallback(() => {
togglePeerTranslations({ chatId, isEnabled: true });
closeMenu();
@ -546,6 +555,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
{lang('ReportSelectMessages')}
</MenuItem>
)}
{canViewBoosts && (
<MenuItem
icon="boost"
onClick={handleBoostClick}
>
{lang('Boosts')}
</MenuItem>
)}
{canViewStatistics && (
<MenuItem
icon="stats"

View File

@ -114,7 +114,7 @@ const BoostModal = ({
hasNextLevel,
levelProgress,
remainingBoosts,
} = getBoostProgressInfo(info.boostStatus);
} = getBoostProgressInfo(info.boostStatus, true);
const hasBoost = hasMyBoost || info.applyInfo?.type === 'already';
const isJustUpgraded = boosts === currentLevelBoosts && hasBoost;

View File

@ -1,3 +1,5 @@
@import '../../styles/mixins';
.root {
position: relative;
height: 100%;
@ -17,9 +19,7 @@
justify-content: center;
flex-direction: column;
background-color: var(--color-background);
border-bottom: 0.625rem solid var(--color-background-secondary);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
@include side-panel-section;
}
.general {

View File

@ -18,8 +18,8 @@ import PollAnswerResults from './PollAnswerResults';
import './PollResults.scss';
type OwnProps = {
onClose: NoneToVoidFunction;
isActive: boolean;
onClose: NoneToVoidFunction;
};
type StateProps = {
@ -28,10 +28,10 @@ type StateProps = {
};
const PollResults: FC<OwnProps & StateProps> = ({
onClose,
isActive,
chat,
message,
onClose,
}) => {
const lang = useLang();

View File

@ -1,3 +1,5 @@
@import '../../styles/mixins';
.Profile {
height: 100%;
display: flex;
@ -16,8 +18,7 @@
> .profile-info > .ChatExtra {
padding: 0.875rem 0.5rem 0.5rem;
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
border-bottom: 0.625rem solid var(--color-background-secondary);
@include side-panel-section;
.narrow {
margin-bottom: 0;

View File

@ -30,6 +30,7 @@ import PollResults from './PollResults.async';
import Profile from './Profile';
import RightHeader from './RightHeader';
import RightSearch from './RightSearch.async';
import BoostStatistics from './statistics/BoostStatistics';
import MessageStatistics from './statistics/MessageStatistics.async';
import Statistics from './statistics/Statistics.async';
import StickerSearch from './StickerSearch.async';
@ -90,6 +91,7 @@ const RightColumn: FC<OwnProps & StateProps> = ({
resetNextProfileTab,
closeCreateTopicPanel,
closeEditTopicPanel,
closeBoostStatistics,
} = getActions();
const { width: windowWidth } = useWindowSize();
@ -105,6 +107,7 @@ const RightColumn: FC<OwnProps & StateProps> = ({
const isManagement = contentKey === RightColumnContent.Management;
const isStatistics = contentKey === RightColumnContent.Statistics;
const isMessageStatistics = contentKey === RightColumnContent.MessageStatistics;
const isBoostStatistics = contentKey === RightColumnContent.BoostStatistics;
const isStickerSearch = contentKey === RightColumnContent.StickerSearch;
const isGifSearch = contentKey === RightColumnContent.GifSearch;
const isPollResults = contentKey === RightColumnContent.PollResults;
@ -176,6 +179,9 @@ const RightColumn: FC<OwnProps & StateProps> = ({
case RightColumnContent.Statistics:
toggleStatistics();
break;
case RightColumnContent.BoostStatistics:
closeBoostStatistics();
break;
case RightColumnContent.Search: {
blurSearchInput();
closeLocalTextSearch();
@ -312,6 +318,8 @@ const RightColumn: FC<OwnProps & StateProps> = ({
case RightColumnContent.Statistics:
return <Statistics chatId={chatId!} />;
case RightColumnContent.BoostStatistics:
return <BoostStatistics />;
case RightColumnContent.MessageStatistics:
return <MessageStatistics chatId={chatId!} isActive={isOpen && isActive} />;
case RightColumnContent.StickerSearch:
@ -346,6 +354,7 @@ const RightColumn: FC<OwnProps & StateProps> = ({
isSearch={isSearch}
isManagement={isManagement}
isStatistics={isStatistics}
isBoostStatistics={isBoostStatistics}
isMessageStatistics={isMessageStatistics}
isStickerSearch={isStickerSearch}
isGifSearch={isGifSearch}

View File

@ -46,6 +46,7 @@ type OwnProps = {
isSearch?: boolean;
isManagement?: boolean;
isStatistics?: boolean;
isBoostStatistics?: boolean;
isMessageStatistics?: boolean;
isStickerSearch?: boolean;
isGifSearch?: boolean;
@ -88,6 +89,7 @@ enum HeaderContent {
Search,
Statistics,
MessageStatistics,
BoostStatistics,
Management,
ManageInitial,
ManageChannelSubscribers,
@ -126,6 +128,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
isManagement,
isStatistics,
isMessageStatistics,
isBoostStatistics,
isStickerSearch,
isGifSearch,
isPollResults,
@ -139,8 +142,6 @@ const RightHeader: FC<OwnProps & StateProps> = ({
isSelf,
canManage,
isChannel,
onClose,
onScreenSelect,
messageSearchQuery,
stickerSearchQuery,
gifSearchQuery,
@ -151,6 +152,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
isBot,
isInsideTopic,
canEditTopic,
onClose,
onScreenSelect,
}) => {
const {
setLocalTextSearchQuery,
@ -288,6 +291,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
HeaderContent.Statistics
) : isMessageStatistics ? (
HeaderContent.MessageStatistics
) : isBoostStatistics ? (
HeaderContent.BoostStatistics
) : isCreatingTopic ? (
HeaderContent.CreateTopic
) : isEditingTopic ? (
@ -437,6 +442,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
return <h3>{lang(isChannel ? 'ChannelStats.Title' : 'GroupStats.Title')}</h3>;
case HeaderContent.MessageStatistics:
return <h3>{lang('Stats.MessageTitle')}</h3>;
case HeaderContent.BoostStatistics:
return <h3>{lang('Boosts')}</h3>;
case HeaderContent.SharedMedia:
return <h3>{lang('SharedMedia')}</h3>;
case HeaderContent.ManageChannelSubscribers:

View File

@ -284,6 +284,7 @@ const ManageInvites: FC<OwnProps & StateProps> = ({
</div>
{primaryInviteLink && (
<InviteLink
className="section"
inviteLink={primaryInviteLink}
onRevoke={!chat?.usernames ? handlePrimaryRevoke : undefined}
title={chat?.usernames ? lang('PublicLink') : lang('lng_create_permanent_link_title')}

View File

@ -1,3 +1,5 @@
@import "../../../styles/mixins";
.Management {
position: relative;
height: 100%;
@ -15,14 +17,7 @@
.section {
padding: 1rem 1.5rem;
background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
border-bottom: 0.625rem solid var(--color-background-secondary);
&:last-child {
border-bottom: none;
box-shadow: none;
}
@include side-panel-section;
&.wide {
padding: 1.5rem;
@ -168,10 +163,10 @@
&__filter {
padding: 0 1rem 0.25rem 0.75rem;
background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
margin-bottom: 0.625rem;
@include side-panel-section;
display: flex;
flex-flow: row wrap;
flex-shrink: 0;
@ -341,10 +336,10 @@
}
.part {
background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
margin: 0 -1.5rem;
padding: 0 1.5rem 1rem;
@include side-panel-section;
}
.section, .part {

View File

@ -0,0 +1,40 @@
@import '../../../styles/mixins';
.root {
overflow-y: scroll;
overflow-x: hidden;
}
.noResults {
color: var(--color-text-secondary);
text-align: center;
font-size: 0.9375rem;
}
.section-header {
font-weight: 500;
color: var(--color-text-secondary);
}
.section {
padding: 1rem;
@include side-panel-section;
}
.user :global(.status) {
overflow: hidden;
}
.stats {
margin-bottom: 0;
border: none;
padding: 0.75rem 0 0 0;
}
.down {
font-size: 1.5rem;
}
.loadMoreSpinner {
margin-inline-end: 1rem;
}

View File

@ -0,0 +1,154 @@
import React, { memo, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiBoostStatistics } from '../../../api/types';
import type { TabState } from '../../../global/types';
import { selectTabState } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { formatDateAtTime } from '../../../util/dateFormat';
import { getBoostProgressInfo } from '../../common/helpers/boostInfo';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Icon from '../../common/Icon';
import InviteLink from '../../common/InviteLink';
import PremiumProgress from '../../common/PremiumProgress';
import PrivateChatInfo from '../../common/PrivateChatInfo';
import ListItem from '../../ui/ListItem';
import Loading from '../../ui/Loading';
import Spinner from '../../ui/Spinner';
import StatisticsOverview from './StatisticsOverview';
import styles from './BoostStatistics.module.scss';
type StateProps = {
boostStatistics: TabState['boostStatistics'];
};
const BoostStatistics = ({
boostStatistics,
}: StateProps) => {
const { openChat, loadMoreBoosters, closeBoostStatistics } = getActions();
const lang = useLang();
const isLoaded = boostStatistics?.boostStatus;
const status = isLoaded ? boostStatistics.boostStatus : undefined;
const {
currentLevel,
hasNextLevel,
boosts,
levelProgress,
remainingBoosts,
} = useMemo(() => {
if (!status) {
return {
currentLevel: 0,
hasNextLevel: false,
boosts: 0,
levelProgress: 0,
remainingBoosts: 0,
};
}
return getBoostProgressInfo(status);
}, [status]);
const statsOverview = useMemo(() => {
if (!status) return undefined;
return {
level: currentLevel,
boosts,
premiumSubscribers: status.premiumSubscribers!,
remainingBoosts,
} satisfies ApiBoostStatistics;
}, [status, boosts, currentLevel, remainingBoosts]);
const boostersToLoadCount = useMemo(() => {
if (!boostStatistics?.count) return undefined;
const loadedCount = boostStatistics.boosterIds?.length || 0;
const totalCount = boostStatistics.count;
return totalCount - loadedCount;
}, [boostStatistics]);
const handleBoosterClick = useLastCallback((userId: string) => {
openChat({ id: userId });
closeBoostStatistics();
});
const handleLoadMore = useLastCallback(() => {
loadMoreBoosters();
});
return (
<div className={buildClassName(styles.root, 'custom-scroll')}>
{!isLoaded && <Loading />}
{isLoaded && statsOverview && (
<>
<div className={styles.section}>
<PremiumProgress
leftText={lang('BoostsLevel', currentLevel!)}
rightText={hasNextLevel ? lang('BoostsLevel', currentLevel! + 1) : undefined}
progress={levelProgress}
floatingBadgeText={boosts.toString()}
floatingBadgeIcon="boost"
/>
<StatisticsOverview className={styles.stats} statistics={statsOverview} type="boost" />
</div>
<div className={styles.section}>
<h4 className={styles.sectionHeader} dir={lang.isRtl ? 'rtl' : undefined}>
{lang('Boosters')}
</h4>
{!boostStatistics.boosterIds?.length && (
<div className={styles.noResults}>{lang('NoBoostersHint')}</div>
)}
{boostStatistics.boosterIds?.map((userId) => (
<ListItem
key={userId}
className="chat-item-clickable"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleBoosterClick(userId)}
>
<PrivateChatInfo
className={styles.user}
forceShowSelf
userId={userId}
status={lang('BoostExpireOn', formatDateAtTime(lang, boostStatistics.boosters![userId] * 1000))}
/>
</ListItem>
))}
{Boolean(boostersToLoadCount) && (
<ListItem
key="load-more"
className={styles.showMore}
disabled={boostStatistics?.isLoadingBoosters}
onClick={handleLoadMore}
>
{boostStatistics?.isLoadingBoosters ? (
<Spinner className={styles.loadMoreSpinner} />
) : (
<Icon name="down" className={styles.down} />
)}
{lang('ShowVotes', boostersToLoadCount)}
</ListItem>
)}
</div>
<InviteLink className={styles.section} inviteLink={status!.boostUrl} title={lang('LinkForBoosting')} />
</>
)}
</div>
);
};
export default memo(withGlobal(
(global): StateProps => {
const tabState = selectTabState(global);
const boostStatistics = tabState.boostStatistics;
return {
boostStatistics,
};
},
)(BoostStatistics));

View File

@ -150,7 +150,7 @@ const Statistics: FC<OwnProps & StateProps> = ({
return (
<div className={buildClassName('Statistics custom-scroll', isReady && 'ready')}>
<StatisticsOverview statistics={statistics} isMessage />
<StatisticsOverview statistics={statistics} type="message" title={lang('StatisticOverview')} />
{!loadedCharts.current.length && <Loading />}

View File

@ -178,7 +178,11 @@ const Statistics: FC<OwnProps & StateProps> = ({
return (
<div className={buildClassName('Statistics custom-scroll', isReady && 'ready')}>
<StatisticsOverview statistics={statistics} isGroup={isGroup} />
<StatisticsOverview
statistics={statistics}
type={isGroup ? 'group' : 'channel'}
title={lang('StatisticOverview')}
/>
{!loadedCharts.current.length && <Loading />}

View File

@ -42,7 +42,13 @@
&-value {
font-weight: 500;
font-size: 1rem;
font-size: 1.25rem;
}
&-secondary-value {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin-inline-start: 0.25rem;
}
}

View File

@ -2,6 +2,7 @@ import type { FC } from '../../../lib/teact/teact';
import React, { memo } from '../../../lib/teact/teact';
import type {
ApiBoostStatistics,
ApiChannelStatistics, ApiGroupStatistics, ApiMessageStatistics, StatisticsOverviewItem,
} from '../../../api/types';
@ -17,6 +18,7 @@ type OverviewCell = {
name: string;
title: string;
isPercentage?: boolean;
withAbsoluteValue?: boolean;
isPlain?: boolean;
isApproximate?: boolean;
};
@ -55,13 +57,38 @@ const MESSAGE_OVERVIEW: OverviewCell[][] = [
],
];
const BOOST_OVERVIEW: OverviewCell[][] = [
[
{ name: 'level', title: 'Stats.Boosts.Level', isPlain: true },
{
name: 'premiumSubscribers',
title: 'Stats.Boosts.PremiumSubscribers',
isPercentage: true,
isApproximate: true,
withAbsoluteValue: true,
},
],
[
{ name: 'boosts', title: 'Stats.Boosts.ExistingBoosts', isPlain: true },
{ name: 'remainingBoosts', title: 'Stats.Boosts.BoostsToLevelUp', isPlain: true },
],
];
type StatisticsType = 'channel' | 'group' | 'message' | 'boost';
export type OwnProps = {
isGroup?: boolean;
isMessage?: boolean;
statistics: ApiChannelStatistics | ApiGroupStatistics | ApiMessageStatistics;
type: StatisticsType;
title?: string;
className?: string;
statistics: ApiChannelStatistics | ApiGroupStatistics | ApiMessageStatistics | ApiBoostStatistics;
};
const StatisticsOverview: FC<OwnProps> = ({ isGroup, isMessage, statistics }) => {
const StatisticsOverview: FC<OwnProps> = ({
title,
type,
statistics,
className,
}) => {
const lang = useLang();
const renderOverviewItemValue = ({ change, percentage }: StatisticsOverviewItem) => {
@ -86,10 +113,17 @@ const StatisticsOverview: FC<OwnProps> = ({ isGroup, isMessage, statistics }) =>
const { period } = (statistics as ApiGroupStatistics);
const schema = type === 'boost' ? BOOST_OVERVIEW : type === 'message' ? MESSAGE_OVERVIEW : type === 'group'
? GROUP_OVERVIEW : CHANNEL_OVERVIEW;
return (
<div className="StatisticsOverview">
<div className={buildClassName('StatisticsOverview', className)}>
<div className="StatisticsOverview__header">
<div className="StatisticsOverview__title">{lang('StatisticOverview')}</div>
{title && (
<div className="StatisticsOverview__title">
{title}
</div>
)}
{period && (
<div className="StatisticsOverview__caption">
@ -99,7 +133,7 @@ const StatisticsOverview: FC<OwnProps> = ({ isGroup, isMessage, statistics }) =>
</div>
<table className="StatisticsOverview__table">
{(isMessage ? MESSAGE_OVERVIEW : isGroup ? GROUP_OVERVIEW : CHANNEL_OVERVIEW).map((row) => (
{schema.map((row) => (
<tr>
{row.map((cell: OverviewCell) => {
const field = (statistics as any)[cell.name];
@ -108,7 +142,7 @@ const StatisticsOverview: FC<OwnProps> = ({ isGroup, isMessage, statistics }) =>
return (
<td className="StatisticsOverview__table-cell">
<b className="StatisticsOverview__table-value">
{cell.isApproximate ? `${formatInteger(field)}` : formatInteger(field)}
{`${cell.isApproximate ? '≈' : ''}${formatInteger(field)}`}
</b>
<h3 className="StatisticsOverview__table-heading">{lang(cell.title)}</h3>
</td>
@ -118,7 +152,14 @@ const StatisticsOverview: FC<OwnProps> = ({ isGroup, isMessage, statistics }) =>
if (cell.isPercentage) {
return (
<td className="StatisticsOverview__table-cell">
<b className="StatisticsOverview__table-value">{field.percentage}%</b>
{cell.withAbsoluteValue && (
<span className="StatisticsOverview__table-value">
{`${cell.isApproximate ? '≈' : ''}${formatInteger(field.part)}`}
</span>
)}
<span className={`StatisticsOverview__table-${cell.withAbsoluteValue ? 'secondary-' : ''}value`}>
{field.percentage}%
</span>
<h3 className="StatisticsOverview__table-heading">{lang(cell.title)}</h3>
</td>
);

View File

@ -82,7 +82,8 @@
> .icon {
font-size: 1.5rem;
margin-right: 2rem;
margin-inline-start: 0.125rem;
margin-inline-end: 1.25rem;
color: var(--color-text-secondary);
}
@ -489,13 +490,6 @@
}
}
}
&[dir="rtl"] {
.ListItem-button > .icon {
margin-left: 2rem;
margin-right: 0;
}
}
}
.list-item-ellipsis {

View File

@ -2,7 +2,7 @@ import type { ActionReturnType } from '../../types';
import { DEBUG, PREVIEW_AVATAR_COUNT } from '../../../config';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { buildCollectionByKey } from '../../../util/iteratees';
import { buildCollectionByKey, unique } from '../../../util/iteratees';
import { translate } from '../../../util/langProvider';
import { getServerTime } from '../../../util/serverTime';
import { callApi } from '../../../api/gramjs';
@ -557,6 +557,91 @@ addActionHandler('openBoostModal', async (global, actions, payload): Promise<voi
setGlobal(global);
});
addActionHandler('openBoostStatistics', async (global, actions, payload): Promise<void> => {
const { chatId, tabId = getCurrentTabId() } = payload;
const chat = selectChat(global, chatId);
if (!chat) return;
global = updateTabState(global, {
boostStatistics: {
chatId,
},
}, tabId);
setGlobal(global);
const [boostersListResult, boostStatusResult] = await Promise.all([
callApi('fetchBoostersList', { chat }),
callApi('fetchBoostsStatus', { chat }),
]);
global = getGlobal();
if (!boostersListResult || !boostStatusResult) {
global = updateTabState(global, {
boostStatistics: undefined,
}, tabId);
setGlobal(global);
return;
}
global = addUsers(global, buildCollectionByKey(boostersListResult.users, 'id'));
global = updateTabState(global, {
boostStatistics: {
chatId,
boostStatus: boostStatusResult,
boosters: boostersListResult.boosters,
boosterIds: boostersListResult.boosterIds,
count: boostersListResult.count,
nextOffset: boostersListResult.nextOffset,
},
}, tabId);
setGlobal(global);
});
addActionHandler('loadMoreBoosters', async (global, actions, payload): Promise<void> => {
const { tabId = getCurrentTabId() } = payload || {};
let tabState = selectTabState(global, tabId);
if (!tabState.boostStatistics) return;
const chat = selectChat(global, tabState.boostStatistics.chatId);
if (!chat) return;
global = updateTabState(global, {
boostStatistics: {
...tabState.boostStatistics,
isLoadingBoosters: true,
},
}, tabId);
setGlobal(global);
const result = await callApi('fetchBoostersList', {
chat,
offset: tabState.boostStatistics.nextOffset,
});
if (!result) return;
global = getGlobal();
global = addUsers(global, buildCollectionByKey(result.users, 'id'));
tabState = selectTabState(global, tabId);
if (!tabState.boostStatistics) return;
global = updateTabState(global, {
boostStatistics: {
...tabState.boostStatistics,
boosters: {
...tabState.boostStatistics.boosters,
...result.boosters,
},
boosterIds: unique([...tabState.boostStatistics.boosterIds || [], ...result.boosterIds]),
count: result.count,
nextOffset: result.nextOffset,
isLoadingBoosters: false,
},
}, tabId);
setGlobal(global);
});
addActionHandler('applyBoost', async (global, actions, payload): Promise<void> => {
const { chatId, tabId = getCurrentTabId() } = payload;

View File

@ -55,6 +55,7 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => {
global = updateTabState(global, {
isStatisticsShown: false,
boostStatistics: undefined,
contentToBeScheduled: undefined,
...(id !== selectTabState(global, tabId).forwardMessages.toChatId && {
forwardMessages: {},

View File

@ -419,3 +419,11 @@ addActionHandler('closeBoostModal', (global, actions, payload): ActionReturnType
boostModal: undefined,
}, tabId);
});
addActionHandler('closeBoostStatistics', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
boostStatistics: undefined,
}, tabId);
});

View File

@ -39,6 +39,8 @@ export function selectRightColumnContentKey<T extends GlobalState>(
RightColumnContent.MessageStatistics
) : selectIsStatisticsShown(global, tabId) ? (
RightColumnContent.Statistics
) : tabState.boostStatistics ? (
RightColumnContent.BoostStatistics
) : tabState.stickerSearch.query !== undefined ? (
RightColumnContent.StickerSearch
) : tabState.gifSearch.query !== undefined ? (

View File

@ -625,6 +625,16 @@ export type TabState = {
boostStatus?: ApiBoostsStatus;
applyInfo?: ApiApplyBoostInfo;
};
boostStatistics?: {
chatId: string;
boosters?: Record<string, number>;
boosterIds?: string[];
boostStatus?: ApiBoostsStatus;
isLoadingBoosters?: boolean;
nextOffset?: string;
count?: number;
};
};
export type GlobalState = {
@ -2084,6 +2094,11 @@ export interface ActionPayloads {
chatId: string;
} & WithTabId;
closeBoostModal: WithTabId | undefined;
openBoostStatistics: {
chatId: string;
} & WithTabId;
closeBoostStatistics: WithTabId | undefined;
loadMoreBoosters: WithTabId | undefined;
applyBoost: {
chatId: string;
} & WithTabId;

View File

@ -348,7 +348,7 @@ namespace Api {
export type TypeAccessPointRule = AccessPointRule;
export type TypeTlsClientHello = TlsClientHello;
export type TypeTlsBlock = TlsBlockString | TlsBlockRandom | TlsBlockZero | TlsBlockDomain | TlsBlockGrease | TlsBlockScope;
export namespace storage {
export type TypeFileType = storage.FileUnknown | storage.FilePartial | storage.FileJpeg | storage.FileGif | storage.FilePng | storage.FilePdf | storage.FileMp3 | storage.FileMov | storage.FileMp4 | storage.FileWebp;
@ -8916,7 +8916,7 @@ namespace Api {
}> {
entries: Api.TypeTlsBlock[];
};
export namespace storage {
export class FileUnknown extends VirtualClass<void> {};
@ -10983,7 +10983,7 @@ namespace Api {
}>, Api.TypeDestroySessionRes> {
sessionId: long;
};
export namespace auth {
export class SendCode extends Request<Partial<{

View File

@ -1473,4 +1473,4 @@ stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool;
stories.getBoostsStatus#4c449472 peer:InputPeer = stories.BoostsStatus;
stories.getBoostersList#337ef980 peer:InputPeer offset:string limit:int = stories.BoostersList;
stories.canApplyBoost#db05c1bd peer:InputPeer = stories.CanApplyBoostResult;
stories.applyBoost#f29d7c2b peer:InputPeer = Bool;`;
stories.applyBoost#f29d7c2b peer:InputPeer = Bool;`;

View File

@ -98,3 +98,14 @@
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
}
}
@mixin side-panel-section {
background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
border-bottom: 0.625rem solid var(--color-background-secondary);
&:last-child {
border-bottom: none;
box-shadow: none;
}
}

View File

@ -275,6 +275,7 @@ export enum RightColumnContent {
Search,
Management,
Statistics,
BoostStatistics,
MessageStatistics,
StickerSearch,
GifSearch,