Statistics: Posts and messages (#1801)

This commit is contained in:
Alexander Zinchuk 2022-04-19 15:12:26 +02:00
parent c3318d93e6
commit 47a26ab9f1
29 changed files with 410 additions and 43 deletions

View File

@ -137,7 +137,7 @@ type UniversalMessage = (
& Pick<Partial<GramJs.Message & GramJs.MessageService>, ( & Pick<Partial<GramJs.Message & GramJs.MessageService>, (
'out' | 'message' | 'entities' | 'fromId' | 'peerId' | 'fwdFrom' | 'replyTo' | 'replyMarkup' | 'post' | 'out' | 'message' | 'entities' | 'fromId' | 'peerId' | 'fwdFrom' | 'replyTo' | 'replyMarkup' | 'post' |
'media' | 'action' | 'views' | 'editDate' | 'editHide' | 'mediaUnread' | 'groupedId' | 'mentioned' | 'viaBotId' | 'media' | 'action' | 'views' | 'editDate' | 'editHide' | 'mediaUnread' | 'groupedId' | 'mentioned' | 'viaBotId' |
'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards' | 'reactions' 'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards' | 'reactions' | 'forwards'
)> )>
); );
@ -172,6 +172,7 @@ export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalM
date: mtpMessage.date, date: mtpMessage.date,
senderId: fromId || (mtpMessage.out && mtpMessage.post && currentUserId) || chatId, senderId: fromId || (mtpMessage.out && mtpMessage.post && currentUserId) || chatId,
views: mtpMessage.views, views: mtpMessage.views,
forwards: mtpMessage.forwards,
isFromScheduled: mtpMessage.fromScheduled, isFromScheduled: mtpMessage.fromScheduled,
reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions), reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions),
...(replyToMsgId && { replyToMessageId: replyToMsgId }), ...(replyToMsgId && { replyToMessageId: replyToMsgId }),

View File

