Management: Group Statistics (#1774)

This commit is contained in:
Alexander Zinchuk 2022-03-19 21:20:04 +01:00
parent 6d063c2132
commit 05a59608fc
22 changed files with 302 additions and 117 deletions

View File

@ -1,12 +1,14 @@
import { Api as GramJs } from '../../../lib/gramjs'; import { Api as GramJs } from '../../../lib/gramjs';
import { import {
ApiStatistics, ApiChannelStatistics,
ApiGroupStatistics,
StatisticsGraph, StatisticsGraph,
StatisticsOverviewItem, StatisticsOverviewItem,
StatisticsOverviewPercentage, StatisticsOverviewPercentage,
StatisticsOverviewPeriod,
} from '../../types'; } from '../../types';
export function buildStatistics(stats: GramJs.stats.BroadcastStats): ApiStatistics { export function buildChannelStatistics(stats: GramJs.stats.BroadcastStats): ApiChannelStatistics {
return { return {
// Graphs // Graphs
growthGraph: buildGraph(stats.growthGraph), growthGraph: buildGraph(stats.growthGraph),
@ -31,6 +33,27 @@ export function buildStatistics(stats: GramJs.stats.BroadcastStats): ApiStatisti
}; };
} }
export function buildGroupStatistics(stats: GramJs.stats.MegagroupStats): ApiGroupStatistics {
return {
// Graphs
growthGraph: buildGraph(stats.growthGraph),
membersGraph: buildGraph(stats.membersGraph),
topHoursGraph: buildGraph(stats.topHoursGraph),
// Async graphs
languagesGraph: (stats.languagesGraph as GramJs.StatsGraphAsync).token,
messagesGraph: (stats.messagesGraph as GramJs.StatsGraphAsync).token,
actionsGraph: (stats.actionsGraph as GramJs.StatsGraphAsync).token,
// Statistics overview
period: getOverviewPeriod(stats.period),
members: buildStatisticsOverview(stats.members),
viewers: buildStatisticsOverview(stats.viewers),
messages: buildStatisticsOverview(stats.messages),
posters: buildStatisticsOverview(stats.posters),
};
}
export function buildGraph(result: GramJs.TypeStatsGraph, isPercentage?: boolean): StatisticsGraph { export function buildGraph(result: GramJs.TypeStatsGraph, isPercentage?: boolean): StatisticsGraph {
if ((result as GramJs.StatsGraphError).error) { if ((result as GramJs.StatsGraphError).error) {
throw new Error((result as GramJs.StatsGraphError).error); throw new Error((result as GramJs.StatsGraphError).error);
@ -41,7 +64,7 @@ export function buildGraph(result: GramJs.TypeStatsGraph, isPercentage?: boolean
const hasSecondYAxis = data.y_scaled; const hasSecondYAxis = data.y_scaled;
return { return {
type: getGraphType(data.types.y0, isPercentage), type: isPercentage ? 'area' : data.types.y0,
zoomToken: (result as GramJs.StatsGraph).zoomToken, zoomToken: (result as GramJs.StatsGraph).zoomToken,
labelFormatter: data.xTickFormatter, labelFormatter: data.xTickFormatter,
tooltipFormatter: data.xTooltipFormatter, tooltipFormatter: data.xTooltipFormatter,
@ -63,15 +86,6 @@ export function buildGraph(result: GramJs.TypeStatsGraph, isPercentage?: boolean
}; };
} }
function getGraphType(apiType: string, isPercentage?: boolean): string {
switch (apiType) {
case 'step':
return 'bar';
default:
return isPercentage ? 'area' : apiType;
}
}
function extractColor(color: string): string { function extractColor(color: string): string {
return color.substring(color.indexOf('#')); return color.substring(color.indexOf('#'));
} }
@ -113,3 +127,10 @@ function buildStatisticsPercentage(data: GramJs.StatsPercentValue): StatisticsOv
percentage: ((data.part / data.total) * 100).toFixed(2), percentage: ((data.part / data.total) * 100).toFixed(2),
}; };
} }
function getOverviewPeriod(data: GramJs.StatsDateRangeDays): StatisticsOverviewPeriod {
return {
maxDate: data.maxDate,
minDate: data.minDate,
};
}

View File

@ -77,4 +77,4 @@ export {
setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction, setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction,
} from './reactions'; } from './reactions';
export { fetchStatistics, fetchStatisticsAsyncGraph } from './statistics'; export { fetchChannelStatistics, fetchGroupStatistics, fetchStatisticsAsyncGraph } from './statistics';

