Statistics: Fix statistics overview (#5963)

This commit is contained in:
Alexander Zinchuk 2025-08-21 12:05:42 +02:00
parent f4240a93ab
commit fb79e088fe
17 changed files with 288 additions and 128 deletions

View File

@ -8,12 +8,12 @@ import type {
ApiPostStatistics, ApiPostStatistics,
ApiStoryPublicForward, ApiStoryPublicForward,
ChannelMonetizationBalances, ChannelMonetizationBalances,
StatisticsGraph,
StatisticsMessageInteractionCounter, StatisticsMessageInteractionCounter,
StatisticsOverviewItem, StatisticsOverviewItem,
StatisticsOverviewPercentage, StatisticsOverviewPercentage,
StatisticsOverviewPeriod, StatisticsOverviewPeriod,
StatisticsStoryInteractionCounter, StatisticsStoryInteractionCounter,
TypeStatisticsGraph,
} from '../../types'; } from '../../types';
import { buildApiUsernames, buildAvatarPhotoId } from './common'; import { buildApiUsernames, buildAvatarPhotoId } from './common';
@ -23,20 +23,19 @@ const DECIMALS = 10 ** 9;
export function buildChannelStatistics(stats: GramJs.stats.BroadcastStats): ApiChannelStatistics { export function buildChannelStatistics(stats: GramJs.stats.BroadcastStats): ApiChannelStatistics {
return { return {
type: 'channel',
// Graphs // Graphs
growthGraph: buildGraph(stats.growthGraph), growthGraph: buildGraph(stats.growthGraph),
followersGraph: buildGraph(stats.followersGraph), followersGraph: buildGraph(stats.followersGraph),
muteGraph: buildGraph(stats.muteGraph), muteGraph: buildGraph(stats.muteGraph),
topHoursGraph: buildGraph(stats.topHoursGraph), topHoursGraph: buildGraph(stats.topHoursGraph),
languagesGraph: buildGraph(stats.languagesGraph),
// Async graphs viewsBySourceGraph: buildGraph(stats.viewsBySourceGraph),
languagesGraph: (stats.languagesGraph as GramJs.StatsGraphAsync).token, newFollowersBySourceGraph: buildGraph(stats.newFollowersBySourceGraph),
viewsBySourceGraph: (stats.viewsBySourceGraph as GramJs.StatsGraphAsync).token, interactionsGraph: buildGraph(stats.interactionsGraph),
newFollowersBySourceGraph: (stats.newFollowersBySourceGraph as GramJs.StatsGraphAsync).token, reactionsByEmotionGraph: buildGraph(stats.reactionsByEmotionGraph),
interactionsGraph: (stats.interactionsGraph as GramJs.StatsGraphAsync).token, storyInteractionsGraph: buildGraph(stats.storyInteractionsGraph),
reactionsByEmotionGraph: (stats.reactionsByEmotionGraph as GramJs.StatsGraphAsync).token, storyReactionsByEmotionGraph: buildGraph(stats.storyReactionsByEmotionGraph),
storyInteractionsGraph: (stats.storyInteractionsGraph as GramJs.StatsGraphAsync).token,
storyReactionsByEmotionGraph: (stats.storyReactionsByEmotionGraph as GramJs.StatsGraphAsync).token,
// Statistics overview // Statistics overview
followers: buildStatisticsOverview(stats.followers), followers: buildStatisticsOverview(stats.followers),
@ -72,6 +71,7 @@ export function buildApiPostInteractionCounter(
): StatisticsMessageInteractionCounter | StatisticsStoryInteractionCounter | undefined { ): StatisticsMessageInteractionCounter | StatisticsStoryInteractionCounter | undefined {
if (interaction instanceof GramJs.PostInteractionCountersMessage) { if (interaction instanceof GramJs.PostInteractionCountersMessage) {
return { return {
type: 'message',
msgId: interaction.msgId, msgId: interaction.msgId,
forwardsCount: interaction.forwards, forwardsCount: interaction.forwards,
viewsCount: interaction.views, viewsCount: interaction.views,
@ -81,6 +81,7 @@ export function buildApiPostInteractionCounter(
if (interaction instanceof GramJs.PostInteractionCountersStory) { if (interaction instanceof GramJs.PostInteractionCountersStory) {
return { return {
type: 'story',
storyId: interaction.storyId, storyId: interaction.storyId,
reactionsCount: interaction.reactions, reactionsCount: interaction.reactions,
viewsCount: interaction.views, viewsCount: interaction.views,
@ -93,15 +94,14 @@ export function buildApiPostInteractionCounter(
export function buildGroupStatistics(stats: GramJs.stats.MegagroupStats): ApiGroupStatistics { export function buildGroupStatistics(stats: GramJs.stats.MegagroupStats): ApiGroupStatistics {
return { return {
type: 'group',
// Graphs // Graphs
growthGraph: buildGraph(stats.growthGraph), growthGraph: buildGraph(stats.growthGraph),
membersGraph: buildGraph(stats.membersGraph), membersGraph: buildGraph(stats.membersGraph),
topHoursGraph: buildGraph(stats.topHoursGraph), topHoursGraph: buildGraph(stats.topHoursGraph),
languagesGraph: buildGraph(stats.languagesGraph),
// Async graphs messagesGraph: buildGraph(stats.messagesGraph),
languagesGraph: (stats.languagesGraph as GramJs.StatsGraphAsync).token, actionsGraph: buildGraph(stats.actionsGraph),
messagesGraph: (stats.messagesGraph as GramJs.StatsGraphAsync).token,
actionsGraph: (stats.actionsGraph as GramJs.StatsGraphAsync).token,
// Statistics overview // Statistics overview
period: getOverviewPeriod(stats.period), period: getOverviewPeriod(stats.period),
@ -158,18 +158,29 @@ export function buildStoryPublicForwards(
export function buildGraph( export function buildGraph(
result: GramJs.TypeStatsGraph, isPercentage?: boolean, isCurrency?: boolean, currencyRate?: number, result: GramJs.TypeStatsGraph, isPercentage?: boolean, isCurrency?: boolean, currencyRate?: number,
): StatisticsGraph | undefined { ): TypeStatisticsGraph {
if ((result as GramJs.StatsGraphError).error) { if (result instanceof GramJs.StatsGraphError) {
return undefined; return {
graphType: 'error',
error: result.error,
};
} }
const data = JSON.parse((result as GramJs.StatsGraph).json.data); if (result instanceof GramJs.StatsGraphAsync) {
return {
graphType: 'async',
token: result.token,
};
}
const data = JSON.parse(result.json.data);
const [x, ...y] = data.columns; const [x, ...y] = data.columns;
const hasSecondYAxis = data.y_scaled; const hasSecondYAxis = data.y_scaled;
return { return {
graphType: 'graph',
type: isPercentage ? 'area' : data.types.y0, type: isPercentage ? 'area' : data.types.y0,
zoomToken: (result as GramJs.StatsGraph).zoomToken, zoomToken: result.zoomToken,
labelFormatter: data.xTickFormatter, labelFormatter: data.xTickFormatter,
tooltipFormatter: data.xTooltipFormatter, tooltipFormatter: data.xTooltipFormatter,
labels: x.slice(1), labels: x.slice(1),

View File

@ -270,6 +270,30 @@ export async function fetchMessage({ chat, messageId }: { chat: ApiChat; message
return { message }; return { message };
} }
export async function fetchMessagesById({ chat, messageIds }: { chat: ApiChat; messageIds: number[] }) {
const isChannel = getEntityTypeById(chat.id) === 'channel';
const result = await invokeRequest(
isChannel
? new GramJs.channels.GetMessages({
channel: buildInputChannel(chat.id, chat.accessHash),
id: messageIds.map((id) => new GramJs.InputMessageID({ id })),
})
: new GramJs.messages.GetMessages({
id: messageIds.map((id) => new GramJs.InputMessageID({ id })),
}),
{
shouldThrow: true,
},
);
if (!result || result instanceof GramJs.messages.MessagesNotModified) {
return undefined;
}
return result.messages.map(buildApiMessage).filter(Boolean);
}
let mediaQueue = Promise.resolve(); let mediaQueue = Promise.resolve();
export function sendMessageLocal( export function sendMessageLocal(

View File

@ -5,7 +5,6 @@ import type {
ApiChat, ApiMessagePublicForward, ApiPeer, ApiPostStatistics, ApiStoryPublicForward, StatisticsGraph, ApiChat, ApiMessagePublicForward, ApiPeer, ApiPostStatistics, ApiStoryPublicForward, StatisticsGraph,
} from '../../types'; } from '../../types';
import { STATISTICS_PUBLIC_FORWARDS_LIMIT } from '../../../config';
import { import {
buildChannelMonetizationStatistics, buildChannelMonetizationStatistics,
buildChannelStatistics, buildChannelStatistics,
@ -104,11 +103,13 @@ export async function fetchMessagePublicForwards({
messageId, messageId,
dcId, dcId,
offset = DEFAULT_PRIMITIVES.STRING, offset = DEFAULT_PRIMITIVES.STRING,
limit = DEFAULT_PRIMITIVES.INT,
}: { }: {
chat: ApiChat; chat: ApiChat;
messageId: number; messageId: number;
dcId?: number; dcId?: number;
offset?: string; offset?: string;
limit?: number;
}): Promise<{ }): Promise<{
forwards?: ApiMessagePublicForward[]; forwards?: ApiMessagePublicForward[];
count?: number; count?: number;
@ -118,7 +119,7 @@ export async function fetchMessagePublicForwards({
channel: buildInputChannel(chat.id, chat.accessHash), channel: buildInputChannel(chat.id, chat.accessHash),
msgId: messageId, msgId: messageId,
offset, offset,
limit: STATISTICS_PUBLIC_FORWARDS_LIMIT, limit,
}), { }), {
dcId, dcId,
}); });
@ -156,7 +157,10 @@ export async function fetchStatisticsAsyncGraph({
return undefined; return undefined;
} }
return buildGraph(result as GramJs.StatsGraph, isPercentage); const graph = buildGraph(result, isPercentage);
if (graph.graphType !== 'graph') return undefined;
return graph;
} }
export async function fetchStoryStatistics({ export async function fetchStoryStatistics({
@ -187,11 +191,13 @@ export async function fetchStoryPublicForwards({
storyId, storyId,
dcId, dcId,
offset = DEFAULT_PRIMITIVES.STRING, offset = DEFAULT_PRIMITIVES.STRING,
limit = DEFAULT_PRIMITIVES.INT,
}: { }: {
chat: ApiChat; chat: ApiChat;
storyId: number; storyId: number;
dcId?: number; dcId?: number;
offset?: string; offset?: string;
limit?: number;
}): Promise<{ }): Promise<{
publicForwards: (ApiMessagePublicForward | ApiStoryPublicForward)[] | undefined; publicForwards: (ApiMessagePublicForward | ApiStoryPublicForward)[] | undefined;
count?: number; count?: number;
@ -201,7 +207,7 @@ export async function fetchStoryPublicForwards({
peer: buildInputPeer(chat.id, chat.accessHash), peer: buildInputPeer(chat.id, chat.accessHash),
id: storyId, id: storyId,
offset, offset,
limit: STATISTICS_PUBLIC_FORWARDS_LIMIT, limit,
}), { }), {
dcId, dcId,
}); });

View File

@ -2,17 +2,18 @@ import type { ApiChat } from './chats';
import type { ApiTypePrepaidGiveaway } from './payments'; import type { ApiTypePrepaidGiveaway } from './payments';
export interface ApiChannelStatistics { export interface ApiChannelStatistics {
growthGraph?: StatisticsGraph | string; type: 'channel';
followersGraph?: StatisticsGraph | string; growthGraph?: TypeStatisticsGraph;
muteGraph?: StatisticsGraph | string; followersGraph?: TypeStatisticsGraph;
topHoursGraph?: StatisticsGraph | string; muteGraph?: TypeStatisticsGraph;
reactionsByEmotionGraph?: StatisticsGraph | string; topHoursGraph?: TypeStatisticsGraph;
storyInteractionsGraph?: StatisticsGraph | string; reactionsByEmotionGraph?: TypeStatisticsGraph;
storyReactionsByEmotionGraph?: StatisticsGraph | string; storyInteractionsGraph?: TypeStatisticsGraph;
interactionsGraph: StatisticsGraph | string; storyReactionsByEmotionGraph?: TypeStatisticsGraph;
viewsBySourceGraph: StatisticsGraph | string; interactionsGraph: TypeStatisticsGraph;
newFollowersBySourceGraph: StatisticsGraph | string; viewsBySourceGraph: TypeStatisticsGraph;
languagesGraph: StatisticsGraph | string; newFollowersBySourceGraph: TypeStatisticsGraph;
languagesGraph: TypeStatisticsGraph;
followers: StatisticsOverviewItem; followers: StatisticsOverviewItem;
viewsPerPost: StatisticsOverviewItem; viewsPerPost: StatisticsOverviewItem;
sharesPerPost: StatisticsOverviewItem; sharesPerPost: StatisticsOverviewItem;
@ -25,19 +26,20 @@ export interface ApiChannelStatistics {
} }
export interface ApiChannelMonetizationStatistics { export interface ApiChannelMonetizationStatistics {
topHoursGraph?: StatisticsGraph | string; topHoursGraph?: TypeStatisticsGraph;
revenueGraph?: StatisticsGraph | string; revenueGraph?: TypeStatisticsGraph;
balances?: ChannelMonetizationBalances; balances?: ChannelMonetizationBalances;
usdRate?: number; usdRate?: number;
} }
export interface ApiGroupStatistics { export interface ApiGroupStatistics {
growthGraph?: StatisticsGraph | string; type: 'group';
membersGraph?: StatisticsGraph | string; growthGraph?: TypeStatisticsGraph;
topHoursGraph?: StatisticsGraph | string; membersGraph?: TypeStatisticsGraph;
languagesGraph: StatisticsGraph | string; topHoursGraph?: TypeStatisticsGraph;
messagesGraph: StatisticsGraph | string; languagesGraph: TypeStatisticsGraph;
actionsGraph: StatisticsGraph | string; messagesGraph: TypeStatisticsGraph;
actionsGraph: TypeStatisticsGraph;
period: StatisticsOverviewPeriod; period: StatisticsOverviewPeriod;
members: StatisticsOverviewItem; members: StatisticsOverviewItem;
viewers: StatisticsOverviewItem; viewers: StatisticsOverviewItem;
@ -46,8 +48,8 @@ export interface ApiGroupStatistics {
} }
export interface ApiPostStatistics { export interface ApiPostStatistics {
viewsGraph?: StatisticsGraph | string; viewsGraph?: TypeStatisticsGraph;
reactionsGraph?: StatisticsGraph | string; reactionsGraph?: TypeStatisticsGraph;
forwardsCount?: number; forwardsCount?: number;
viewsCount?: number; viewsCount?: number;
reactionsCount?: number; reactionsCount?: number;
@ -80,6 +82,7 @@ export interface ApiStoryPublicForward {
} }
export interface StatisticsGraph { export interface StatisticsGraph {
graphType: 'graph';
type: string; type: string;
zoomToken?: string; zoomToken?: string;
labelFormatter: string; labelFormatter: string;
@ -104,6 +107,18 @@ export interface StatisticsGraph {
}; };
} }
export interface StatisticsGraphError {
graphType: 'error';
error: string;
}
export interface StatisticsGraphAsync {
graphType: 'async';
token: string;
}
export type TypeStatisticsGraph = StatisticsGraph | StatisticsGraphError | StatisticsGraphAsync;
export interface StatisticsOverviewItem { export interface StatisticsOverviewItem {
current?: number; current?: number;
change?: number; change?: number;
@ -122,6 +137,7 @@ export interface StatisticsOverviewPeriod {
} }
export interface StatisticsMessageInteractionCounter { export interface StatisticsMessageInteractionCounter {
type: 'message';
msgId: number; msgId: number;
forwardsCount: number; forwardsCount: number;
viewsCount: number; viewsCount: number;
@ -129,6 +145,7 @@ export interface StatisticsMessageInteractionCounter {
} }
export interface StatisticsStoryInteractionCounter { export interface StatisticsStoryInteractionCounter {
type: 'story';
storyId: number; storyId: number;
viewsCount: number; viewsCount: number;
forwardsCount: number; forwardsCount: number;

View File

@ -1026,7 +1026,11 @@ const Message: FC<OwnProps & StateProps> = ({
return ( return (
<div <div
className={buildClassName('quick-reaction', isQuickReactionVisible && !hasActiveReactions && 'visible')} className={buildClassName(
'quick-reaction',
'no-selection',
isQuickReactionVisible && !hasActiveReactions && 'visible',
)}
onClick={handleSendQuickReaction} onClick={handleSendQuickReaction}
ref={quickReactionRef} ref={quickReactionRef}
> >

View File

@ -6,14 +6,13 @@ import { getActions, withGlobal } from '../../../global';
import type { import type {
ApiMessagePublicForward, ApiMessagePublicForward,
ApiPostStatistics, ApiPostStatistics,
StatisticsGraph,
} from '../../../api/types'; } from '../../../api/types';
import { LoadMoreDirection } from '../../../types'; import { LoadMoreDirection } from '../../../types';
import { STATISTICS_PUBLIC_FORWARDS_LIMIT } from '../../../config';
import { selectChatFullInfo, selectTabState } from '../../../global/selectors'; import { selectChatFullInfo, selectTabState } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { isGraph } from './helpers/isGraph';
import useForceUpdate from '../../../hooks/useForceUpdate'; import useForceUpdate from '../../../hooks/useForceUpdate';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
@ -56,7 +55,7 @@ export type StateProps = {
dcId?: number; dcId?: number;
}; };
function Statistics({ function MessageStatistics({
chatId, chatId,
isActive, isActive,
statistics, statistics,
@ -66,7 +65,8 @@ function Statistics({
const lang = useOldLang(); const lang = useOldLang();
const containerRef = useRef<HTMLDivElement>(); const containerRef = useRef<HTMLDivElement>();
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const loadedCharts = useRef<string[]>([]); const loadedCharts = useRef<Set<string>>(new Set());
const errorCharts = useRef<Set<string>>(new Set());
const { loadMessageStatistics, loadMessagePublicForwards, loadStatisticsAsyncGraph } = getActions(); const { loadMessageStatistics, loadMessagePublicForwards, loadStatisticsAsyncGraph } = getActions();
const forceUpdate = useForceUpdate(); const forceUpdate = useForceUpdate();
@ -79,7 +79,8 @@ function Statistics({
useEffect(() => { useEffect(() => {
if (!isActive || messageId) { if (!isActive || messageId) {
loadedCharts.current = []; loadedCharts.current.clear();
errorCharts.current.clear();
setIsReady(false); setIsReady(false);
} }
}, [isActive, messageId]); }, [isActive, messageId]);
@ -92,10 +93,13 @@ function Statistics({
GRAPHS.forEach((name) => { GRAPHS.forEach((name) => {
const graph = statistics[name]; const graph = statistics[name];
const isAsync = typeof graph === 'string'; if (!isGraph(graph)) {
return;
}
const isAsync = graph.graphType === 'async';
if (isAsync) { if (isAsync) {
loadStatisticsAsyncGraph({ name, chatId, token: graph }); loadStatisticsAsyncGraph({ name, chatId, token: graph.token });
} }
}); });
}, [chatId, statistics, loadStatisticsAsyncGraph]); }, [chatId, statistics, loadStatisticsAsyncGraph]);
@ -115,19 +119,24 @@ function Statistics({
GRAPHS.forEach((name, index: number) => { GRAPHS.forEach((name, index: number) => {
const graph = statistics[name]; const graph = statistics[name];
const isAsync = typeof graph === 'string'; if (!isGraph(graph)) {
return;
}
const isAsync = graph.graphType === 'async';
const isError = graph.graphType === 'error';
if (isAsync || loadedCharts.current.includes(name)) { if (isAsync || loadedCharts.current.has(name)) {
return; return;
} }
if (!graph) { if (isError) {
loadedCharts.current.push(name); loadedCharts.current.add(name);
errorCharts.current.add(name);
return; return;
} }
const { zoomToken } = graph as StatisticsGraph; const { zoomToken } = graph;
LovelyChart.create( LovelyChart.create(
containerRef.current!.children[index] as HTMLElement, containerRef.current!.children[index] as HTMLElement,
@ -137,11 +146,11 @@ function Statistics({
onZoom: (x: number) => callApi('fetchStatisticsAsyncGraph', { token: zoomToken, x, dcId }), onZoom: (x: number) => callApi('fetchStatisticsAsyncGraph', { token: zoomToken, x, dcId }),
zoomOutLabel: lang('Graph.ZoomOut'), zoomOutLabel: lang('Graph.ZoomOut'),
} : {}, } : {},
...graph as StatisticsGraph, ...graph,
}, },
); );
loadedCharts.current.push(name); loadedCharts.current.add(name);
}); });
forceUpdate(); forceUpdate();
@ -161,15 +170,21 @@ function Statistics({
} }
return ( return (
<div className={buildClassName(styles.root, 'custom-scroll', isReady && styles.ready)}> <div
key={`${chatId}-${messageId}`}
className={buildClassName(styles.root, 'custom-scroll', isReady && styles.ready)}
>
<StatisticsOverview statistics={statistics} type="message" title={lang('StatisticOverview')} /> <StatisticsOverview statistics={statistics} type="message" title={lang('StatisticOverview')} />
{!loadedCharts.current.length && <Loading />} {(!loadedCharts.current.size || !statistics.publicForwardsData) && <Loading />}
<div ref={containerRef}> <div ref={containerRef}>
{GRAPHS.map((graph) => ( {GRAPHS.map((graph) => {
<div className={buildClassName(styles.graph, !loadedCharts.current.includes(graph) && styles.hidden)} /> const isReady = loadedCharts.current.has(graph) && !errorCharts.current.has(graph);
))} return (
<div className={buildClassName(styles.graph, !isReady && styles.hidden)} />
);
})}
</div> </div>
{Boolean(statistics.publicForwards) && ( {Boolean(statistics.publicForwards) && (
@ -180,7 +195,6 @@ function Statistics({
items={statistics.publicForwardsData} items={statistics.publicForwardsData}
itemSelector=".statistic-public-forward" itemSelector=".statistic-public-forward"
onLoadMore={handleLoadMore} onLoadMore={handleLoadMore}
preloadBackwards={STATISTICS_PUBLIC_FORWARDS_LIMIT}
noFastList noFastList
> >
{(statistics.publicForwardsData as ApiMessagePublicForward[]).map((item) => ( {(statistics.publicForwardsData as ApiMessagePublicForward[]).map((item) => (
@ -202,4 +216,4 @@ export default memo(withGlobal<OwnProps>(
return { statistics, dcId, messageId }; return { statistics, dcId, messageId };
}, },
)(Statistics)); )(MessageStatistics));

View File

@ -3,11 +3,12 @@ import {
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiChannelMonetizationStatistics, StatisticsGraph } from '../../../api/types'; import type { ApiChannelMonetizationStatistics } from '../../../api/types';
import { selectChat, selectChatFullInfo, selectTabState } from '../../../global/selectors'; import { selectChat, selectChatFullInfo, selectTabState } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
import { isGraph } from './helpers/isGraph';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useForceUpdate from '../../../hooks/useForceUpdate'; import useForceUpdate from '../../../hooks/useForceUpdate';
@ -68,7 +69,9 @@ const MonetizationStatistics = ({
const containerRef = useRef<HTMLDivElement>(); const containerRef = useRef<HTMLDivElement>();
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const loadedCharts = useRef<string[]>([]); const loadedCharts = useRef<Set<string>>(new Set());
const errorCharts = useRef<Set<string>>(new Set());
const forceUpdate = useForceUpdate(); const forceUpdate = useForceUpdate();
const [isAboutMonetizationModalOpen, openAboutMonetizationModal, closeAboutMonetizationModal] = useFlag(false); const [isAboutMonetizationModalOpen, openAboutMonetizationModal, closeAboutMonetizationModal] = useFlag(false);
const [isConfirmPasswordDialogOpen, openConfirmPasswordDialogOpen, closeConfirmPasswordDialogOpen] = useFlag(); const [isConfirmPasswordDialogOpen, openConfirmPasswordDialogOpen, closeConfirmPasswordDialogOpen] = useFlag();
@ -100,7 +103,8 @@ const MonetizationStatistics = ({
}); });
} }
loadedCharts.current = []; loadedCharts.current.clear();
errorCharts.current.clear();
if (!statistics || !containerRef.current) { if (!statistics || !containerRef.current) {
return; return;
@ -108,24 +112,29 @@ const MonetizationStatistics = ({
MONETIZATION_GRAPHS.forEach((name, index: number) => { MONETIZATION_GRAPHS.forEach((name, index: number) => {
const graph = statistics[name]; const graph = statistics[name];
const isAsync = typeof graph === 'string'; if (!isGraph(graph)) {
return;
}
const isAsync = graph.graphType === 'async';
const isError = graph.graphType === 'error';
if (isAsync || loadedCharts.current.includes(name)) { if (isAsync || loadedCharts.current.has(name)) {
return; return;
} }
if (!graph) { if (isError) {
loadedCharts.current.push(name); loadedCharts.current.add(name);
errorCharts.current.add(name);
return; return;
} }
LovelyChart.create(containerRef.current!.children[index] as HTMLElement, { LovelyChart.create(containerRef.current!.children[index] as HTMLElement, {
title: oldLang((MONETIZATION_GRAPHS_TITLES as Record<string, string>)[name]), title: oldLang((MONETIZATION_GRAPHS_TITLES as Record<string, string>)[name]),
...graph as StatisticsGraph, ...graph,
}); });
loadedCharts.current.push(name); loadedCharts.current.add(name);
containerRef.current!.children[index].classList.remove(styles.hidden); containerRef.current!.children[index].classList.remove(styles.hidden);
}); });
@ -232,7 +241,7 @@ const MonetizationStatistics = ({
} }
/> />
{!loadedCharts.current.length && <Loading />} {!loadedCharts.current.size && <Loading />}
<div ref={containerRef} className={styles.section}> <div ref={containerRef} className={styles.section}>
{MONETIZATION_GRAPHS.filter(Boolean).map((graph) => ( {MONETIZATION_GRAPHS.filter(Boolean).map((graph) => (

View File

@ -36,7 +36,6 @@
.messages, .publicForwards { .messages, .publicForwards {
padding: 1rem 0; padding: 1rem 0;
border-top: 1px solid var(--color-borders);
&-title { &-title {
padding-left: 0.75rem; padding-left: 0.75rem;
@ -69,6 +68,7 @@
&.hidden { &.hidden {
margin: 0; margin: 0;
border-bottom: none;
opacity: 0; opacity: 0;
} }
} }

View File

@ -1,4 +1,3 @@
import type { FC } from '../../../lib/teact/teact';
import { import {
memo, useEffect, useMemo, memo, useEffect, useMemo,
useRef, useState, useRef, useState,
@ -22,6 +21,7 @@ import {
} from '../../../global/selectors'; } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { isGraph } from './helpers/isGraph';
import useForceUpdate from '../../../hooks/useForceUpdate'; import useForceUpdate from '../../../hooks/useForceUpdate';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
@ -84,7 +84,7 @@ export type StateProps = {
storiesById?: Record<string, ApiTypeStory>; storiesById?: Record<string, ApiTypeStory>;
}; };
const Statistics: FC<OwnProps & StateProps> = ({ const Statistics = ({
chatId, chatId,
chat, chat,
statistics, statistics,
@ -92,11 +92,12 @@ const Statistics: FC<OwnProps & StateProps> = ({
isGroup, isGroup,
messagesById, messagesById,
storiesById, storiesById,
}) => { }: OwnProps & StateProps) => {
const lang = useOldLang(); const lang = useOldLang();
const containerRef = useRef<HTMLDivElement>(); const containerRef = useRef<HTMLDivElement>();
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const loadedCharts = useRef<string[]>([]); const loadedCharts = useRef<Set<string>>(new Set());
const errorCharts = useRef<Set<string>>(new Set());
const { loadStatistics, loadStatisticsAsyncGraph } = getActions(); const { loadStatistics, loadStatisticsAsyncGraph } = getActions();
const forceUpdate = useForceUpdate(); const forceUpdate = useForceUpdate();
@ -121,13 +122,16 @@ const Statistics: FC<OwnProps & StateProps> = ({
graphs.forEach((name) => { graphs.forEach((name) => {
const graph = statistics[name as keyof typeof statistics]; const graph = statistics[name as keyof typeof statistics];
const isAsync = typeof graph === 'string'; if (!isGraph(graph)) {
return;
}
const isAsync = graph.graphType === 'async';
if (isAsync) { if (isAsync) {
loadStatisticsAsyncGraph({ loadStatisticsAsyncGraph({
name, name,
chatId, chatId,
token: graph, token: graph.token,
// Hardcode percentage for languages graph, since API does not return `percentage` flag // Hardcode percentage for languages graph, since API does not return `percentage` flag
isPercentage: name === 'languagesGraph', isPercentage: name === 'languagesGraph',
}); });
@ -150,14 +154,20 @@ const Statistics: FC<OwnProps & StateProps> = ({
graphs.forEach((name, index: number) => { graphs.forEach((name, index: number) => {
const graph = statistics[name as keyof typeof statistics]; const graph = statistics[name as keyof typeof statistics];
const isAsync = typeof graph === 'string'; if (!isGraph(graph)) {
if (isAsync || loadedCharts.current.includes(name)) {
return; return;
} }
if (!graph) { const isAsync = graph.graphType === 'async';
loadedCharts.current.push(name); const isError = graph.graphType === 'error';
if (isAsync || loadedCharts.current.has(name)) {
return;
}
if (isError) {
loadedCharts.current.add(name);
errorCharts.current.add(name);
return; return;
} }
@ -176,7 +186,7 @@ const Statistics: FC<OwnProps & StateProps> = ({
}, },
); );
loadedCharts.current.push(name); loadedCharts.current.add(name);
containerRef.current!.children[index].classList.remove(styles.hidden); containerRef.current!.children[index].classList.remove(styles.hidden);
}); });
@ -197,12 +207,15 @@ const Statistics: FC<OwnProps & StateProps> = ({
/> />
)} )}
{!loadedCharts.current.length && <Loading />} {!loadedCharts.current.size && <Loading />}
<div ref={containerRef}> <div ref={containerRef}>
{graphs.map((graph) => ( {graphs.map((graph) => {
<div key={graph} className={buildClassName(styles.graph, styles.hidden)} /> const isReady = loadedCharts.current.has(graph) && !errorCharts.current.has(graph);
))} return (
<div className={buildClassName(styles.graph, !isReady && styles.hidden)} />
);
})}
</div> </div>
{Boolean((statistics as ApiChannelStatistics)?.recentPosts?.length) && ( {Boolean((statistics as ApiChannelStatistics)?.recentPosts?.length) && (

View File

@ -100,6 +100,8 @@ const BOOST_OVERVIEW: OverviewCell[][] = [
type StatisticsType = 'channel' | 'group' | 'message' | 'boost' | 'story' | 'monetization'; type StatisticsType = 'channel' | 'group' | 'message' | 'boost' | 'story' | 'monetization';
const DEFAULT_VALUE = 0;
export type OwnProps = { export type OwnProps = {
type: StatisticsType; type: StatisticsType;
title?: string; title?: string;
@ -212,13 +214,13 @@ const StatisticsOverview: FC<OwnProps> = ({
) : schema.map((row) => ( ) : schema.map((row) => (
<tr> <tr>
{row.map((cell: OverviewCell) => { {row.map((cell: OverviewCell) => {
const field = (statistics as any)[cell.name]; const field = (statistics as any)?.[cell.name];
if (cell.isPlain) { if (cell.isPlain) {
return ( return (
<td className={styles.tableCell}> <td className={styles.tableCell}>
<b className={styles.tableValue}> <b className={styles.tableValue}>
{`${cell.isApproximate ? '≈' : ''}${formatInteger(field)}`} {`${cell.isApproximate ? '≈ ' : ''}${formatInteger(field ?? DEFAULT_VALUE)}`}
</b> </b>
<h3 className={styles.tableHeading}>{oldLang(cell.title)}</h3> <h3 className={styles.tableHeading}>{oldLang(cell.title)}</h3>
</td> </td>
@ -226,15 +228,18 @@ const StatisticsOverview: FC<OwnProps> = ({
} }
if (cell.isPercentage) { if (cell.isPercentage) {
const part = field?.part ?? DEFAULT_VALUE;
const percentage = field?.percentage ?? DEFAULT_VALUE;
return ( return (
<td className={styles.tableCell}> <td className={styles.tableCell}>
{cell.withAbsoluteValue && ( {cell.withAbsoluteValue && (
<span className={styles.tableValue}> <span className={styles.tableValue}>
{`${cell.isApproximate ? '≈' : ''}${formatInteger(field.part)}`} {`${cell.isApproximate ? '≈ ' : ''}${formatInteger(part)}`}
</span> </span>
)} )}
<span className={cell.withAbsoluteValue ? styles.tableSecondaryValue : styles.tableValue}> <span className={cell.withAbsoluteValue ? styles.tableSecondaryValue : styles.tableValue}>
{field.percentage} {percentage}
% %
</span> </span>
<h3 className={styles.tableHeading}>{oldLang(cell.title)}</h3> <h3 className={styles.tableHeading}>{oldLang(cell.title)}</h3>
@ -245,7 +250,7 @@ const StatisticsOverview: FC<OwnProps> = ({
return ( return (
<td className={styles.tableCell}> <td className={styles.tableCell}>
<b className={styles.tableValue}> <b className={styles.tableValue}>
{formatIntegerCompact(lang, field.current)} {formatIntegerCompact(lang, field?.current ?? DEFAULT_VALUE)}
</b> </b>
{' '} {' '}
{renderOverviewItemValue(field)} {renderOverviewItemValue(field)}

View File

@ -7,13 +7,12 @@ import type {
ApiChat, ApiChat,
ApiPostStatistics, ApiPostStatistics,
ApiUser, ApiUser,
StatisticsGraph,
} from '../../../api/types'; } from '../../../api/types';
import { STATISTICS_PUBLIC_FORWARDS_LIMIT } from '../../../config';
import { selectChatFullInfo, selectTabState } from '../../../global/selectors'; import { selectChatFullInfo, selectTabState } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { isGraph } from './helpers/isGraph';
import useForceUpdate from '../../../hooks/useForceUpdate'; import useForceUpdate from '../../../hooks/useForceUpdate';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
@ -71,7 +70,8 @@ function StoryStatistics({
const lang = useOldLang(); const lang = useOldLang();
const containerRef = useRef<HTMLDivElement>(); const containerRef = useRef<HTMLDivElement>();
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const loadedCharts = useRef<string[]>([]); const loadedCharts = useRef<Set<string>>(new Set());
const errorCharts = useRef<Set<string>>(new Set());
const { loadStoryStatistics, loadStoryPublicForwards, loadStatisticsAsyncGraph } = getActions(); const { loadStoryStatistics, loadStoryPublicForwards, loadStatisticsAsyncGraph } = getActions();
const forceUpdate = useForceUpdate(); const forceUpdate = useForceUpdate();
@ -84,7 +84,8 @@ function StoryStatistics({
useEffect(() => { useEffect(() => {
if (!isActive || storyId) { if (!isActive || storyId) {
loadedCharts.current = []; loadedCharts.current.clear();
errorCharts.current.clear();
setIsReady(false); setIsReady(false);
} }
}, [isActive, storyId]); }, [isActive, storyId]);
@ -97,10 +98,13 @@ function StoryStatistics({
GRAPHS.forEach((name) => { GRAPHS.forEach((name) => {
const graph = statistics[name]; const graph = statistics[name];
const isAsync = typeof graph === 'string'; if (!isGraph(graph)) {
return;
}
const isAsync = graph.graphType === 'async';
if (isAsync) { if (isAsync) {
loadStatisticsAsyncGraph({ name, chatId, token: graph }); loadStatisticsAsyncGraph({ name, chatId, token: graph.token });
} }
}); });
}, [chatId, statistics, loadStatisticsAsyncGraph]); }, [chatId, statistics, loadStatisticsAsyncGraph]);
@ -120,19 +124,24 @@ function StoryStatistics({
GRAPHS.forEach((name, index: number) => { GRAPHS.forEach((name, index: number) => {
const graph = statistics[name]; const graph = statistics[name];
const isAsync = typeof graph === 'string'; if (!isGraph(graph)) {
return;
}
const isAsync = graph.graphType === 'async';
const isError = graph.graphType === 'error';
if (isAsync || loadedCharts.current.includes(name)) { if (isAsync || loadedCharts.current.has(name)) {
return; return;
} }
if (!graph) { if (isError) {
loadedCharts.current.push(name); loadedCharts.current.add(name);
errorCharts.current.add(name);
return; return;
} }
const { zoomToken } = graph as StatisticsGraph; const { zoomToken } = graph;
LovelyChart.create( LovelyChart.create(
containerRef.current!.children[index] as HTMLElement, containerRef.current!.children[index] as HTMLElement,
@ -142,11 +151,11 @@ function StoryStatistics({
onZoom: (x: number) => callApi('fetchStatisticsAsyncGraph', { token: zoomToken, x, dcId }), onZoom: (x: number) => callApi('fetchStatisticsAsyncGraph', { token: zoomToken, x, dcId }),
zoomOutLabel: lang('Graph.ZoomOut'), zoomOutLabel: lang('Graph.ZoomOut'),
} : {}, } : {},
...graph as StatisticsGraph, ...graph,
}, },
); );
loadedCharts.current.push(name); loadedCharts.current.add(name);
}); });
forceUpdate(); forceUpdate();
@ -166,15 +175,21 @@ function StoryStatistics({
} }
return ( return (
<div className={buildClassName(styles.root, 'custom-scroll', isReady && styles.ready)}> <div
key={`${chatId}-${storyId}`}
className={buildClassName(styles.root, 'custom-scroll', isReady && styles.ready)}
>
<StatisticsOverview statistics={statistics} type="story" title={lang('StatisticOverview')} /> <StatisticsOverview statistics={statistics} type="story" title={lang('StatisticOverview')} />
{!loadedCharts.current.length && <Loading />} {!loadedCharts.current.size && <Loading />}
<div ref={containerRef}> <div ref={containerRef}>
{GRAPHS.map((graph) => ( {GRAPHS.map((graph) => {
<div className={buildClassName(styles.graph, !loadedCharts.current.includes(graph) && styles.hidden)} /> const isReady = loadedCharts.current.has(graph) && !errorCharts.current.has(graph);
))} return (
<div className={buildClassName(styles.graph, !isReady && styles.hidden)} />
);
})}
</div> </div>
{Boolean(statistics.publicForwards) && ( {Boolean(statistics.publicForwards) && (
@ -185,7 +200,6 @@ function StoryStatistics({
items={statistics.publicForwardsData} items={statistics.publicForwardsData}
itemSelector=".statistic-public-forward" itemSelector=".statistic-public-forward"
onLoadMore={handleLoadMore} onLoadMore={handleLoadMore}
preloadBackwards={STATISTICS_PUBLIC_FORWARDS_LIMIT}
noFastList noFastList
> >
{statistics.publicForwardsData!.map((item) => { {statistics.publicForwardsData!.map((item) => {

View File

@ -0,0 +1,6 @@
import type { TypeStatisticsGraph } from '../../../../api/types';
export function isGraph(obj: unknown): obj is TypeStatisticsGraph {
// eslint-disable-next-line no-null/no-null
return typeof obj === 'object' && obj !== null && 'graphType' in obj;
}

View File

@ -104,7 +104,6 @@ export const TOPIC_LIST_SENSITIVE_AREA = 600;
export const GROUP_CALL_PARTICIPANTS_LIMIT = 100; export const GROUP_CALL_PARTICIPANTS_LIMIT = 100;
export const STORY_LIST_LIMIT = 100; export const STORY_LIST_LIMIT = 100;
export const API_GENERAL_ID_LIMIT = 100; export const API_GENERAL_ID_LIMIT = 100;
export const STATISTICS_PUBLIC_FORWARDS_LIMIT = 50;
export const RESALE_GIFTS_LIMIT = 50; export const RESALE_GIFTS_LIMIT = 50;
export const TODO_ITEMS_LIMIT = 30; export const TODO_ITEMS_LIMIT = 30;
export const TODO_TITLE_LENGTH_LIMIT = 32; export const TODO_TITLE_LENGTH_LIMIT = 32;

View File

@ -314,6 +314,24 @@ addActionHandler('loadMessage', async (global, actions, payload): Promise<void>
} }
}); });
addActionHandler('loadMessagesById', async (global, actions, payload): Promise<void> => {
const { chatId, messageIds } = payload;
const chat = selectChat(global, chatId);
if (!chat) {
return;
}
const messages = await callApi('fetchMessagesById', {
chat,
messageIds,
});
if (!messages) return;
global = getGlobal();
global = addChatMessagesById(global, chatId, buildCollectionByKey(messages, 'id'));
setGlobal(global);
});
addActionHandler('sendMessage', async (global, actions, payload): Promise<void> => { addActionHandler('sendMessage', async (global, actions, payload): Promise<void> => {
const { messageList, tabId = getCurrentTabId() } = payload; const { messageList, tabId = getCurrentTabId() } = payload;

View File

@ -1,4 +1,3 @@
import { areDeepEqual } from '../../../util/areDeepEqual';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, setGlobal } from '../../index';
@ -39,6 +38,25 @@ addActionHandler('loadStatistics', async (global, actions, payload): Promise<voi
global = getGlobal(); global = getGlobal();
global = updateStatistics(global, chatId, stats, tabId); global = updateStatistics(global, chatId, stats, tabId);
setGlobal(global); setGlobal(global);
if (stats.type === 'channel') {
const messageInteractions = stats.recentPosts.filter((post) => post.type === 'message');
const storyInteractions = stats.recentPosts.filter((post) => post.type === 'story');
if (messageInteractions.length > 0) {
actions.loadMessagesById({
chatId,
messageIds: messageInteractions.map((interaction) => interaction.msgId),
});
}
if (storyInteractions.length > 0) {
actions.loadPeerStoriesByIds({
peerId: chatId,
storyIds: storyInteractions.map((interaction) => interaction.storyId),
});
}
}
}); });
addActionHandler('loadChannelMonetizationStatistics', async (global, actions, payload): Promise<void> => { addActionHandler('loadChannelMonetizationStatistics', async (global, actions, payload): Promise<void> => {
@ -122,17 +140,11 @@ addActionHandler('loadMessagePublicForwards', async (global, actions, payload):
count, count,
} = publicForwards || {}; } = publicForwards || {};
// Api returns the last element from the previous page as the first element
const shouldOmitFirstElement = stats.publicForwardsData?.length && forwards?.length
&& areDeepEqual(stats.publicForwardsData[stats.publicForwardsData.length - 1], forwards[0]);
global = getGlobal(); global = getGlobal();
global = updateMessageStatistics(global, { global = updateMessageStatistics(global, {
...stats, ...stats,
publicForwards: count || forwards?.length, publicForwards: count || forwards?.length,
publicForwardsData: (stats.publicForwardsData || []).concat( publicForwardsData: (stats.publicForwardsData || []).concat((forwards || [])),
shouldOmitFirstElement ? forwards.slice(1) : (forwards || []),
),
nextOffset, nextOffset,
}, tabId); }, tabId);
setGlobal(global); setGlobal(global);

View File

@ -184,7 +184,9 @@ addActionHandler('toggleMessageStatistics', (global, actions, payload): ActionRe
statistics: { statistics: {
...selectTabState(global, tabId).statistics, ...selectTabState(global, tabId).statistics,
currentMessageId: messageId, currentMessageId: messageId,
currentMessage: undefined,
currentStoryId: undefined, currentStoryId: undefined,
currentStory: undefined,
}, },
}, tabId); }, tabId);
}); });
@ -196,6 +198,8 @@ addActionHandler('toggleStoryStatistics', (global, actions, payload): ActionRetu
...selectTabState(global, tabId).statistics, ...selectTabState(global, tabId).statistics,
currentStoryId: storyId, currentStoryId: storyId,
currentMessageId: undefined, currentMessageId: undefined,
currentMessage: undefined,
currentStory: undefined,
}, },
}, tabId); }, tabId);
}); });

View File

@ -504,6 +504,10 @@ export interface ActionPayloads {
isDeleting?: boolean; isDeleting?: boolean;
}; };
}; };
loadMessagesById: {
chatId: string;
messageIds: number[];
};
editMessage: { editMessage: {
messageList?: MessageList; messageList?: MessageList;
text: string; text: string;
@ -1570,7 +1574,7 @@ export interface ActionPayloads {
loadPeerStoriesByIds: { loadPeerStoriesByIds: {
peerId: string; peerId: string;
storyIds: number[]; storyIds: number[];
} & WithTabId; };
viewStory: { viewStory: {
peerId: string; peerId: string;
storyId: number; storyId: number;