@ -2,6 +2,7 @@ import { Api as GramJs } from '../../../lib/gramjs';
import { import {
ApiChannelStatistics, ApiChannelStatistics,
ApiGroupStatistics, ApiGroupStatistics,
ApiMessageStatistics,
StatisticsGraph, StatisticsGraph,
StatisticsOverviewItem, StatisticsOverviewItem,
StatisticsOverviewPercentage, StatisticsOverviewPercentage,
@ -54,9 +55,17 @@ export function buildGroupStatistics(stats: GramJs.stats.MegagroupStats): ApiGro
}; };
} }
export function buildGraph(result: GramJs.TypeStatsGraph, isPercentage?: boolean): StatisticsGraph { export function buildMessageStatistics(stats: GramJs.stats.MessageStats): ApiMessageStatistics {
return {
viewsGraph: buildGraph(stats.viewsGraph),
};
}
export function buildGraph(
result: GramJs.TypeStatsGraph, isPercentage?: boolean
): StatisticsGraph | undefined {
if ((result as GramJs.StatsGraphError).error) { if ((result as GramJs.StatsGraphError).error) {
throw new Error((result as GramJs.StatsGraphError).error); return undefined;
} }
const data = JSON.parse((result as GramJs.StatsGraph).json.data); const data = JSON.parse((result as GramJs.StatsGraph).json.data);

View File

@ -83,7 +83,9 @@ export {
setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction, setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction,
} from './reactions'; } from './reactions';
export { fetchChannelStatistics, fetchGroupStatistics, fetchStatisticsAsyncGraph } from './statistics'; export {
fetchChannelStatistics, fetchGroupStatistics, fetchMessageStatistics, fetchStatisticsAsyncGraph,
} from './statistics';
export { export {
acceptPhoneCall, confirmPhoneCall, requestPhoneCall, decodePhoneCallData, createPhoneCallState, acceptPhoneCall, confirmPhoneCall, requestPhoneCall, decodePhoneCallData, createPhoneCallState,

View File

@ -2,12 +2,14 @@ import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs'; import { Api as GramJs } from '../../../lib/gramjs';
import { import {
ApiChat, ApiChannelStatistics, ApiGroupStatistics, StatisticsGraph, ApiChat, ApiChannelStatistics, ApiGroupStatistics, ApiMessageStatistics, StatisticsGraph,
} from '../../types'; } from '../../types';
import { invokeRequest } from './client'; import { invokeRequest } from './client';
import { buildInputEntity } from '../gramjsBuilders'; import { buildInputEntity } from '../gramjsBuilders';
import { buildChannelStatistics, buildGroupStatistics, buildGraph } from '../apiBuilders/statistics'; import {
buildChannelStatistics, buildGroupStatistics, buildMessageStatistics, buildGraph,
} from '../apiBuilders/statistics';
export async function fetchChannelStatistics({ export async function fetchChannelStatistics({
chat, chat,
@ -37,6 +39,25 @@ export async function fetchGroupStatistics({
return buildGroupStatistics(result); return buildGroupStatistics(result);
} }
export async function fetchMessageStatistics({
chat,
messageId,
}: {
chat: ApiChat;
messageId: number;
}): Promise<ApiMessageStatistics | undefined> {
const result = await invokeRequest(new GramJs.stats.GetMessageStats({
channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel,
msgId: messageId,
}), undefined, undefined, undefined, chat.fullInfo!.statisticsDcId);
if (!result) {
return undefined;
}
return buildMessageStatistics(result);
}
export async function fetchStatisticsAsyncGraph({ export async function fetchStatisticsAsyncGraph({
token, token,
x, x,

View File

@ -297,6 +297,7 @@ export interface ApiMessage {
isDeleting?: boolean; isDeleting?: boolean;
previousLocalId?: number; previousLocalId?: number;
views?: number; views?: number;
forwards?: number;
isEdited?: boolean; isEdited?: boolean;
editDate?: number; editDate?: number;
isMentioned?: boolean; isMentioned?: boolean;

View File

@ -1,10 +1,10 @@
import { ApiMessage } from './messages'; import { ApiMessage } from './messages';
export interface ApiChannelStatistics { export interface ApiChannelStatistics {
growthGraph: StatisticsGraph; growthGraph?: StatisticsGraph | string;
followersGraph: StatisticsGraph; followersGraph?: StatisticsGraph | string;
muteGraph: StatisticsGraph; muteGraph?: StatisticsGraph | string;
topHoursGraph: StatisticsGraph; topHoursGraph?: StatisticsGraph | string;
interactionsGraph: StatisticsGraph | string; interactionsGraph: StatisticsGraph | string;
viewsBySourceGraph: StatisticsGraph | string; viewsBySourceGraph: StatisticsGraph | string;
newFollowersBySourceGraph: StatisticsGraph | string; newFollowersBySourceGraph: StatisticsGraph | string;
@ -17,9 +17,9 @@ export interface ApiChannelStatistics {
} }
export interface ApiGroupStatistics { export interface ApiGroupStatistics {
growthGraph: StatisticsGraph; growthGraph?: StatisticsGraph | string;
membersGraph: StatisticsGraph; membersGraph?: StatisticsGraph | string;
topHoursGraph: StatisticsGraph; topHoursGraph?: StatisticsGraph | string;
languagesGraph: StatisticsGraph | string; languagesGraph: StatisticsGraph | string;
messagesGraph: StatisticsGraph | string; messagesGraph: StatisticsGraph | string;
actionsGraph: StatisticsGraph | string; actionsGraph: StatisticsGraph | string;
@ -30,6 +30,12 @@ export interface ApiGroupStatistics {
posters: StatisticsOverviewItem; posters: StatisticsOverviewItem;
} }
export interface ApiMessageStatistics {
viewsGraph?: StatisticsGraph | string;
forwards?: number;
views?: number;
}
export interface StatisticsGraph { export interface StatisticsGraph {
type: string; type: string;
zoomToken?: string; zoomToken?: string;

View File

@ -58,6 +58,7 @@ export { default as StickerSearch } from '../components/right/StickerSearch';
// eslint-disable-next-line import/no-cycle // eslint-disable-next-line import/no-cycle
export { default as GifSearch } from '../components/right/GifSearch'; export { default as GifSearch } from '../components/right/GifSearch';
export { default as Statistics } from '../components/right/statistics/Statistics'; export { default as Statistics } from '../components/right/statistics/Statistics';
export { default as MessageStatistics } from '../components/right/statistics/MessageStatistics';
export { default as PollResults } from '../components/right/PollResults'; export { default as PollResults } from '../components/right/PollResults';
export { default as Management } from '../components/right/management/Management'; export { default as Management } from '../components/right/management/Management';

View File

@ -25,6 +25,7 @@ import Transition from '../ui/Transition';
import RightSearch from './RightSearch.async'; import RightSearch from './RightSearch.async';
import Management from './management/Management.async'; import Management from './management/Management.async';
import Statistics from './statistics/Statistics.async'; import Statistics from './statistics/Statistics.async';
import MessageStatistics from './statistics/MessageStatistics.async';
import StickerSearch from './StickerSearch.async'; import StickerSearch from './StickerSearch.async';
import GifSearch from './GifSearch.async'; import GifSearch from './GifSearch.async';
import PollResults from './PollResults.async'; import PollResults from './PollResults.async';
@ -71,6 +72,7 @@ const RightColumn: FC<StateProps> = ({
setNewChatMembersDialogState, setNewChatMembersDialogState,
setEditingExportedInvite, setEditingExportedInvite,
toggleStatistics, toggleStatistics,
toggleMessageStatistics,
setOpenedInviteInfo, setOpenedInviteInfo,
requestNextManagementScreen, requestNextManagementScreen,
} = getActions(); } = getActions();
@ -87,6 +89,7 @@ const RightColumn: FC<StateProps> = ({
const isSearch = contentKey === RightColumnContent.Search; const isSearch = contentKey === RightColumnContent.Search;
const isManagement = contentKey === RightColumnContent.Management; const isManagement = contentKey === RightColumnContent.Management;
const isStatistics = contentKey === RightColumnContent.Statistics; const isStatistics = contentKey === RightColumnContent.Statistics;
const isMessageStatistics = contentKey === RightColumnContent.MessageStatistics;
const isStickerSearch = contentKey === RightColumnContent.StickerSearch; const isStickerSearch = contentKey === RightColumnContent.StickerSearch;
const isGifSearch = contentKey === RightColumnContent.GifSearch; const isGifSearch = contentKey === RightColumnContent.GifSearch;
const isPollResults = contentKey === RightColumnContent.PollResults; const isPollResults = contentKey === RightColumnContent.PollResults;
@ -150,6 +153,9 @@ const RightColumn: FC<StateProps> = ({
break; break;
} }
case RightColumnContent.MessageStatistics:
toggleMessageStatistics();
break;
case RightColumnContent.Statistics: case RightColumnContent.Statistics:
toggleStatistics(); toggleStatistics();
break; break;
@ -174,7 +180,7 @@ const RightColumn: FC<StateProps> = ({
}, [ }, [
contentKey, isScrolledDown, toggleChatInfo, closePollResults, setNewChatMembersDialogState, contentKey, isScrolledDown, toggleChatInfo, closePollResults, setNewChatMembersDialogState,
managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery, managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery,
setEditingExportedInvite, chatId, setOpenedInviteInfo, toggleStatistics, setEditingExportedInvite, chatId, setOpenedInviteInfo, toggleStatistics, toggleMessageStatistics,
]); ]);
const handleSelectChatMember = useCallback((memberId, isPromoted) => { const handleSelectChatMember = useCallback((memberId, isPromoted) => {
@ -268,6 +274,8 @@ const RightColumn: FC<StateProps> = ({
case RightColumnContent.Statistics: case RightColumnContent.Statistics:
return <Statistics chatId={chatId!} isActive={isOpen && isActive} />; return <Statistics chatId={chatId!} isActive={isOpen && isActive} />;
case RightColumnContent.MessageStatistics:
return <MessageStatistics chatId={chatId!} isActive={isOpen && isActive} />;
case RightColumnContent.StickerSearch: case RightColumnContent.StickerSearch:
return <StickerSearch onClose={close} isActive={isOpen && isActive} />; return <StickerSearch onClose={close} isActive={isOpen && isActive} />;
case RightColumnContent.GifSearch: case RightColumnContent.GifSearch:
@ -293,6 +301,7 @@ const RightColumn: FC<StateProps> = ({
isSearch={isSearch} isSearch={isSearch}
isManagement={isManagement} isManagement={isManagement}
isStatistics={isStatistics} isStatistics={isStatistics}
isMessageStatistics={isMessageStatistics}
isStickerSearch={isStickerSearch} isStickerSearch={isStickerSearch}
isGifSearch={isGifSearch} isGifSearch={isGifSearch}
isPollResults={isPollResults} isPollResults={isPollResults}

View File

@ -40,6 +40,7 @@ type OwnProps = {
isSearch?: boolean; isSearch?: boolean;
isManagement?: boolean; isManagement?: boolean;
isStatistics?: boolean; isStatistics?: boolean;
isMessageStatistics?: boolean;
isStickerSearch?: boolean; isStickerSearch?: boolean;
isGifSearch?: boolean; isGifSearch?: boolean;
isPollResults?: boolean; isPollResults?: boolean;
@ -73,6 +74,7 @@ enum HeaderContent {
SharedMedia, SharedMedia,
Search, Search,
Statistics, Statistics,
MessageStatistics,
Management, Management,
ManageInitial, ManageInitial,
ManageChannelSubscribers, ManageChannelSubscribers,
@ -107,6 +109,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
isSearch, isSearch,
isManagement, isManagement,
isStatistics, isStatistics,
isMessageStatistics,
isStickerSearch, isStickerSearch,
isGifSearch, isGifSearch,
isPollResults, isPollResults,
@ -246,6 +249,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
) : undefined // Never reached ) : undefined // Never reached
) : isStatistics ? ( ) : isStatistics ? (
HeaderContent.Statistics HeaderContent.Statistics
) : isMessageStatistics ? (
HeaderContent.MessageStatistics
) : undefined; // When column is closed ) : undefined; // When column is closed
const renderingContentKey = useCurrentOrPrev(contentKey, true) ?? -1; const renderingContentKey = useCurrentOrPrev(contentKey, true) ?? -1;
@ -373,6 +378,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
); );
case HeaderContent.Statistics: case HeaderContent.Statistics:
return <h3>{lang(isChannel ? 'ChannelStats.Title' : 'GroupStats.Title')}</h3>; return <h3>{lang(isChannel ? 'ChannelStats.Title' : 'GroupStats.Title')}</h3>;
case HeaderContent.MessageStatistics:
return <h3>{lang('Stats.MessageTitle')}</h3>;
case HeaderContent.SharedMedia: case HeaderContent.SharedMedia:
return <h3>{lang('SharedMedia')}</h3>; return <h3>{lang('SharedMedia')}</h3>;
case HeaderContent.ManageChannelSubscribers: case HeaderContent.ManageChannelSubscribers:

View File

@ -0,0 +1,16 @@
import React, { FC } from '../../../lib/teact/teact';
import { Bundles } from '../../../util/moduleLoader';
import { OwnProps } from './MessageStatistics';
import useModuleLoader from '../../../hooks/useModuleLoader';
import Loading from '../../ui/Loading';
const MessageStatisticsAsync: FC<OwnProps> = (props) => {
const MessageStatistics = useModuleLoader(Bundles.Extra, 'MessageStatistics');
// eslint-disable-next-line react/jsx-props-no-spreading
return MessageStatistics ? <MessageStatistics {...props} /> : <Loading />;
};
export default MessageStatisticsAsync;

View File

@ -0,0 +1,170 @@
import React, {
FC, memo, useState, useEffect, useRef,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import { callApi } from '../../../api/gramjs';
import { ApiMessageStatistics, StatisticsGraph } from '../../../api/types';
import { selectChat } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import useForceUpdate from '../../../hooks/useForceUpdate';
import Loading from '../../ui/Loading';
import StatisticsOverview from './StatisticsOverview';
import './Statistics.scss';
type ILovelyChart = { create: Function };
let lovelyChartPromise: Promise<ILovelyChart>;
let LovelyChart: ILovelyChart;
async function ensureLovelyChart() {
if (!lovelyChartPromise) {
lovelyChartPromise = import('../../../lib/lovely-chart/LovelyChart') as Promise<ILovelyChart>;
LovelyChart = await lovelyChartPromise;
}
return lovelyChartPromise;
}
const GRAPH_TITLES = {
viewsGraph: 'Stats.MessageInteractionsTitle',
};
const GRAPHS = Object.keys(GRAPH_TITLES) as (keyof ApiMessageStatistics)[];
export type OwnProps = {
chatId: string;
isActive: boolean;
};
export type StateProps = {
statistics: ApiMessageStatistics;
messageId?: number;
dcId?: number;
};
const Statistics: FC<OwnProps & StateProps> = ({
chatId,
isActive,
statistics,
dcId,
messageId,
}) => {
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
const loadedCharts = useRef<string[]>([]);
const { loadMessageStatistics, loadStatisticsAsyncGraph } = getActions();
const forceUpdate = useForceUpdate();
useEffect(() => {
if (messageId) {
loadMessageStatistics({ chatId, messageId });
}
}, [chatId, loadMessageStatistics, messageId]);
useEffect(() => {
if (!isActive || messageId) {
loadedCharts.current = [];
setIsReady(false);
}
}, [isActive, messageId]);
// Load async graphs
useEffect(() => {
if (!statistics) {
return;
}
GRAPHS.forEach((name) => {
const graph = statistics[name as keyof typeof statistics];
const isAsync = typeof graph === 'string';
if (isAsync) {
loadStatisticsAsyncGraph({ name, chatId, token: graph });
}
});
}, [chatId, statistics, loadStatisticsAsyncGraph]);
useEffect(() => {
(async () => {
await ensureLovelyChart();
if (!isReady) {
setIsReady(true);
return;
}
if (!statistics) {
return;
}
GRAPHS.forEach((name, index: number) => {
const graph = statistics[name as keyof typeof statistics];
const isAsync = typeof graph === 'string';
if (isAsync || loadedCharts.current.includes(name)) {
return;
}
if (!graph) {
loadedCharts.current.push(name);
return;
}
const { zoomToken } = graph as StatisticsGraph;
LovelyChart.create(
containerRef.current!.children[index],
{
title: lang((GRAPH_TITLES as Record<string, string>)[name]),
...zoomToken ? {
onZoom: (x: number) => callApi('fetchStatisticsAsyncGraph', { token: zoomToken, x, dcId }),
zoomOutLabel: lang('Graph.ZoomOut'),
} : {},
...graph as StatisticsGraph,
},
);
loadedCharts.current.push(name);
});
forceUpdate();
})();
}, [
isReady, statistics, lang, chatId, messageId, loadStatisticsAsyncGraph, dcId, forceUpdate,
]);
if (!isReady || !statistics || !messageId) {
return <Loading />;
}
return (
<div className={buildClassName('Statistics custom-scroll', isReady && 'ready')}>
<StatisticsOverview statistics={statistics} isMessage />
{!loadedCharts.current.length && <Loading />}
<div ref={containerRef}>
{GRAPHS.map((graph) => (
<div className={buildClassName('Statistics__graph', !loadedCharts.current.includes(graph) && 'hidden')} />
))}
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const { currentMessage, currentMessageId } = global.statistics;
const chat = selectChat(global, chatId);
const dcId = chat?.fullInfo?.statisticsDcId;
return { statistics: currentMessage, dcId, messageId: currentMessageId };
},
)(Statistics));

View File

@ -9,6 +9,7 @@ import {
ApiChannelStatistics, ApiChannelStatistics,
ApiGroupStatistics, ApiGroupStatistics,
StatisticsRecentMessage as StatisticsRecentMessageType, StatisticsRecentMessage as StatisticsRecentMessageType,
StatisticsGraph,
} from '../../../api/types'; } from '../../../api/types';
import { selectChat, selectStatistics } from '../../../global/selectors'; import { selectChat, selectStatistics } from '../../../global/selectors';
@ -65,6 +66,7 @@ export type StateProps = {
statistics: ApiChannelStatistics | ApiGroupStatistics; statistics: ApiChannelStatistics | ApiGroupStatistics;
dcId?: number; dcId?: number;
isGroup: boolean; isGroup: boolean;
messageId?: number;
}; };
const Statistics: FC<OwnProps & StateProps> = ({ const Statistics: FC<OwnProps & StateProps> = ({
@ -73,6 +75,7 @@ const Statistics: FC<OwnProps & StateProps> = ({
statistics, statistics,
dcId, dcId,
isGroup, isGroup,
messageId,
}) => { }) => {
const lang = useLang(); const lang = useLang();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -89,6 +92,7 @@ const Statistics: FC<OwnProps & StateProps> = ({
useEffect(() => { useEffect(() => {
if (!isActive) { if (!isActive) {
loadedCharts.current = []; loadedCharts.current = [];
setIsReady(false);
} }
}, [isActive]); }, [isActive]);
@ -131,7 +135,7 @@ const Statistics: FC<OwnProps & StateProps> = ({
return; return;
} }
if (!statistics) { if (!statistics || !containerRef.current) {
return; return;
} }
@ -143,26 +147,34 @@ const Statistics: FC<OwnProps & StateProps> = ({
return; return;
} }
if (!graph) {
loadedCharts.current.push(name);
return;
}
const { zoomToken } = graph; const { zoomToken } = graph;
LovelyChart.create( LovelyChart.create(
containerRef.current!.children[index], containerRef.current!.children[index],
{ {
title: lang((graphTitles as Record<string, string>)[name]), 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'),
}, } : {},
...graph, ...graph as StatisticsGraph,
}, },
); );
loadedCharts.current.push(name); loadedCharts.current.push(name);
}); });
})(); })();
}, [graphs, graphTitles, isReady, statistics, lang, chatId, loadStatisticsAsyncGraph, dcId]); }, [
graphs, graphTitles, isReady, statistics, lang, chatId, loadStatisticsAsyncGraph, dcId,
]);
if (!isReady || !statistics) { if (!isReady || !statistics || messageId) {
return <Loading />; return <Loading />;
} }
@ -197,7 +209,11 @@ export default memo(withGlobal<OwnProps>(
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
const dcId = chat?.fullInfo?.statisticsDcId; const dcId = chat?.fullInfo?.statisticsDcId;
const isGroup = chat?.type === 'chatTypeSuperGroup'; const isGroup = chat?.type === 'chatTypeSuperGroup';
// Show Loading component if message was already selected for improving transition animation
const messageId = global.statistics.currentMessageId;
return { statistics, dcId, isGroup }; return {
statistics, dcId, isGroup, messageId,
};
}, },
)(Statistics)); )(Statistics));

View File

@ -32,6 +32,10 @@
&__table { &__table {
width: 100%; width: 100%;
&-cell {
width: 50%;
}
&-heading { &-heading {
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);

View File

@ -1,6 +1,8 @@
import React, { FC, memo } from '../../../lib/teact/teact'; import React, { FC, memo } from '../../../lib/teact/teact';
import { ApiChannelStatistics, ApiGroupStatistics, StatisticsOverviewItem } from '../../../api/types'; import {
ApiChannelStatistics, ApiGroupStatistics, ApiMessageStatistics, StatisticsOverviewItem,
} from '../../../api/types';
import { formatIntegerCompact } from '../../../util/textFormat'; import { formatIntegerCompact } from '../../../util/textFormat';
import { formatFullDate } from '../../../util/dateFormat'; import { formatFullDate } from '../../../util/dateFormat';
@ -13,6 +15,8 @@ type OverviewCell = {
name: string; name: string;
title: string; title: string;
isPercentage?: boolean; isPercentage?: boolean;
isPlain?: boolean;
isApproximate?: boolean;
}; };
const CHANNEL_OVERVIEW: OverviewCell[][] = [ const CHANNEL_OVERVIEW: OverviewCell[][] = [
@ -37,12 +41,22 @@ const GROUP_OVERVIEW: OverviewCell[][] = [
], ],
]; ];
const MESSAGE_OVERVIEW: OverviewCell[][] = [
[
{ name: 'views', title: 'StatisticViews', isPlain: true },
{
name: 'forwards', title: 'PrivateShares', isPlain: true, isApproximate: true,
},
],
];
export type OwnProps = { export type OwnProps = {
isGroup?: boolean; isGroup?: boolean;
statistics: ApiChannelStatistics | ApiGroupStatistics; isMessage?: boolean;
statistics: ApiChannelStatistics | ApiGroupStatistics | ApiMessageStatistics;
}; };
const StatisticsOverview: FC<OwnProps> = ({ isGroup, statistics }) => { const StatisticsOverview: FC<OwnProps> = ({ isGroup, isMessage, statistics }) => {
const lang = useLang(); const lang = useLang();
const renderOverviewItemValue = ({ change, percentage }: StatisticsOverviewItem) => { const renderOverviewItemValue = ({ change, percentage }: StatisticsOverviewItem) => {
@ -70,7 +84,7 @@ const StatisticsOverview: FC<OwnProps> = ({ isGroup, statistics }) => {
return ( return (
<div className="StatisticsOverview"> <div className="StatisticsOverview">
<div className="StatisticsOverview__header"> <div className="StatisticsOverview__header">
<div className="StatisticsOverview__title">{lang('ChannelStats.Overview')}</div> <div className="StatisticsOverview__title">{lang('StatisticOverview')}</div>
{period && ( {period && (
<div className="StatisticsOverview__caption"> <div className="StatisticsOverview__caption">
@ -80,14 +94,23 @@ const StatisticsOverview: FC<OwnProps> = ({ isGroup, statistics }) => {
</div> </div>
<table className="StatisticsOverview__table"> <table className="StatisticsOverview__table">
{(isGroup ? GROUP_OVERVIEW : CHANNEL_OVERVIEW).map((row) => ( {(isMessage ? MESSAGE_OVERVIEW : isGroup ? GROUP_OVERVIEW : CHANNEL_OVERVIEW).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) {
return (
<td className="StatisticsOverview__table-cell">
<b className="StatisticsOverview__table-value">{cell.isApproximate ? `${field}` : field}</b>
<h3 className="StatisticsOverview__table-heading">{lang(cell.title)}</h3>
</td>
);
}
if (cell.isPercentage) { if (cell.isPercentage) {
return ( return (
<td> <td className="StatisticsOverview__table-cell">
<b className="StatisticsOverview__table-value">{field.percentage}%</b> <b className="StatisticsOverview__table-value">{field.percentage}%</b>
<h3 className="StatisticsOverview__table-heading">{lang(cell.title)}</h3> <h3 className="StatisticsOverview__table-heading">{lang(cell.title)}</h3>
</td> </td>
@ -95,7 +118,7 @@ const StatisticsOverview: FC<OwnProps> = ({ isGroup, statistics }) => {
} }
return ( return (
<td> <td className="StatisticsOverview__table-cell">
<b className="StatisticsOverview__table-value"> <b className="StatisticsOverview__table-value">
{formatIntegerCompact(field.current)} {formatIntegerCompact(field.current)}
</b> </b>

View File

@ -1,5 +1,6 @@
.StatisticsRecentMessage { .StatisticsRecentMessage {
position: relative; position: relative;
cursor: pointer;
margin-bottom: 1rem; margin-bottom: 1rem;
&--with-image { &--with-image {

View File

@ -1,6 +1,7 @@
import React, { FC, memo } from '../../../lib/teact/teact'; import React, { FC, memo, useCallback } from '../../../lib/teact/teact';
import useLang, { LangFn } from '../../../hooks/useLang'; import useLang, { LangFn } from '../../../hooks/useLang';
import { getActions } from '../../../global';
import { ApiMessage, StatisticsRecentMessage as StatisticsRecentMessageType } from '../../../api/types'; import { ApiMessage, StatisticsRecentMessage as StatisticsRecentMessageType } from '../../../api/types';
@ -23,17 +24,23 @@ export type OwnProps = {
const StatisticsRecentMessage: FC<OwnProps> = ({ message }) => { const StatisticsRecentMessage: FC<OwnProps> = ({ message }) => {
const lang = useLang(); const lang = useLang();
const { toggleMessageStatistics } = getActions();
const mediaThumbnail = getMessageMediaThumbDataUri(message); const mediaThumbnail = getMessageMediaThumbDataUri(message);
const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'micro')); const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'micro'));
const isRoundVideo = Boolean(getMessageRoundVideo(message)); const isRoundVideo = Boolean(getMessageRoundVideo(message));
const handleClick = useCallback(() => {
toggleMessageStatistics({ messageId: message.id });
}, [toggleMessageStatistics, message.id]);
return ( return (
<div <div
className={buildClassName( className={buildClassName(
'StatisticsRecentMessage', 'StatisticsRecentMessage',
Boolean(mediaBlobUrl || mediaThumbnail) && 'StatisticsRecentMessage--with-image', Boolean(mediaBlobUrl || mediaThumbnail) && 'StatisticsRecentMessage--with-image',
)} )}
onClick={handleClick}
> >
<div className="StatisticsRecentMessage__title"> <div className="StatisticsRecentMessage__title">
<div className="StatisticsRecentMessage__summary"> <div className="StatisticsRecentMessage__summary">

View File

@ -2,7 +2,7 @@ import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { ApiChannelStatistics } from '../../../api/types'; import { ApiChannelStatistics } from '../../../api/types';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { updateStatistics, updateStatisticsGraph } from '../../reducers'; import { updateStatistics, updateMessageStatistics, 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) => {
@ -29,6 +29,28 @@ addActionHandler('loadStatistics', async (global, actions, payload) => {
setGlobal(updateStatistics(global, chatId, result)); setGlobal(updateStatistics(global, chatId, result));
}); });
addActionHandler('loadMessageStatistics', async (global, actions, payload) => {
const { chatId, messageId } = payload;
const chat = selectChat(global, chatId);
if (!chat?.fullInfo) {
return;
}
let result = await callApi('fetchMessageStatistics', { chat, messageId });
if (!result) {
result = {};
}
global = getGlobal();
const { views, forwards } = selectChatMessages(global, chatId)[messageId];
result.views = views;
result.forwards = forwards;
setGlobal(updateMessageStatistics(global, result));
});
addActionHandler('loadStatisticsAsyncGraph', async (global, actions, payload) => { addActionHandler('loadStatisticsAsyncGraph', async (global, actions, payload) => {
const { const {
chatId, token, name, isPercentage, chatId, token, name, isPercentage,

View File

@ -114,6 +114,20 @@ addActionHandler('toggleStatistics', (global) => {
return { return {
...global, ...global,
isStatisticsShown: !global.isStatisticsShown, isStatisticsShown: !global.isStatisticsShown,
statistics: {
...global.statistics,
currentMessageId: undefined,
},
};
});
addActionHandler('toggleMessageStatistics', (global, action, payload) => {
return {
...global,
statistics: {
...global.statistics,
currentMessageId: payload?.messageId,
},
}; };
}); });

View File

@ -201,6 +201,7 @@ export const INITIAL_STATE: GlobalState = {
statistics: { statistics: {
byChatId: {}, byChatId: {},
currentMessage: {},
}, },
pollModal: { pollModal: {

View File

@ -1,5 +1,7 @@
import { GlobalState } from '../types'; import { GlobalState } from '../types';
import { ApiChannelStatistics, ApiGroupStatistics, StatisticsGraph } from '../../api/types'; import {
ApiChannelStatistics, ApiGroupStatistics, ApiMessageStatistics, StatisticsGraph,
} from '../../api/types';
export function updateStatistics( export function updateStatistics(
global: GlobalState, chatId: string, statistics: ApiChannelStatistics | ApiGroupStatistics, global: GlobalState, chatId: string, statistics: ApiChannelStatistics | ApiGroupStatistics,
@ -7,6 +9,7 @@ export function updateStatistics(
return { return {
...global, ...global,
statistics: { statistics: {
currentMessage: {},
byChatId: { byChatId: {
...global.statistics.byChatId, ...global.statistics.byChatId,
[chatId]: statistics, [chatId]: statistics,
@ -15,12 +18,25 @@ export function updateStatistics(
}; };
} }
export function updateMessageStatistics(
global: GlobalState, statistics: ApiMessageStatistics,
): GlobalState {
return {
...global,
statistics: {
...global.statistics,
currentMessage: statistics,
},
};
}
export function updateStatisticsGraph( export function updateStatisticsGraph(
global: GlobalState, chatId: string, name: string, update: StatisticsGraph, global: GlobalState, chatId: string, name: string, update: StatisticsGraph,
): GlobalState { ): GlobalState {
return { return {
...global, ...global,
statistics: { statistics: {
...global.statistics,
byChatId: { byChatId: {
...global.statistics.byChatId, ...global.statistics.byChatId,
[chatId]: { [chatId]: {

View File

@ -17,3 +17,11 @@ export function selectIsStatisticsShown(global: GlobalState) {
return chat?.fullInfo?.canViewStatistics; return chat?.fullInfo?.canViewStatistics;
} }
export function selectIsMessageStatisticsShown(global: GlobalState) {
if (!global.isStatisticsShown) {
return false;
}
return Boolean(global.statistics.currentMessageId);
}

View File

@ -5,7 +5,7 @@ import { getSystemTheme, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'
import { selectCurrentMessageList, selectIsPollResultsOpen } from './messages'; import { selectCurrentMessageList, selectIsPollResultsOpen } from './messages';
import { selectCurrentTextSearch } from './localSearch'; import { selectCurrentTextSearch } from './localSearch';
import { selectCurrentStickerSearch, selectCurrentGifSearch } from './symbols'; import { selectCurrentStickerSearch, selectCurrentGifSearch } from './symbols';
import { selectIsStatisticsShown } from './statistics'; import { selectIsStatisticsShown, selectIsMessageStatisticsShown } from './statistics';
import { selectCurrentManagement } from './management'; import { selectCurrentManagement } from './management';
export function selectIsMediaViewerOpen(global: GlobalState) { export function selectIsMediaViewerOpen(global: GlobalState) {
@ -20,6 +20,8 @@ export function selectRightColumnContentKey(global: GlobalState) {
RightColumnContent.Search RightColumnContent.Search
) : selectCurrentManagement(global) ? ( ) : selectCurrentManagement(global) ? (
RightColumnContent.Management RightColumnContent.Management
) : selectIsMessageStatisticsShown(global) ? (
RightColumnContent.MessageStatistics
) : selectIsStatisticsShown(global) ? ( ) : selectIsStatisticsShown(global) ? (
RightColumnContent.Statistics RightColumnContent.Statistics
) : selectCurrentStickerSearch(global).query !== undefined ? ( ) : selectCurrentStickerSearch(global).query !== undefined ? (

View File

@ -28,6 +28,7 @@ import {
ApiSponsoredMessage, ApiSponsoredMessage,
ApiChannelStatistics, ApiChannelStatistics,
ApiGroupStatistics, ApiGroupStatistics,
ApiMessageStatistics,
ApiPaymentFormNativeParams, ApiPaymentFormNativeParams,
ApiUpdate, ApiUpdate,
ApiReportReason, ApiReportReason,
@ -518,6 +519,8 @@ export type GlobalState = {
statistics: { statistics: {
byChatId: Record<string, ApiChannelStatistics | ApiGroupStatistics>; byChatId: Record<string, ApiChannelStatistics | ApiGroupStatistics>;
currentMessage: ApiMessageStatistics;
currentMessageId?: number;
}; };
newContact?: { newContact?: {
@ -809,7 +812,7 @@ export type NonTypedActionNames = (
'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' | 'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' |
'setNewChatMembersDialogState' | 'disableHistoryAnimations' | 'setLeftColumnWidth' | 'resetLeftColumnWidth' | 'setNewChatMembersDialogState' | 'disableHistoryAnimations' | 'setLeftColumnWidth' | 'resetLeftColumnWidth' |
'openSeenByModal' | 'closeSeenByModal' | 'closeReactorListModal' | 'openReactorListModal' | 'openSeenByModal' | 'closeSeenByModal' | 'closeReactorListModal' | 'openReactorListModal' |
'toggleStatistics' | 'toggleStatistics' | 'toggleMessageStatistics' |
// auth // auth
'setAuthPhoneNumber' | 'setAuthCode' | 'setAuthPassword' | 'signUp' | 'returnToAuthPhoneNumber' | 'setAuthPhoneNumber' | 'setAuthCode' | 'setAuthPassword' | 'signUp' | 'returnToAuthPhoneNumber' |
'setAuthRememberMe' | 'clearAuthError' | 'uploadProfilePhoto' | 'goToAuthQrCode' | 'clearCache' | 'setAuthRememberMe' | 'clearAuthError' | 'uploadProfilePhoto' | 'goToAuthQrCode' | 'clearCache' |
@ -896,7 +899,7 @@ export type NonTypedActionNames = (
'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' | 'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' |
'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' | 'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' |
// stats // stats
'loadStatistics' | 'loadStatisticsAsyncGraph' 'loadStatistics' | 'loadMessageStatistics' | 'loadStatisticsAsyncGraph'
); );
const typed = typify<GlobalState, ActionPayloads, NonTypedActionNames>(); const typed = typify<GlobalState, ActionPayloads, NonTypedActionNames>();

View File

@ -1217,4 +1217,5 @@ 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;`; stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats;
stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats;`;

View File

@ -222,6 +222,7 @@
"help.getAppConfig", "help.getAppConfig",
"stats.getBroadcastStats", "stats.getBroadcastStats",
"stats.getMegagroupStats", "stats.getMegagroupStats",
"stats.getMessageStats",
"stats.loadAsyncGraph", "stats.loadAsyncGraph",
"messages.getAttachMenuBots", "messages.getAttachMenuBots",
"messages.getAttachMenuBot", "messages.getAttachMenuBot",

View File

@ -292,6 +292,7 @@ export function createTooltip(container, data, plotSize, colors, onZoom, onFocus
return statsFormatDayHourFull(data.xLabels[labelIndex].value); return statsFormatDayHourFull(data.xLabels[labelIndex].value);
case 'statsTooltipFormat(\'day\')': case 'statsTooltipFormat(\'day\')':
return getLabelDate(data.xLabels[labelIndex]); return getLabelDate(data.xLabels[labelIndex]);
case 'statsTooltipFormat(\'hour\')':
case 'statsTooltipFormat(\'5min\')': case 'statsTooltipFormat(\'5min\')':
return getLabelTime(data.xLabels[labelIndex]); return getLabelTime(data.xLabels[labelIndex]);
default: default:

View File

@ -26,13 +26,7 @@ export function createZoomer(data, overviewData, colors, stateManager, container
const { value } = label; const { value } = label;
const dataPromise = data.shouldZoomToPie ? Promise.resolve(_generatePieData(labelIndex)) : data.onZoom(value); const dataPromise = data.shouldZoomToPie ? Promise.resolve(_generatePieData(labelIndex)) : data.onZoom(value);
dataPromise dataPromise.then((newData) => _replaceData(newData, labelIndex, label));
.then((newData) => _replaceData(newData, labelIndex, label))
.catch(() => {
tooltip.toggleLoading(false);
tooltip.toggleIsZoomed(false);
header.toggleIsZooming(false);
});
} }
function zoomOut(state) { function zoomOut(state) {
@ -58,6 +52,14 @@ export function createZoomer(data, overviewData, colors, stateManager, container
} }
function _replaceData(newRawData, labelIndex, zoomInLabel) { function _replaceData(newRawData, labelIndex, zoomInLabel) {
if (!newRawData) {
tooltip.toggleLoading(false);
tooltip.toggleIsZoomed(false);
header.toggleIsZooming(false);
return;
}
tooltip.toggleLoading(false); tooltip.toggleLoading(false);
const labelWidth = 1 / data.xLabels.length; const labelWidth = 1 / data.xLabels.length;

View File

@ -1,5 +1,5 @@
import { getMaxMin } from './utils'; import { getMaxMin } from './utils';
import { statsFormatDay, statsFormatDayHour, statsFormatText, statsFormatMin } from './format'; import { statsFormatHour, statsFormatDay, statsFormatDayHour, statsFormatText, statsFormatMin } from './format';
export function analyzeData(data) { export function analyzeData(data) {
const { title, labelFormatter, tooltipFormatter, isStacked, isPercentage, hasSecondYAxis, onZoom, minimapRange, hideCaption, zoomOutLabel } = data; const { title, labelFormatter, tooltipFormatter, isStacked, isPercentage, hasSecondYAxis, onZoom, minimapRange, hideCaption, zoomOutLabel } = data;
@ -28,6 +28,7 @@ export function analyzeData(data) {
case 'statsFormat(\'day\')': case 'statsFormat(\'day\')':
xLabels = statsFormatDay(labels); xLabels = statsFormatDay(labels);
break; break;
case 'statsFormat(\'hour\')':
case 'statsFormat(\'5min\')': case 'statsFormat(\'5min\')':
xLabels = statsFormatMin(labels); xLabels = statsFormatMin(labels);
break; break;

View File

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