View File

@ -1,13 +1,17 @@
import BigInt from 'big-integer'; import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs'; import { Api as GramJs } from '../../../lib/gramjs';
import { ApiChat, ApiStatistics, StatisticsGraph } from '../../types'; import {
ApiChat, ApiChannelStatistics, ApiGroupStatistics, StatisticsGraph,
} from '../../types';
import { invokeRequest } from './client'; import { invokeRequest } from './client';
import { buildInputEntity } from '../gramjsBuilders'; import { buildInputEntity } from '../gramjsBuilders';
import { buildStatistics, buildGraph } from '../apiBuilders/statistics'; import { buildChannelStatistics, buildGroupStatistics, buildGraph } from '../apiBuilders/statistics';
export async function fetchStatistics({ chat }: { chat: ApiChat }): Promise<ApiStatistics | undefined> { export async function fetchChannelStatistics({
chat,
}: { chat: ApiChat }): Promise<ApiChannelStatistics | undefined> {
const result = await invokeRequest(new GramJs.stats.GetBroadcastStats({ const result = await invokeRequest(new GramJs.stats.GetBroadcastStats({
channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel, channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel,
}), undefined, undefined, undefined, chat.fullInfo!.statisticsDcId); }), undefined, undefined, undefined, chat.fullInfo!.statisticsDcId);
@ -16,7 +20,21 @@ export async function fetchStatistics({ chat }: { chat: ApiChat }): Promise<ApiS
return undefined; return undefined;
} }
return buildStatistics(result); return buildChannelStatistics(result);
}
export async function fetchGroupStatistics({
chat,
}: { chat: ApiChat }): Promise<ApiGroupStatistics | undefined> {
const result = await invokeRequest(new GramJs.stats.GetMegagroupStats({
channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel,
}), undefined, undefined, undefined, chat.fullInfo!.statisticsDcId);
if (!result) {
return undefined;
}
return buildGroupStatistics(result);
} }
export async function fetchStatisticsAsyncGraph({ export async function fetchStatisticsAsyncGraph({

View File

@ -1,6 +1,6 @@
import { ApiMessage } from './messages'; import { ApiMessage } from './messages';
export interface ApiStatistics { export interface ApiChannelStatistics {
growthGraph: StatisticsGraph; growthGraph: StatisticsGraph;
followersGraph: StatisticsGraph; followersGraph: StatisticsGraph;
muteGraph: StatisticsGraph; muteGraph: StatisticsGraph;
@ -16,6 +16,20 @@ export interface ApiStatistics {
recentTopMessages: Array<StatisticsRecentMessage | StatisticsRecentMessage & ApiMessage>; recentTopMessages: Array<StatisticsRecentMessage | StatisticsRecentMessage & ApiMessage>;
} }
export interface ApiGroupStatistics {
growthGraph: StatisticsGraph;
membersGraph: StatisticsGraph;
topHoursGraph: StatisticsGraph;
languagesGraph: StatisticsGraph | string;
messagesGraph: StatisticsGraph | string;
actionsGraph: StatisticsGraph | string;
period: StatisticsOverviewPeriod;
members: StatisticsOverviewItem;
viewers: StatisticsOverviewItem;
messages: StatisticsOverviewItem;
posters: StatisticsOverviewItem;
}
export interface StatisticsGraph { export interface StatisticsGraph {
type: string; type: string;
zoomToken?: string; zoomToken?: string;
@ -49,6 +63,11 @@ export interface StatisticsOverviewPercentage {
percentage: string; percentage: string;
} }
export interface StatisticsOverviewPeriod {
maxDate: number;
minDate: number;
}
export interface StatisticsRecentMessage { export interface StatisticsRecentMessage {
msgId: number; msgId: number;
forwards: number; forwards: number;

View File

@ -3,8 +3,9 @@
overflow-x: hidden; overflow-x: hidden;
overflow-y: hidden; overflow-y: hidden;
&--messages { &__messages {
padding: 1rem 0.75rem; padding: 1rem 0.75rem;
border-top: 1px solid var(--color-borders);
&-title { &-title {
padding-left: 0.25rem; padding-left: 0.25rem;
@ -23,13 +24,18 @@
overflow-y: scroll !important; overflow-y: scroll !important;
} }
.chat-container { &__graph {
margin-bottom: 1rem; margin-bottom: 1rem;
border-bottom: 1px solid var(--color-borders); border-bottom: 1px solid var(--color-borders);
opacity: 1; opacity: 1;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
&:last-of-type {
margin-bottom: 0;
border-bottom: none;
}
&.hidden { &.hidden {
opacity: 0; opacity: 0;
} }

View File

@ -1,11 +1,14 @@
import React, { import React, {
FC, memo, useState, useEffect, useRef, FC, memo, useState, useEffect, useRef, useMemo,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { import {
ApiMessage, ApiStatistics, StatisticsRecentMessage as StatisticsRecentMessageType, StatisticsGraph, ApiMessage,
ApiChannelStatistics,
ApiGroupStatistics,
StatisticsRecentMessage as StatisticsRecentMessageType,
} from '../../../api/types'; } from '../../../api/types';
import { selectChat, selectStatistics } from '../../../global/selectors'; import { selectChat, selectStatistics } from '../../../global/selectors';
@ -31,7 +34,7 @@ async function ensureLovelyChart() {
return lovelyChartPromise; return lovelyChartPromise;
} }
const GRAPHS_TITLES = { const CHANNEL_GRAPHS_TITLES = {
growthGraph: 'ChannelStats.Graph.Growth', growthGraph: 'ChannelStats.Graph.Growth',
followersGraph: 'ChannelStats.Graph.Followers', followersGraph: 'ChannelStats.Graph.Followers',
muteGraph: 'ChannelStats.Graph.Notifications', muteGraph: 'ChannelStats.Graph.Notifications',
@ -41,7 +44,17 @@ const GRAPHS_TITLES = {
languagesGraph: 'ChannelStats.Graph.Language', languagesGraph: 'ChannelStats.Graph.Language',
interactionsGraph: 'ChannelStats.Graph.Interactions', interactionsGraph: 'ChannelStats.Graph.Interactions',
}; };
const GRAPHS = Object.keys(GRAPHS_TITLES) as (keyof ApiStatistics)[]; const CHANNEL_GRAPHS = Object.keys(CHANNEL_GRAPHS_TITLES) as (keyof ApiChannelStatistics)[];
const GROUP_GRAPHS_TITLES = {
growthGraph: 'Stats.GroupGrowthTitle',
membersGraph: 'Stats.GroupMembersTitle',
languagesGraph: 'Stats.GroupLanguagesTitle',
messagesGraph: 'Stats.GroupMessagesTitle',
actionsGraph: 'Stats.GroupActionsTitle',
topHoursGraph: 'Stats.GroupTopHoursTitle',
};
const GROUP_GRAPHS = Object.keys(GROUP_GRAPHS_TITLES) as (keyof ApiGroupStatistics)[];
export type OwnProps = { export type OwnProps = {
chatId: string; chatId: string;
@ -49,12 +62,17 @@ export type OwnProps = {
}; };
export type StateProps = { export type StateProps = {
statistics: ApiStatistics; statistics: ApiChannelStatistics | ApiGroupStatistics;
dcId?: number; dcId?: number;
isGroup: boolean;
}; };
const Statistics: FC<OwnProps & StateProps> = ({ const Statistics: FC<OwnProps & StateProps> = ({
chatId, isActive, statistics, dcId, chatId,
isActive,
statistics,
dcId,
isGroup,
}) => { }) => {
const lang = useLang(); const lang = useLang();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -65,8 +83,8 @@ const Statistics: FC<OwnProps & StateProps> = ({
const { loadStatistics, loadStatisticsAsyncGraph } = getActions(); const { loadStatistics, loadStatisticsAsyncGraph } = getActions();
useEffect(() => { useEffect(() => {
loadStatistics({ chatId }); loadStatistics({ chatId, isGroup });
}, [chatId, loadStatistics]); }, [chatId, loadStatistics, isGroup]);
useEffect(() => { useEffect(() => {
if (!isActive) { if (!isActive) {
@ -74,25 +92,35 @@ const Statistics: FC<OwnProps & StateProps> = ({
} }
}, [isActive]); }, [isActive]);
const graphs = useMemo(() => {
return isGroup ? GROUP_GRAPHS : CHANNEL_GRAPHS;
}, [isGroup]);
const graphTitles = useMemo(() => {
return isGroup ? GROUP_GRAPHS_TITLES : CHANNEL_GRAPHS_TITLES;
}, [isGroup]);
// Load async graphs // Load async graphs
useEffect(() => { useEffect(() => {
if (!statistics) { if (!statistics) {
return; return;
} }
GRAPHS.forEach((graph) => { graphs.forEach((name) => {
const isAsync = typeof statistics?.[graph] === 'string'; const graph = statistics[name as keyof typeof statistics];
const isAsync = typeof graph === 'string';
if (isAsync) { if (isAsync) {
loadStatisticsAsyncGraph({ loadStatisticsAsyncGraph({
name: graph, name,
chatId, chatId,
token: statistics[graph], token: graph,
// 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: graph === 'languagesGraph', isPercentage: name === 'languagesGraph',
}); });
} }
}); });
}, [chatId, statistics, loadStatisticsAsyncGraph]); }, [graphs, chatId, statistics, loadStatisticsAsyncGraph]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@ -107,30 +135,32 @@ const Statistics: FC<OwnProps & StateProps> = ({
return; return;
} }
GRAPHS.forEach((graph, index: number) => { graphs.forEach((name, index: number) => {
const isAsync = typeof statistics?.[graph] === 'string'; const graph = statistics[name as keyof typeof statistics];
if (isAsync || loadedCharts.current.includes(graph)) { const isAsync = typeof graph === 'string';
if (isAsync || loadedCharts.current.includes(name)) {
return; return;
} }
const { zoomToken } = (statistics[graph] as StatisticsGraph); const { zoomToken } = graph;
LovelyChart.create( LovelyChart.create(
containerRef.current!.children[index], containerRef.current!.children[index],
{ {
title: lang((GRAPHS_TITLES as Record<string, string>)[graph]), title: lang((graphTitles as Record<string, string>)[name]),
...zoomToken && { ...zoomToken && {
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'),
}, },
...(statistics[graph] as StatisticsGraph), ...graph,
}, },
); );
loadedCharts.current.push(graph); loadedCharts.current.push(name);
}); });
})(); })();
}, [isReady, statistics, lang, chatId, loadStatisticsAsyncGraph, dcId]); }, [graphs, graphTitles, isReady, statistics, lang, chatId, loadStatisticsAsyncGraph, dcId]);
if (!isReady || !statistics) { if (!isReady || !statistics) {
return <Loading />; return <Loading />;
@ -138,21 +168,21 @@ const Statistics: FC<OwnProps & StateProps> = ({
return ( return (
<div className={buildClassName('Statistics custom-scroll', isReady && 'ready')}> <div className={buildClassName('Statistics custom-scroll', isReady && 'ready')}>
<StatisticsOverview statistics={statistics} /> <StatisticsOverview statistics={statistics} isGroup={isGroup} />
{!loadedCharts.current.length && <Loading />} {!loadedCharts.current.length && <Loading />}
<div ref={containerRef}> <div ref={containerRef}>
{GRAPHS.map((graph) => ( {graphs.map((graph) => (
<div className={buildClassName('chat-container', !loadedCharts.current.includes(graph) && 'hidden')} /> <div className={buildClassName('Statistics__graph', !loadedCharts.current.includes(graph) && 'hidden')} />
))} ))}
</div> </div>
{Boolean(statistics.recentTopMessages?.length) && ( {Boolean((statistics as ApiChannelStatistics).recentTopMessages?.length) && (
<div className="Statistics--messages"> <div className="Statistics__messages">
<h2 className="Statistics--messages-title">{lang('ChannelStats.Recent.Header')}</h2> <h2 className="Statistics__messages-title">{lang('ChannelStats.Recent.Header')}</h2>
{statistics.recentTopMessages.map((message) => ( {(statistics as ApiChannelStatistics).recentTopMessages.map((message) => (
<StatisticsRecentMessage message={message as ApiMessage & StatisticsRecentMessageType} /> <StatisticsRecentMessage message={message as ApiMessage & StatisticsRecentMessageType} />
))} ))}
</div> </div>
@ -164,8 +194,10 @@ const Statistics: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => { (global, { chatId }): StateProps => {
const statistics = selectStatistics(global, chatId); const statistics = selectStatistics(global, chatId);
const dcId = selectChat(global, chatId)?.fullInfo?.statisticsDcId; const chat = selectChat(global, chatId);
const dcId = chat?.fullInfo?.statisticsDcId;
const isGroup = chat?.type === 'chatTypeSuperGroup';
return { statistics, dcId }; return { statistics, dcId, isGroup };
}, },
)(Statistics)); )(Statistics));

View File

@ -3,7 +3,16 @@
margin-bottom: 1rem; margin-bottom: 1rem;
border-bottom: 1px solid var(--color-borders); border-bottom: 1px solid var(--color-borders);
&--title { &__header {
margin-bottom: 0.5rem;
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
}
&__title {
margin-right: 2em;
padding-left: 0.25rem; padding-left: 0.25rem;
font-size: 16px; font-size: 16px;
color: var(--text-color); color: var(--text-color);
@ -15,7 +24,12 @@
} }
} }
&--table { &__caption {
font-size: 0.75rem;
text-align: right;
}
&__table {
width: 100%; width: 100%;
&-heading { &-heading {
@ -29,7 +43,7 @@
} }
} }
&--value { &__value {
font-size: 0.6875rem; font-size: 0.6875rem;
color: var(--color-text-green); color: var(--color-text-green);

View File

@ -1,25 +1,60 @@
import React, { FC, memo } from '../../../lib/teact/teact'; import React, { FC, memo } from '../../../lib/teact/teact';
import { ApiStatistics, StatisticsOverviewItem } from '../../../api/types'; import { ApiChannelStatistics, ApiGroupStatistics, StatisticsOverviewItem } from '../../../api/types';
import { formatIntegerCompact } from '../../../util/textFormat';
import { formatFullDate } from '../../../util/dateFormat';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import './StatisticsOverview.scss'; import './StatisticsOverview.scss';
export type OwnProps = { type OverviewCell = {
statistics: ApiStatistics; name: string;
title: string;
isPercentage?: boolean;
}; };
const StatisticsOverview: FC<OwnProps> = ({ statistics }) => { const CHANNEL_OVERVIEW: OverviewCell[][] = [
[
{ name: 'followers', title: 'ChannelStats.Overview.Followers' },
{ name: 'enabledNotifications', title: 'ChannelStats.Overview.EnabledNotifications', isPercentage: true },
],
[
{ name: 'viewsPerPost', title: 'ChannelStats.Overview.ViewsPerPost' },
{ name: 'sharesPerPost', title: 'ChannelStats.Overview.SharesPerPost' },
],
];
const GROUP_OVERVIEW: OverviewCell[][] = [
[
{ name: 'members', title: 'Stats.GroupMembers' },
{ name: 'messages', title: 'Stats.GroupMessages' },
],
[
{ name: 'viewers', title: 'Stats.GroupViewers' },
{ name: 'posters', title: 'Stats.GroupPosters' },
],
];
export type OwnProps = {
isGroup?: boolean;
statistics: ApiChannelStatistics | ApiGroupStatistics;
};
const StatisticsOverview: FC<OwnProps> = ({ isGroup, statistics }) => {
const lang = useLang(); const lang = useLang();
const renderOverviewItemValue = ({ change, percentage }: StatisticsOverviewItem) => { const renderOverviewItemValue = ({ change, percentage }: StatisticsOverviewItem) => {
if (!change) {
return undefined;
}
const isChangeNegative = Number(change) < 0; const isChangeNegative = Number(change) < 0;
return ( return (
<span className={buildClassName('StatisticsOverview--value', isChangeNegative && 'negative')}> <span className={buildClassName('StatisticsOverview__value', isChangeNegative && 'negative')}>
{isChangeNegative ? change : `+${change}`} {isChangeNegative ? `-${formatIntegerCompact(Math.abs(change))}` : `+${formatIntegerCompact(change)}`}
{percentage && ( {percentage && (
<> <>
{' '} {' '}
@ -30,40 +65,48 @@ const StatisticsOverview: FC<OwnProps> = ({ statistics }) => {
); );
}; };
const { const { period } = (statistics as ApiGroupStatistics);
followers, viewsPerPost, sharesPerPost, enabledNotifications,
} = statistics;
return ( return (
<div className="StatisticsOverview"> <div className="StatisticsOverview">
<h2 className="StatisticsOverview--title">{lang('ChannelStats.Overview')}</h2> <div className="StatisticsOverview__header">
<div className="StatisticsOverview__title">{lang('ChannelStats.Overview')}</div>
<table className="StatisticsOverview--table"> {period && (
<tr> <div className="StatisticsOverview__caption">
<td> {formatFullDate(lang, period.minDate * 1000)} {formatFullDate(lang, period.maxDate * 1000)}
<b className="StatisticsOverview--table-value">{followers.current}</b> {renderOverviewItemValue(followers)} </div>
<h3 className="StatisticsOverview--table-heading">{lang('ChannelStats.Overview.Followers')}</h3> )}
</td> </div>
<td>
<b className="StatisticsOverview--table-value">{enabledNotifications.percentage}%</b>
<h3 className="StatisticsOverview--table-heading">{lang('ChannelStats.Overview.EnabledNotifications')}</h3>
</td>
</tr>
<tr> <table className="StatisticsOverview__table">
<td> {(isGroup ? GROUP_OVERVIEW : CHANNEL_OVERVIEW).map((row) => (
<b className="StatisticsOverview--table-value">{viewsPerPost.current}</b> <tr>
{' '} {row.map((cell: OverviewCell) => {
{renderOverviewItemValue(viewsPerPost)} const field = (statistics as any)[cell.name];
<h3 className="StatisticsOverview--table-heading">{lang('ChannelStats.Overview.ViewsPerPost')}</h3>
</td> if (cell.isPercentage) {
<td> return (
<b className="StatisticsOverview--table-value">{sharesPerPost.current}</b> <td>
{' '} <b className="StatisticsOverview__table-value">{field.percentage}%</b>
{renderOverviewItemValue(sharesPerPost)} <h3 className="StatisticsOverview__table-heading">{lang(cell.title)}</h3>
<h3 className="StatisticsOverview--table-heading">{lang('ChannelStats.Overview.SharesPerPost')}</h3> </td>
</td> );
</tr> }
return (
<td>
<b className="StatisticsOverview__table-value">
{formatIntegerCompact(field.current)}
</b>
{' '}
{renderOverviewItemValue(field)}
<h3 className="StatisticsOverview__table-heading">{lang(cell.title)}</h3>
</td>
);
})}
</tr>
))}
</table> </table>
</div> </div>
); );

View File

@ -2,14 +2,14 @@
position: relative; position: relative;
padding-left: 3rem; padding-left: 3rem;
&--summary { &__summary {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
margin-right: 0.75rem; margin-right: 0.75rem;
.media-preview--image { .media-preview__image {
width: 2.5rem; width: 2.5rem;
height: 2.5rem; height: 2.5rem;
position: absolute; position: absolute;
@ -35,24 +35,24 @@
} }
} }
&--title { &__title {
display: flex; display: flex;
align-items: center; align-items: center;
line-height: 1.25rem; line-height: 1.25rem;
} }
&--info { &__info {
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; width: 100%;
color: var(--color-text-meta); color: var(--color-text-meta);
} }
&--meta { &__meta {
font-size: 0.75rem; font-size: 0.75rem;
} }
&--date { &__date {
flex: 1; flex: 1;
font-size: 0.8125rem; font-size: 0.8125rem;
} }

View File

@ -30,20 +30,20 @@ const StatisticsRecentMessage: FC<OwnProps> = ({ message }) => {
return ( return (
<p className="StatisticsRecentMessage"> <p className="StatisticsRecentMessage">
<div className="StatisticsRecentMessage--title"> <div className="StatisticsRecentMessage__title">
<div className="StatisticsRecentMessage--summary"> <div className="StatisticsRecentMessage__summary">
{renderSummary(lang, message, mediaBlobUrl || mediaThumbnail, isRoundVideo)} {renderSummary(lang, message, mediaBlobUrl || mediaThumbnail, isRoundVideo)}
</div> </div>
<div className="StatisticsRecentMessage--meta"> <div className="StatisticsRecentMessage__meta">
{lang('ChannelStats.ViewsCount', message.views)} {lang('ChannelStats.ViewsCount', message.views)}
</div> </div>
</div> </div>
<div className="StatisticsRecentMessage--info"> <div className="StatisticsRecentMessage__info">
<div className="StatisticsRecentMessage--date"> <div className="StatisticsRecentMessage__date">
{formatDateTimeToString(message.date * 1000, lang.code)} {formatDateTimeToString(message.date * 1000, lang.code)}
</div> </div>
<div className="StatisticsRecentMessage--meta"> <div className="StatisticsRecentMessage__meta">
{message.forwards ? lang('ChannelStats.SharesCount', message.forwards) : 'No shares'} {message.forwards ? lang('ChannelStats.SharesCount', message.forwards) : 'No shares'}
</div> </div>
</div> </div>
@ -58,7 +58,7 @@ function renderSummary(lang: LangFn, message: ApiMessage, blobUrl?: string, isRo
return ( return (
<span className="media-preview"> <span className="media-preview">
<img src={blobUrl} alt="" className={buildClassName('media-preview--image', isRoundVideo && 'round')} /> <img src={blobUrl} alt="" className={buildClassName('media-preview__image', isRoundVideo && 'round')} />
{getMessageVideo(message) && <i className="icon-play" />} {getMessageVideo(message) && <i className="icon-play" />}
{renderMessageSummary(lang, message, true)} {renderMessageSummary(lang, message, true)}
</span> </span>

View File

@ -1,27 +1,28 @@
import { addActionHandler, getGlobal } from '../../index'; import { addActionHandler, getGlobal } from '../../index';
import { ApiChannelStatistics } from '../../../api/types';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { updateStatistics, updateStatisticsGraph } from '../../reducers'; import { updateStatistics, updateStatisticsGraph } from '../../reducers';
import { selectChatMessages, selectChat } from '../../selectors'; import { selectChatMessages, selectChat } from '../../selectors';
addActionHandler('loadStatistics', async (global, actions, payload) => { addActionHandler('loadStatistics', async (global, actions, payload) => {
const { chatId } = payload; const { chatId, isGroup } = payload;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
if (!chat?.fullInfo) { if (!chat?.fullInfo) {
return undefined; return undefined;
} }
const result = await callApi('fetchStatistics', { chat }); const result = await callApi(isGroup ? 'fetchGroupStatistics' : 'fetchChannelStatistics', { chat });
if (!result) { if (!result) {
return undefined; return undefined;
} }
global = getGlobal(); global = getGlobal();
if (result?.recentTopMessages.length) { if ((result as ApiChannelStatistics).recentTopMessages?.length) {
const messages = selectChatMessages(global, chatId); const messages = selectChatMessages(global, chatId);
result.recentTopMessages = result.recentTopMessages (result as ApiChannelStatistics).recentTopMessages = (result as ApiChannelStatistics).recentTopMessages
.map((message) => ({ ...message, ...messages[message.msgId] })); .map((message) => ({ ...message, ...messages[message.msgId] }));
} }

View File

@ -33,6 +33,7 @@ addActionHandler('openChat', (global, actions, payload) => {
global = { global = {
...global, ...global,
isStatisticsShown: false,
messages: { messages: {
...global.messages, ...global.messages,
contentToBeScheduled: undefined, contentToBeScheduled: undefined,

View File

@ -1,8 +1,8 @@
import { GlobalState } from '../types'; import { GlobalState } from '../types';
import { ApiStatistics, StatisticsGraph } from '../../api/types'; import { ApiChannelStatistics, ApiGroupStatistics, StatisticsGraph } from '../../api/types';
export function updateStatistics( export function updateStatistics(
global: GlobalState, chatId: string, statistics: ApiStatistics, global: GlobalState, chatId: string, statistics: ApiChannelStatistics | ApiGroupStatistics,
): GlobalState { ): GlobalState {
return { return {
...global, ...global,

View File

@ -26,7 +26,8 @@ import {
ApiAvailableReaction, ApiAvailableReaction,
ApiAppConfig, ApiAppConfig,
ApiSponsoredMessage, ApiSponsoredMessage,
ApiStatistics, ApiChannelStatistics,
ApiGroupStatistics,
ApiPaymentFormNativeParams, ApiUpdate, ApiPaymentFormNativeParams, ApiUpdate,
} from '../api/types'; } from '../api/types';
import { import {
@ -508,7 +509,7 @@ export type GlobalState = {
serviceNotifications: ServiceNotification[]; serviceNotifications: ServiceNotification[];
statistics: { statistics: {
byChatId: Record<string, ApiStatistics>; byChatId: Record<string, ApiChannelStatistics | ApiGroupStatistics>;
}; };
newContact?: { newContact?: {

View File

@ -1170,4 +1170,5 @@ langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector<strin
langpack.getLanguages#42c6978f lang_pack:string = Vector<LangPackLanguage>; langpack.getLanguages#42c6978f lang_pack:string = Vector<LangPackLanguage>;
folders.editPeerFolders#6847d0ab folder_peers:Vector<InputFolderPeer> = Updates; folders.editPeerFolders#6847d0ab folder_peers:Vector<InputFolderPeer> = Updates;
stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats;
stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph;`; stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph;
stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats;`;

View File

@ -211,5 +211,6 @@
"messages.setDefaultReaction", "messages.setDefaultReaction",
"help.getAppConfig", "help.getAppConfig",
"stats.getBroadcastStats", "stats.getBroadcastStats",
"stats.getMegagroupStats",
"stats.loadAsyncGraph" "stats.loadAsyncGraph"
] ]

View File

@ -152,7 +152,7 @@ function create(container, originalData) {
} }
function _onFocus(focusOn) { function _onFocus(focusOn) {
if (_data.isBars || _data.isPie) { if (_data.isBars || _data.isPie || _data.isSteps) {
// TODO animate // TODO animate
_stateManager.update({ focusOn }); _stateManager.update({ focusOn });
} }

View File

@ -143,7 +143,6 @@ function calculateYRanges(data, filter, labelFromIndex, labelToIndex, prevState)
const yRanges = calculateYRangesForGroup(data, labelFromIndex, labelToIndex, prevState, filteredDatasets); const yRanges = calculateYRangesForGroup(data, labelFromIndex, labelToIndex, prevState, filteredDatasets);
if (secondaryYAxisDataset) { if (secondaryYAxisDataset) {
const group = filter[secondaryYAxisDataset.key] ? [secondaryYAxisDataset] : [];
const { const {
yMinViewport: yMinViewportSecond, yMinViewport: yMinViewportSecond,
yMaxViewport: yMaxViewportSecond, yMaxViewport: yMaxViewportSecond,

View File

@ -408,7 +408,7 @@ export function createTooltip(container, data, plotSize, colors, onZoom, onFocus
} }
}); });
if (data.isBars && data.isStacked) { if ((data.isBars || data.isSteps) && data.isStacked) {
_renderTotal(dataSetContainer, formatInteger(totalValue)); _renderTotal(dataSetContainer, formatInteger(totalValue));
} }

View File

@ -48,6 +48,7 @@ export function analyzeData(data) {
onZoom, onZoom,
isLines: data.type === 'line', isLines: data.type === 'line',
isBars: data.type === 'bar', isBars: data.type === 'bar',
isSteps: data.type === 'step',
isAreas: data.type === 'area', isAreas: data.type === 'area',
isPie: data.type === 'pie', isPie: data.type === 'pie',
yMin: totalYMin, yMin: totalYMin,

View File

@ -58,14 +58,14 @@ export function drawDatasets(
drawDataset(datasetType, context, datasetPoints, datasetProjection, options); drawDataset(datasetType, context, datasetPoints, datasetProjection, options);
}); });
if (state.focusOn && data.isBars) { if (state.focusOn && (data.isBars || data.isSteps)) {
const [x0] = toPixels(projection, 0, 0); const [x0] = toPixels(projection, 0, 0);
const [x1] = toPixels(projection, 1, 0); const [x1] = toPixels(projection, 1, 0);
drawBarsMask(context, projection, { drawBarsMask(context, projection, {
focusOn: state.focusOn, focusOn: state.focusOn,
color: getCssColor(colors, 'mask'), color: getCssColor(colors, 'mask'),
lineWidth: x1 - x0, lineWidth: data.isSteps ? x1 - x0 + lineWidth : x1 - x0,
}); });
} }
} }
@ -76,6 +76,8 @@ function drawDataset(type, ...args) {
return drawDatasetLine(...args); return drawDatasetLine(...args);
case 'bar': case 'bar':
return drawDatasetBars(...args); return drawDatasetBars(...args);
case 'step':
return drawDatasetSteps(...args);
case 'area': case 'area':
return drawDatasetArea(...args); return drawDatasetArea(...args);
case 'pie': case 'pie':
@ -138,6 +140,31 @@ function drawDatasetBars(context, points, projection, options) {
context.restore(); context.restore();
} }
function drawDatasetSteps(context, points, projection, options) {
context.beginPath();
let pixels = [];
for (let j = 0, l = points.length; j < l; j++) {
const { labelIndex, stackValue } = points[j];
pixels.push(
toPixels(projection, labelIndex - PLOT_BARS_WIDTH_SHIFT, stackValue),
toPixels(projection, labelIndex + PLOT_BARS_WIDTH_SHIFT, stackValue),
);
}
pixels.forEach(([x, y]) => {
context.lineTo(x, y);
});
context.save();
context.strokeStyle = options.color;
context.lineWidth = options.lineWidth;
context.globalAlpha = options.opacity;
context.stroke();
context.restore();
}
function drawBarsMask(context, projection, options) { function drawBarsMask(context, projection, options) {
const [xCenter, yCenter] = projection.getCenter(); const [xCenter, yCenter] = projection.getCenter();
const [width, height] = projection.getSize(); const [width, height] = projection.getSize();

View File

@ -17,7 +17,7 @@
&-title { &-title {
font-size: 16px; font-size: 16px;
float: left; float: left;
margin-right: 1em; margin-right: 2em;
text-transform: lowercase; text-transform: lowercase;
&:first-letter { &:first-letter {