diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index fe566ea1e..9d7f4098a 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -137,7 +137,7 @@ type UniversalMessage = ( & Pick, ( 'out' | 'message' | 'entities' | 'fromId' | 'peerId' | 'fwdFrom' | 'replyTo' | 'replyMarkup' | 'post' | '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, senderId: fromId || (mtpMessage.out && mtpMessage.post && currentUserId) || chatId, views: mtpMessage.views, + forwards: mtpMessage.forwards, isFromScheduled: mtpMessage.fromScheduled, reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions), ...(replyToMsgId && { replyToMessageId: replyToMsgId }), diff --git a/src/api/gramjs/apiBuilders/statistics.ts b/src/api/gramjs/apiBuilders/statistics.ts index ac9571b9c..786941c2c 100644 --- a/src/api/gramjs/apiBuilders/statistics.ts +++ b/src/api/gramjs/apiBuilders/statistics.ts @@ -2,6 +2,7 @@ import { Api as GramJs } from '../../../lib/gramjs'; import { ApiChannelStatistics, ApiGroupStatistics, + ApiMessageStatistics, StatisticsGraph, StatisticsOverviewItem, 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) { - throw new Error((result as GramJs.StatsGraphError).error); + return undefined; } const data = JSON.parse((result as GramJs.StatsGraph).json.data); diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 3386607e2..16a32667a 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -83,7 +83,9 @@ export { setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction, } from './reactions'; -export { fetchChannelStatistics, fetchGroupStatistics, fetchStatisticsAsyncGraph } from './statistics'; +export { + fetchChannelStatistics, fetchGroupStatistics, fetchMessageStatistics, fetchStatisticsAsyncGraph, +} from './statistics'; export { acceptPhoneCall, confirmPhoneCall, requestPhoneCall, decodePhoneCallData, createPhoneCallState, diff --git a/src/api/gramjs/methods/statistics.ts b/src/api/gramjs/methods/statistics.ts index 21640c6bd..3d0b45cfc 100644 --- a/src/api/gramjs/methods/statistics.ts +++ b/src/api/gramjs/methods/statistics.ts @@ -2,12 +2,14 @@ import BigInt from 'big-integer'; import { Api as GramJs } from '../../../lib/gramjs'; import { - ApiChat, ApiChannelStatistics, ApiGroupStatistics, StatisticsGraph, + ApiChat, ApiChannelStatistics, ApiGroupStatistics, ApiMessageStatistics, StatisticsGraph, } from '../../types'; import { invokeRequest } from './client'; import { buildInputEntity } from '../gramjsBuilders'; -import { buildChannelStatistics, buildGroupStatistics, buildGraph } from '../apiBuilders/statistics'; +import { + buildChannelStatistics, buildGroupStatistics, buildMessageStatistics, buildGraph, +} from '../apiBuilders/statistics'; export async function fetchChannelStatistics({ chat, @@ -37,6 +39,25 @@ export async function fetchGroupStatistics({ return buildGroupStatistics(result); } +export async function fetchMessageStatistics({ + chat, + messageId, +}: { + chat: ApiChat; + messageId: number; +}): Promise { + 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({ token, x, diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index e7d272255..9bf0a4b96 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -297,6 +297,7 @@ export interface ApiMessage { isDeleting?: boolean; previousLocalId?: number; views?: number; + forwards?: number; isEdited?: boolean; editDate?: number; isMentioned?: boolean; diff --git a/src/api/types/statistics.ts b/src/api/types/statistics.ts index f0b7acf52..443970fde 100644 --- a/src/api/types/statistics.ts +++ b/src/api/types/statistics.ts @@ -1,10 +1,10 @@ import { ApiMessage } from './messages'; export interface ApiChannelStatistics { - growthGraph: StatisticsGraph; - followersGraph: StatisticsGraph; - muteGraph: StatisticsGraph; - topHoursGraph: StatisticsGraph; + growthGraph?: StatisticsGraph | string; + followersGraph?: StatisticsGraph | string; + muteGraph?: StatisticsGraph | string; + topHoursGraph?: StatisticsGraph | string; interactionsGraph: StatisticsGraph | string; viewsBySourceGraph: StatisticsGraph | string; newFollowersBySourceGraph: StatisticsGraph | string; @@ -17,9 +17,9 @@ export interface ApiChannelStatistics { } export interface ApiGroupStatistics { - growthGraph: StatisticsGraph; - membersGraph: StatisticsGraph; - topHoursGraph: StatisticsGraph; + growthGraph?: StatisticsGraph | string; + membersGraph?: StatisticsGraph | string; + topHoursGraph?: StatisticsGraph | string; languagesGraph: StatisticsGraph | string; messagesGraph: StatisticsGraph | string; actionsGraph: StatisticsGraph | string; @@ -30,6 +30,12 @@ export interface ApiGroupStatistics { posters: StatisticsOverviewItem; } +export interface ApiMessageStatistics { + viewsGraph?: StatisticsGraph | string; + forwards?: number; + views?: number; +} + export interface StatisticsGraph { type: string; zoomToken?: string; diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index bed7b09ad..9b7ded531 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -58,6 +58,7 @@ export { default as StickerSearch } from '../components/right/StickerSearch'; // eslint-disable-next-line import/no-cycle export { default as GifSearch } from '../components/right/GifSearch'; 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 Management } from '../components/right/management/Management'; diff --git a/src/components/right/RightColumn.tsx b/src/components/right/RightColumn.tsx index dafdb5751..3413e89f4 100644 --- a/src/components/right/RightColumn.tsx +++ b/src/components/right/RightColumn.tsx @@ -25,6 +25,7 @@ import Transition from '../ui/Transition'; import RightSearch from './RightSearch.async'; import Management from './management/Management.async'; import Statistics from './statistics/Statistics.async'; +import MessageStatistics from './statistics/MessageStatistics.async'; import StickerSearch from './StickerSearch.async'; import GifSearch from './GifSearch.async'; import PollResults from './PollResults.async'; @@ -71,6 +72,7 @@ const RightColumn: FC = ({ setNewChatMembersDialogState, setEditingExportedInvite, toggleStatistics, + toggleMessageStatistics, setOpenedInviteInfo, requestNextManagementScreen, } = getActions(); @@ -87,6 +89,7 @@ const RightColumn: FC = ({ const isSearch = contentKey === RightColumnContent.Search; const isManagement = contentKey === RightColumnContent.Management; const isStatistics = contentKey === RightColumnContent.Statistics; + const isMessageStatistics = contentKey === RightColumnContent.MessageStatistics; const isStickerSearch = contentKey === RightColumnContent.StickerSearch; const isGifSearch = contentKey === RightColumnContent.GifSearch; const isPollResults = contentKey === RightColumnContent.PollResults; @@ -150,6 +153,9 @@ const RightColumn: FC = ({ break; } + case RightColumnContent.MessageStatistics: + toggleMessageStatistics(); + break; case RightColumnContent.Statistics: toggleStatistics(); break; @@ -174,7 +180,7 @@ const RightColumn: FC = ({ }, [ contentKey, isScrolledDown, toggleChatInfo, closePollResults, setNewChatMembersDialogState, managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery, - setEditingExportedInvite, chatId, setOpenedInviteInfo, toggleStatistics, + setEditingExportedInvite, chatId, setOpenedInviteInfo, toggleStatistics, toggleMessageStatistics, ]); const handleSelectChatMember = useCallback((memberId, isPromoted) => { @@ -268,6 +274,8 @@ const RightColumn: FC = ({ case RightColumnContent.Statistics: return ; + case RightColumnContent.MessageStatistics: + return ; case RightColumnContent.StickerSearch: return ; case RightColumnContent.GifSearch: @@ -293,6 +301,7 @@ const RightColumn: FC = ({ isSearch={isSearch} isManagement={isManagement} isStatistics={isStatistics} + isMessageStatistics={isMessageStatistics} isStickerSearch={isStickerSearch} isGifSearch={isGifSearch} isPollResults={isPollResults} diff --git a/src/components/right/RightHeader.tsx b/src/components/right/RightHeader.tsx index 7a9ccb348..8433d46b7 100644 --- a/src/components/right/RightHeader.tsx +++ b/src/components/right/RightHeader.tsx @@ -40,6 +40,7 @@ type OwnProps = { isSearch?: boolean; isManagement?: boolean; isStatistics?: boolean; + isMessageStatistics?: boolean; isStickerSearch?: boolean; isGifSearch?: boolean; isPollResults?: boolean; @@ -73,6 +74,7 @@ enum HeaderContent { SharedMedia, Search, Statistics, + MessageStatistics, Management, ManageInitial, ManageChannelSubscribers, @@ -107,6 +109,7 @@ const RightHeader: FC = ({ isSearch, isManagement, isStatistics, + isMessageStatistics, isStickerSearch, isGifSearch, isPollResults, @@ -246,6 +249,8 @@ const RightHeader: FC = ({ ) : undefined // Never reached ) : isStatistics ? ( HeaderContent.Statistics + ) : isMessageStatistics ? ( + HeaderContent.MessageStatistics ) : undefined; // When column is closed const renderingContentKey = useCurrentOrPrev(contentKey, true) ?? -1; @@ -373,6 +378,8 @@ const RightHeader: FC = ({ ); case HeaderContent.Statistics: return

{lang(isChannel ? 'ChannelStats.Title' : 'GroupStats.Title')}

; + case HeaderContent.MessageStatistics: + return

{lang('Stats.MessageTitle')}

; case HeaderContent.SharedMedia: return

{lang('SharedMedia')}

; case HeaderContent.ManageChannelSubscribers: diff --git a/src/components/right/statistics/MessageStatistics.async.tsx b/src/components/right/statistics/MessageStatistics.async.tsx new file mode 100644 index 000000000..6954de4b9 --- /dev/null +++ b/src/components/right/statistics/MessageStatistics.async.tsx @@ -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 = (props) => { + const MessageStatistics = useModuleLoader(Bundles.Extra, 'MessageStatistics'); + + // eslint-disable-next-line react/jsx-props-no-spreading + return MessageStatistics ? : ; +}; + +export default MessageStatisticsAsync; diff --git a/src/components/right/statistics/MessageStatistics.tsx b/src/components/right/statistics/MessageStatistics.tsx new file mode 100644 index 000000000..d9fbf8906 --- /dev/null +++ b/src/components/right/statistics/MessageStatistics.tsx @@ -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; +let LovelyChart: ILovelyChart; + +async function ensureLovelyChart() { + if (!lovelyChartPromise) { + lovelyChartPromise = import('../../../lib/lovely-chart/LovelyChart') as Promise; + 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 = ({ + chatId, + isActive, + statistics, + dcId, + messageId, +}) => { + const lang = useLang(); + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + const [isReady, setIsReady] = useState(false); + const loadedCharts = useRef([]); + + 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)[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 ; + } + + return ( +
+ + + {!loadedCharts.current.length && } + +
+ {GRAPHS.map((graph) => ( +
+ ))} +
+
+ ); +}; + +export default memo(withGlobal( + (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)); diff --git a/src/components/right/statistics/Statistics.tsx b/src/components/right/statistics/Statistics.tsx index ee8f6671b..96907f2b2 100644 --- a/src/components/right/statistics/Statistics.tsx +++ b/src/components/right/statistics/Statistics.tsx @@ -9,6 +9,7 @@ import { ApiChannelStatistics, ApiGroupStatistics, StatisticsRecentMessage as StatisticsRecentMessageType, + StatisticsGraph, } from '../../../api/types'; import { selectChat, selectStatistics } from '../../../global/selectors'; @@ -65,6 +66,7 @@ export type StateProps = { statistics: ApiChannelStatistics | ApiGroupStatistics; dcId?: number; isGroup: boolean; + messageId?: number; }; const Statistics: FC = ({ @@ -73,6 +75,7 @@ const Statistics: FC = ({ statistics, dcId, isGroup, + messageId, }) => { const lang = useLang(); // eslint-disable-next-line no-null/no-null @@ -89,6 +92,7 @@ const Statistics: FC = ({ useEffect(() => { if (!isActive) { loadedCharts.current = []; + setIsReady(false); } }, [isActive]); @@ -131,7 +135,7 @@ const Statistics: FC = ({ return; } - if (!statistics) { + if (!statistics || !containerRef.current) { return; } @@ -143,26 +147,34 @@ const Statistics: FC = ({ return; } + if (!graph) { + loadedCharts.current.push(name); + + return; + } + const { zoomToken } = graph; LovelyChart.create( containerRef.current!.children[index], { title: lang((graphTitles as Record)[name]), - ...zoomToken && { + ...zoomToken ? { onZoom: (x: number) => callApi('fetchStatisticsAsyncGraph', { token: zoomToken, x, dcId }), zoomOutLabel: lang('Graph.ZoomOut'), - }, - ...graph, + } : {}, + ...graph as StatisticsGraph, }, ); 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 ; } @@ -197,7 +209,11 @@ export default memo(withGlobal( const chat = selectChat(global, chatId); const dcId = chat?.fullInfo?.statisticsDcId; 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)); diff --git a/src/components/right/statistics/StatisticsOverview.scss b/src/components/right/statistics/StatisticsOverview.scss index 2614d3666..95e2845c8 100644 --- a/src/components/right/statistics/StatisticsOverview.scss +++ b/src/components/right/statistics/StatisticsOverview.scss @@ -32,6 +32,10 @@ &__table { width: 100%; + &-cell { + width: 50%; + } + &-heading { font-size: 0.9375rem; color: var(--color-text-secondary); diff --git a/src/components/right/statistics/StatisticsOverview.tsx b/src/components/right/statistics/StatisticsOverview.tsx index f236659b8..7f63395dc 100644 --- a/src/components/right/statistics/StatisticsOverview.tsx +++ b/src/components/right/statistics/StatisticsOverview.tsx @@ -1,6 +1,8 @@ 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 { formatFullDate } from '../../../util/dateFormat'; @@ -13,6 +15,8 @@ type OverviewCell = { name: string; title: string; isPercentage?: boolean; + isPlain?: boolean; + isApproximate?: boolean; }; 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 = { isGroup?: boolean; - statistics: ApiChannelStatistics | ApiGroupStatistics; + isMessage?: boolean; + statistics: ApiChannelStatistics | ApiGroupStatistics | ApiMessageStatistics; }; -const StatisticsOverview: FC = ({ isGroup, statistics }) => { +const StatisticsOverview: FC = ({ isGroup, isMessage, statistics }) => { const lang = useLang(); const renderOverviewItemValue = ({ change, percentage }: StatisticsOverviewItem) => { @@ -70,7 +84,7 @@ const StatisticsOverview: FC = ({ isGroup, statistics }) => { return (
-
{lang('ChannelStats.Overview')}
+
{lang('StatisticOverview')}
{period && (
@@ -80,14 +94,23 @@ const StatisticsOverview: FC = ({ isGroup, statistics }) => {
- {(isGroup ? GROUP_OVERVIEW : CHANNEL_OVERVIEW).map((row) => ( + {(isMessage ? MESSAGE_OVERVIEW : isGroup ? GROUP_OVERVIEW : CHANNEL_OVERVIEW).map((row) => ( {row.map((cell: OverviewCell) => { const field = (statistics as any)[cell.name]; + if (cell.isPlain) { + return ( + + ); + } + if (cell.isPercentage) { return ( - @@ -95,7 +118,7 @@ const StatisticsOverview: FC = ({ isGroup, statistics }) => { } return ( -
+ {cell.isApproximate ? `≈${field}` : field} +

{lang(cell.title)}

+
+ {field.percentage}%

{lang(cell.title)}

+ {formatIntegerCompact(field.current)} diff --git a/src/components/right/statistics/StatisticsRecentMessage.scss b/src/components/right/statistics/StatisticsRecentMessage.scss index 91283680d..34d968462 100644 --- a/src/components/right/statistics/StatisticsRecentMessage.scss +++ b/src/components/right/statistics/StatisticsRecentMessage.scss @@ -1,5 +1,6 @@ .StatisticsRecentMessage { position: relative; + cursor: pointer; margin-bottom: 1rem; &--with-image { diff --git a/src/components/right/statistics/StatisticsRecentMessage.tsx b/src/components/right/statistics/StatisticsRecentMessage.tsx index 0dd141016..13e7fc021 100644 --- a/src/components/right/statistics/StatisticsRecentMessage.tsx +++ b/src/components/right/statistics/StatisticsRecentMessage.tsx @@ -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 { getActions } from '../../../global'; import { ApiMessage, StatisticsRecentMessage as StatisticsRecentMessageType } from '../../../api/types'; @@ -23,17 +24,23 @@ export type OwnProps = { const StatisticsRecentMessage: FC = ({ message }) => { const lang = useLang(); + const { toggleMessageStatistics } = getActions(); const mediaThumbnail = getMessageMediaThumbDataUri(message); const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'micro')); const isRoundVideo = Boolean(getMessageRoundVideo(message)); + const handleClick = useCallback(() => { + toggleMessageStatistics({ messageId: message.id }); + }, [toggleMessageStatistics, message.id]); + return (
diff --git a/src/global/actions/api/statistics.ts b/src/global/actions/api/statistics.ts index 5830e1aad..ac9b2bd44 100644 --- a/src/global/actions/api/statistics.ts +++ b/src/global/actions/api/statistics.ts @@ -2,7 +2,7 @@ import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { ApiChannelStatistics } from '../../../api/types'; import { callApi } from '../../../api/gramjs'; -import { updateStatistics, updateStatisticsGraph } from '../../reducers'; +import { updateStatistics, updateMessageStatistics, updateStatisticsGraph } from '../../reducers'; import { selectChatMessages, selectChat } from '../../selectors'; addActionHandler('loadStatistics', async (global, actions, payload) => { @@ -29,6 +29,28 @@ addActionHandler('loadStatistics', async (global, actions, payload) => { 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) => { const { chatId, token, name, isPercentage, diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 4791b41ec..6be84ce87 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -114,6 +114,20 @@ addActionHandler('toggleStatistics', (global) => { return { ...global, isStatisticsShown: !global.isStatisticsShown, + statistics: { + ...global.statistics, + currentMessageId: undefined, + }, + }; +}); + +addActionHandler('toggleMessageStatistics', (global, action, payload) => { + return { + ...global, + statistics: { + ...global.statistics, + currentMessageId: payload?.messageId, + }, }; }); diff --git a/src/global/initialState.ts b/src/global/initialState.ts index a365ef43d..dbcb84e99 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -201,6 +201,7 @@ export const INITIAL_STATE: GlobalState = { statistics: { byChatId: {}, + currentMessage: {}, }, pollModal: { diff --git a/src/global/reducers/statistics.ts b/src/global/reducers/statistics.ts index df81319d3..6ce32a184 100644 --- a/src/global/reducers/statistics.ts +++ b/src/global/reducers/statistics.ts @@ -1,5 +1,7 @@ import { GlobalState } from '../types'; -import { ApiChannelStatistics, ApiGroupStatistics, StatisticsGraph } from '../../api/types'; +import { + ApiChannelStatistics, ApiGroupStatistics, ApiMessageStatistics, StatisticsGraph, +} from '../../api/types'; export function updateStatistics( global: GlobalState, chatId: string, statistics: ApiChannelStatistics | ApiGroupStatistics, @@ -7,6 +9,7 @@ export function updateStatistics( return { ...global, statistics: { + currentMessage: {}, byChatId: { ...global.statistics.byChatId, [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( global: GlobalState, chatId: string, name: string, update: StatisticsGraph, ): GlobalState { return { ...global, statistics: { + ...global.statistics, byChatId: { ...global.statistics.byChatId, [chatId]: { diff --git a/src/global/selectors/statistics.ts b/src/global/selectors/statistics.ts index 5cc5766d5..5057c85bc 100644 --- a/src/global/selectors/statistics.ts +++ b/src/global/selectors/statistics.ts @@ -17,3 +17,11 @@ export function selectIsStatisticsShown(global: GlobalState) { return chat?.fullInfo?.canViewStatistics; } + +export function selectIsMessageStatisticsShown(global: GlobalState) { + if (!global.isStatisticsShown) { + return false; + } + + return Boolean(global.statistics.currentMessageId); +} diff --git a/src/global/selectors/ui.ts b/src/global/selectors/ui.ts index 4b26d264b..c7f503306 100644 --- a/src/global/selectors/ui.ts +++ b/src/global/selectors/ui.ts @@ -5,7 +5,7 @@ import { getSystemTheme, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment' import { selectCurrentMessageList, selectIsPollResultsOpen } from './messages'; import { selectCurrentTextSearch } from './localSearch'; import { selectCurrentStickerSearch, selectCurrentGifSearch } from './symbols'; -import { selectIsStatisticsShown } from './statistics'; +import { selectIsStatisticsShown, selectIsMessageStatisticsShown } from './statistics'; import { selectCurrentManagement } from './management'; export function selectIsMediaViewerOpen(global: GlobalState) { @@ -20,6 +20,8 @@ export function selectRightColumnContentKey(global: GlobalState) { RightColumnContent.Search ) : selectCurrentManagement(global) ? ( RightColumnContent.Management + ) : selectIsMessageStatisticsShown(global) ? ( + RightColumnContent.MessageStatistics ) : selectIsStatisticsShown(global) ? ( RightColumnContent.Statistics ) : selectCurrentStickerSearch(global).query !== undefined ? ( diff --git a/src/global/types.ts b/src/global/types.ts index 752677d3f..bc5ad0226 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -28,6 +28,7 @@ import { ApiSponsoredMessage, ApiChannelStatistics, ApiGroupStatistics, + ApiMessageStatistics, ApiPaymentFormNativeParams, ApiUpdate, ApiReportReason, @@ -518,6 +519,8 @@ export type GlobalState = { statistics: { byChatId: Record; + currentMessage: ApiMessageStatistics; + currentMessageId?: number; }; newContact?: { @@ -809,7 +812,7 @@ export type NonTypedActionNames = ( 'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' | 'setNewChatMembersDialogState' | 'disableHistoryAnimations' | 'setLeftColumnWidth' | 'resetLeftColumnWidth' | 'openSeenByModal' | 'closeSeenByModal' | 'closeReactorListModal' | 'openReactorListModal' | - 'toggleStatistics' | + 'toggleStatistics' | 'toggleMessageStatistics' | // auth 'setAuthPhoneNumber' | 'setAuthCode' | 'setAuthPassword' | 'signUp' | 'returnToAuthPhoneNumber' | 'setAuthRememberMe' | 'clearAuthError' | 'uploadProfilePhoto' | 'goToAuthQrCode' | 'clearCache' | @@ -896,7 +899,7 @@ export type NonTypedActionNames = ( 'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' | 'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' | // stats - 'loadStatistics' | 'loadStatisticsAsyncGraph' + 'loadStatistics' | 'loadMessageStatistics' | 'loadStatisticsAsyncGraph' ); const typed = typify(); diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index a8a056408..bcd4d4f65 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1217,4 +1217,5 @@ langpack.getLanguages#42c6978f lang_pack:string = Vector; folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph; -stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats;`; \ No newline at end of file +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;`; \ No newline at end of file diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index b87fb4170..522ace7ca 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -222,6 +222,7 @@ "help.getAppConfig", "stats.getBroadcastStats", "stats.getMegagroupStats", + "stats.getMessageStats", "stats.loadAsyncGraph", "messages.getAttachMenuBots", "messages.getAttachMenuBot", diff --git a/src/lib/lovely-chart/Tooltip.js b/src/lib/lovely-chart/Tooltip.js index 1dcbdeeca..5cc774daf 100644 --- a/src/lib/lovely-chart/Tooltip.js +++ b/src/lib/lovely-chart/Tooltip.js @@ -292,6 +292,7 @@ export function createTooltip(container, data, plotSize, colors, onZoom, onFocus return statsFormatDayHourFull(data.xLabels[labelIndex].value); case 'statsTooltipFormat(\'day\')': return getLabelDate(data.xLabels[labelIndex]); + case 'statsTooltipFormat(\'hour\')': case 'statsTooltipFormat(\'5min\')': return getLabelTime(data.xLabels[labelIndex]); default: diff --git a/src/lib/lovely-chart/Zoomer.js b/src/lib/lovely-chart/Zoomer.js index 445dc7251..db03a7462 100644 --- a/src/lib/lovely-chart/Zoomer.js +++ b/src/lib/lovely-chart/Zoomer.js @@ -26,13 +26,7 @@ export function createZoomer(data, overviewData, colors, stateManager, container const { value } = label; const dataPromise = data.shouldZoomToPie ? Promise.resolve(_generatePieData(labelIndex)) : data.onZoom(value); - dataPromise - .then((newData) => _replaceData(newData, labelIndex, label)) - .catch(() => { - tooltip.toggleLoading(false); - tooltip.toggleIsZoomed(false); - header.toggleIsZooming(false); - }); + dataPromise.then((newData) => _replaceData(newData, labelIndex, label)); } function zoomOut(state) { @@ -58,6 +52,14 @@ export function createZoomer(data, overviewData, colors, stateManager, container } function _replaceData(newRawData, labelIndex, zoomInLabel) { + if (!newRawData) { + tooltip.toggleLoading(false); + tooltip.toggleIsZoomed(false); + header.toggleIsZooming(false); + + return; + } + tooltip.toggleLoading(false); const labelWidth = 1 / data.xLabels.length; diff --git a/src/lib/lovely-chart/data.js b/src/lib/lovely-chart/data.js index b57d258c7..2e07a4c40 100644 --- a/src/lib/lovely-chart/data.js +++ b/src/lib/lovely-chart/data.js @@ -1,5 +1,5 @@ import { getMaxMin } from './utils'; -import { statsFormatDay, statsFormatDayHour, statsFormatText, statsFormatMin } from './format'; +import { statsFormatHour, statsFormatDay, statsFormatDayHour, statsFormatText, statsFormatMin } from './format'; export function analyzeData(data) { const { title, labelFormatter, tooltipFormatter, isStacked, isPercentage, hasSecondYAxis, onZoom, minimapRange, hideCaption, zoomOutLabel } = data; @@ -28,6 +28,7 @@ export function analyzeData(data) { case 'statsFormat(\'day\')': xLabels = statsFormatDay(labels); break; + case 'statsFormat(\'hour\')': case 'statsFormat(\'5min\')': xLabels = statsFormatMin(labels); break; diff --git a/src/types/index.ts b/src/types/index.ts index dacf2c116..5b3c024b4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -239,6 +239,7 @@ export enum RightColumnContent { Search, Management, Statistics, + MessageStatistics, StickerSearch, GifSearch, PollResults,