Statistics: Story Stats for Channels (#4097)

This commit is contained in:
Alexander Zinchuk 2023-12-12 12:34:46 +01:00
parent 74fd74203e
commit 78dac9bbaf
50 changed files with 1439 additions and 437 deletions

View File

@ -204,8 +204,8 @@ export function buildApiMessageWithChatId(
content,
date: mtpMessage.date,
senderId: fromId || (mtpMessage.out && mtpMessage.post && currentUserId) || chatId,
views: mtpMessage.views,
forwards: mtpMessage.forwards,
viewsCount: mtpMessage.views,
forwardsCount: mtpMessage.forwards,
isScheduled,
isFromScheduled: mtpMessage.fromScheduled,
isSilent: mtpMessage.silent,

View File

@ -4,16 +4,19 @@ import type {
ApiChannelStatistics,
ApiGroupStatistics,
ApiMessagePublicForward,
ApiMessageStatistics,
ApiPostStatistics,
ApiStoryPublicForward,
StatisticsGraph,
StatisticsMessageInteractionCounter,
StatisticsOverviewItem,
StatisticsOverviewPercentage,
StatisticsOverviewPeriod,
StatisticsStoryInteractionCounter,
} from '../../types';
import { buildAvatarHash } from './chats';
import { buildApiPeerId } from './peers';
import { buildApiUsernames } from './common';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
export function buildChannelStatistics(stats: GramJs.stats.BroadcastStats): ApiChannelStatistics {
return {
@ -28,26 +31,43 @@ export function buildChannelStatistics(stats: GramJs.stats.BroadcastStats): ApiC
viewsBySourceGraph: (stats.viewsBySourceGraph as GramJs.StatsGraphAsync).token,
newFollowersBySourceGraph: (stats.newFollowersBySourceGraph as GramJs.StatsGraphAsync).token,
interactionsGraph: (stats.interactionsGraph as GramJs.StatsGraphAsync).token,
reactionsByEmotionGraph: (stats.reactionsByEmotionGraph as GramJs.StatsGraphAsync).token,
storyInteractionsGraph: (stats.storyInteractionsGraph as GramJs.StatsGraphAsync).token,
storyReactionsByEmotionGraph: (stats.storyReactionsByEmotionGraph as GramJs.StatsGraphAsync).token,
// Statistics overview
followers: buildStatisticsOverview(stats.followers),
viewsPerPost: buildStatisticsOverview(stats.viewsPerPost),
sharesPerPost: buildStatisticsOverview(stats.sharesPerPost),
enabledNotifications: buildStatisticsPercentage(stats.enabledNotifications),
reactionsPerPost: buildStatisticsOverview(stats.reactionsPerPost),
viewsPerStory: buildStatisticsOverview(stats.viewsPerStory),
sharesPerStory: buildStatisticsOverview(stats.sharesPerStory),
reactionsPerStory: buildStatisticsOverview(stats.reactionsPerStory),
// Recent posts
recentTopMessages: stats.recentPostsInteractions.map(buildApiMessageInteractionCounter).filter(Boolean),
recentPosts: stats.recentPostsInteractions.map(buildApiPostInteractionCounter).filter(Boolean),
};
}
export function buildApiMessageInteractionCounter(
export function buildApiPostInteractionCounter(
interaction: GramJs.TypePostInteractionCounters,
): StatisticsMessageInteractionCounter | undefined {
): StatisticsMessageInteractionCounter | StatisticsStoryInteractionCounter | undefined {
if (interaction instanceof GramJs.PostInteractionCountersMessage) {
return {
msgId: interaction.msgId,
forwards: interaction.forwards,
views: interaction.views,
forwardsCount: interaction.forwards,
viewsCount: interaction.views,
reactionsCount: interaction.reactions,
};
}
if (interaction instanceof GramJs.PostInteractionCountersStory) {
return {
storyId: interaction.storyId,
reactionsCount: interaction.reactions,
viewsCount: interaction.views,
forwardsCount: interaction.forwards,
};
}
@ -75,9 +95,10 @@ export function buildGroupStatistics(stats: GramJs.stats.MegagroupStats): ApiGro
};
}
export function buildMessageStatistics(stats: GramJs.stats.MessageStats): ApiMessageStatistics {
export function buildPostsStatistics(stats: GramJs.stats.MessageStats): ApiPostStatistics {
return {
viewsGraph: buildGraph(stats.viewsGraph),
reactionsGraph: buildGraph(stats.reactionsByEmotionGraph),
};
}
@ -88,22 +109,30 @@ export function buildMessagePublicForwards(
return undefined;
}
return result.messages.map((message) => {
const peerId = buildApiPeerId((message.peerId as GramJs.PeerChannel).channelId, 'channel');
const channel = result.chats.find((p) => buildApiPeerId(p.id, 'channel') === peerId);
return result.messages.map((message) => buildApiMessagePublicForward(message, result.chats));
}
export function buildStoryPublicForwards(
result: GramJs.stats.PublicForwards,
): Array<ApiStoryPublicForward | ApiMessagePublicForward> | undefined {
if (!result || !('forwards' in result)) {
return undefined;
}
return result.forwards.map((forward) => {
if (forward instanceof GramJs.PublicForwardMessage) {
return buildApiMessagePublicForward(forward.message, result.chats);
}
const { peer, story } = forward;
const peerId = getApiChatIdFromMtpPeer(peer);
return {
messageId: message.id,
views: (message as GramJs.Message).views,
title: (channel as GramJs.Channel).title,
chat: {
id: peerId,
type: 'chatTypeChannel',
title: (channel as GramJs.Channel).title,
username: (channel as GramJs.Channel).username,
avatarHash: buildAvatarHash((channel as GramJs.Channel).photo),
},
};
peerId,
storyId: story.id,
viewsCount: (story as GramJs.StoryItem).views?.viewsCount || 0,
reactionsCount: (story as GramJs.StoryItem).views?.reactionsCount || 0,
} as ApiStoryPublicForward;
});
}
@ -191,3 +220,23 @@ function getOverviewPeriod(data: GramJs.StatsDateRangeDays): StatisticsOverviewP
minDate: data.minDate,
};
}
function buildApiMessagePublicForward(message: GramJs.TypeMessage, chats: GramJs.TypeChat[]): ApiMessagePublicForward {
const peerId = getApiChatIdFromMtpPeer(message.peerId!);
const channel = chats.find((c) => buildApiPeerId(c.id, 'channel') === peerId);
return {
messageId: message.id,
views: (message as GramJs.Message).views,
title: (channel as GramJs.Channel).title,
chat: {
id: peerId,
type: 'chatTypeChannel',
title: (channel as GramJs.Channel).title,
usernames: buildApiUsernames(channel as GramJs.Channel),
avatarHash: channel && 'photo' in channel
? buildAvatarHash((channel as GramJs.Channel).photo)
: undefined,
},
};
}

View File

@ -5,7 +5,7 @@ import type {
ApiMediaAreaCoordinates,
ApiStealthMode,
ApiStoryForwardInfo,
ApiStoryView,
ApiStoryView, ApiStoryViews,
ApiTypeStory,
MediaContent,
} from '../../types';
@ -67,13 +67,7 @@ export function buildApiStory(peerId: string, story: GramJs.TypeStoryItem): ApiT
...(selectedContacts && { isForSelectedContacts: true }),
...(closeFriends && { isForCloseFriends: true }),
...(noforwards && { noForwards: true }),
...(views?.viewsCount && { viewsCount: views.viewsCount }),
...(views?.forwardsCount && { forwardsCount: views.forwardsCount }),
...(views?.reactionsCount && { reactionsCount: views.reactionsCount }),
...(views?.reactions && { reactions: views.reactions.map(buildReactionCount) }),
...(views?.recentViewers && {
recentViewerIds: views.recentViewers.map((viewerId) => buildApiPeerId(viewerId, 'user')),
}),
...(views && { views: buildApiStoryViews(views) }),
...(out && { isOut: true }),
...(privacy && { visibility: buildPrivacyRules(privacy) }),
...(mediaAreas && { mediaAreas: mediaAreas.map(buildApiMediaArea).filter(Boolean) }),
@ -82,6 +76,18 @@ export function buildApiStory(peerId: string, story: GramJs.TypeStoryItem): ApiT
};
}
function buildApiStoryViews(views: GramJs.TypeStoryViews): ApiStoryViews | undefined {
return {
viewsCount: views.viewsCount,
forwardsCount: views.forwardsCount,
reactionsCount: views.reactionsCount,
...(views?.reactions && { reactions: views.reactions.map(buildReactionCount).filter(Boolean) }),
...(views?.recentViewers && {
recentViewerIds: views.recentViewers.map((viewerId) => buildApiPeerId(viewerId, 'user')),
}),
};
}
export function buildApiStoryView(view: GramJs.TypeStoryView): ApiStoryView {
const {
userId, date, reaction, blockedMyStoriesFrom, blocked,

View File

@ -94,7 +94,7 @@ export {
export {
fetchChannelStatistics, fetchGroupStatistics, fetchMessageStatistics,
fetchMessagePublicForwards, fetchStatisticsAsyncGraph,
fetchMessagePublicForwards, fetchStatisticsAsyncGraph, fetchStoryStatistics, fetchStoryPublicForwards,
} from './statistics';
export {

View File

@ -2,15 +2,21 @@ import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import type {
ApiChat, ApiMessagePublicForward, ApiMessageStatistics, StatisticsGraph,
ApiChat, ApiMessagePublicForward, ApiPostStatistics, ApiStoryPublicForward, ApiUser, StatisticsGraph,
} from '../../types';
import { STATISTICS_PUBLIC_FORWARDS_LIMIT } from '../../../config';
import { buildApiChatFromPreview } from '../apiBuilders/chats';
import {
buildChannelStatistics, buildGraph,
buildGroupStatistics, buildMessagePublicForwards, buildMessageStatistics,
buildChannelStatistics,
buildGraph,
buildGroupStatistics,
buildMessagePublicForwards,
buildPostsStatistics,
buildStoryPublicForwards,
} from '../apiBuilders/statistics';
import { buildApiUser } from '../apiBuilders/users';
import { buildInputEntity } from '../gramjsBuilders';
import { buildInputEntity, buildInputPeer } from '../gramjsBuilders';
import { addEntitiesToLocalDb } from '../helpers';
import { invokeRequest } from './client';
@ -62,7 +68,7 @@ export async function fetchMessageStatistics({
chat: ApiChat;
messageId: number;
dcId?: number;
}): Promise<ApiMessageStatistics | undefined> {
}): Promise<ApiPostStatistics | undefined> {
const result = await invokeRequest(new GramJs.stats.GetMessageStats({
channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel,
msgId: messageId,
@ -74,22 +80,30 @@ export async function fetchMessageStatistics({
return undefined;
}
return buildMessageStatistics(result);
return buildPostsStatistics(result);
}
export async function fetchMessagePublicForwards({
chat,
messageId,
dcId,
offsetRate = 0,
}: {
chat: ApiChat;
messageId: number;
dcId?: number;
}): Promise<ApiMessagePublicForward[] | undefined> {
offsetRate?: number;
}): Promise<{
forwards?: ApiMessagePublicForward[];
count?: number;
nextRate?: number;
} | undefined> {
const result = await invokeRequest(new GramJs.stats.GetMessagePublicForwards({
channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel,
msgId: messageId,
offsetPeer: new GramJs.InputPeerEmpty(),
offsetRate,
limit: STATISTICS_PUBLIC_FORWARDS_LIMIT,
}), {
dcId,
});
@ -102,7 +116,13 @@ export async function fetchMessagePublicForwards({
addEntitiesToLocalDb(result.chats);
}
return buildMessagePublicForwards(result);
return {
forwards: buildMessagePublicForwards(result),
...('nextRate' in result ? {
count: result.count,
nextRate: result.nextRate,
} : undefined),
};
}
export async function fetchStatisticsAsyncGraph({
@ -129,3 +149,68 @@ export async function fetchStatisticsAsyncGraph({
return buildGraph(result as GramJs.StatsGraph, isPercentage);
}
export async function fetchStoryStatistics({
chat,
storyId,
dcId,
}: {
chat: ApiChat;
storyId: number;
dcId?: number;
}): Promise<ApiPostStatistics | undefined> {
const result = await invokeRequest(new GramJs.stats.GetStoryStats({
peer: buildInputPeer(chat.id, chat.accessHash),
id: storyId,
}), {
dcId,
});
if (!result) {
return undefined;
}
return buildPostsStatistics(result);
}
export async function fetchStoryPublicForwards({
chat,
storyId,
dcId,
offsetId = '0',
}: {
chat: ApiChat;
storyId: number;
dcId?: number;
offsetId?: string;
}): Promise<{
publicForwards: (ApiMessagePublicForward | ApiStoryPublicForward)[] | undefined;
users: ApiUser[];
chats: ApiChat[];
count?: number;
nextOffsetId?: string;
} | undefined> {
const result = await invokeRequest(new GramJs.stats.GetStoryPublicForwards({
peer: buildInputPeer(chat.id, chat.accessHash),
id: storyId,
offset: offsetId,
limit: STATISTICS_PUBLIC_FORWARDS_LIMIT,
}), {
dcId,
});
if (!result) {
return undefined;
}
addEntitiesToLocalDb(result.chats);
addEntitiesToLocalDb(result.users);
return {
publicForwards: buildStoryPublicForwards(result),
users: result.users.map(buildApiUser).filter(Boolean),
chats: result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean),
count: result.count,
nextOffsetId: result.nextOffset,
};
}

View File

@ -522,7 +522,7 @@ export function updater(update: Update) {
'@type': 'updateMessage',
chatId: buildApiPeerId(update.channelId, 'channel'),
id: update.id,
message: { views: update.views },
message: { viewsCount: update.views },
});
// Chats

View File

@ -472,8 +472,8 @@ export interface ApiMessage {
forwardInfo?: ApiMessageForwardInfo;
isDeleting?: boolean;
previousLocalId?: number;
views?: number;
forwards?: number;
viewsCount?: number;
forwardsCount?: number;
isEdited?: boolean;
editDate?: number;
isMentioned?: boolean;

View File

@ -1,11 +1,13 @@
import type { ApiChat } from './chats';
import type { ApiMessage } from './messages';
export interface ApiChannelStatistics {
growthGraph?: StatisticsGraph | string;
followersGraph?: StatisticsGraph | string;
muteGraph?: StatisticsGraph | string;
topHoursGraph?: StatisticsGraph | string;
reactionsByEmotionGraph?: StatisticsGraph | string;
storyInteractionsGraph?: StatisticsGraph | string;
storyReactionsByEmotionGraph?: StatisticsGraph | string;
interactionsGraph: StatisticsGraph | string;
viewsBySourceGraph: StatisticsGraph | string;
newFollowersBySourceGraph: StatisticsGraph | string;
@ -14,7 +16,11 @@ export interface ApiChannelStatistics {
viewsPerPost: StatisticsOverviewItem;
sharesPerPost: StatisticsOverviewItem;
enabledNotifications: StatisticsOverviewPercentage;
recentTopMessages: Array<StatisticsMessageInteractionCounter | StatisticsMessageInteractionCounter & ApiMessage>;
reactionsPerPost: StatisticsOverviewItem;
viewsPerStory: StatisticsOverviewItem;
sharesPerStory: StatisticsOverviewItem;
reactionsPerStory: StatisticsOverviewItem;
recentPosts: Array<StatisticsMessageInteractionCounter | StatisticsStoryInteractionCounter>;
}
export interface ApiGroupStatistics {
@ -31,12 +37,17 @@ export interface ApiGroupStatistics {
posters: StatisticsOverviewItem;
}
export interface ApiMessageStatistics {
export interface ApiPostStatistics {
viewsGraph?: StatisticsGraph | string;
forwards?: number;
views?: number;
reactionsGraph?: StatisticsGraph | string;
forwardsCount?: number;
viewsCount?: number;
reactionsCount?: number;
publicForwards?: number;
publicForwardsData?: ApiMessagePublicForward[];
publicForwardsData?: (ApiMessagePublicForward | ApiStoryPublicForward)[];
nextRate?: number;
nextOffsetId?: string;
}
export interface ApiBoostStatistics {
@ -53,6 +64,13 @@ export interface ApiMessagePublicForward {
chat: ApiChat;
}
export interface ApiStoryPublicForward {
peerId: string;
storyId: number;
viewsCount?: number;
reactionsCount?: number;
}
export interface StatisticsGraph {
type: string;
zoomToken?: string;
@ -95,6 +113,14 @@ export interface StatisticsOverviewPeriod {
export interface StatisticsMessageInteractionCounter {
msgId: number;
forwards: number;
views: number;
forwardsCount: number;
viewsCount: number;
reactionsCount: number;
}
export interface StatisticsStoryInteractionCounter {
storyId: number;
viewsCount: number;
forwardsCount: number;
reactionsCount: number;
}

View File

@ -18,15 +18,19 @@ export interface ApiStory {
isPublic?: boolean;
isOut?: true;
noForwards?: boolean;
views?: ApiStoryViews;
visibility?: ApiPrivacySettings;
sentReaction?: ApiReaction;
mediaAreas?: ApiMediaArea[];
forwardInfo?: ApiStoryForwardInfo;
}
export interface ApiStoryViews {
viewsCount?: number;
forwardsCount?: number;
reactionsCount?: number;
reactions?: ApiReactionCount[];
recentViewerIds?: string[];
visibility?: ApiPrivacySettings;
sentReaction?: ApiReaction;
mediaAreas?: ApiMediaArea[];
forwardInfo?: ApiStoryForwardInfo;
}
export interface ApiStorySkipped {

View File

@ -73,6 +73,7 @@ export { default as StickerSearch } from '../components/right/StickerSearch';
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 StoryStatistics } from '../components/right/statistics/StoryStatistics';
export { default as PollResults } from '../components/right/PollResults';
export { default as CreateTopic } from '../components/right/CreateTopic';
export { default as EditTopic } from '../components/right/EditTopic';

View File

@ -214,6 +214,21 @@
}
}
&.size-small-mobile {
width: 2.25rem;
height: 2.25rem;
&::before {
width: 2.75rem;
height: 2.75rem;
}
&::after {
width: 2.5rem;
height: 2.5rem;
}
}
&.online::after {
bottom: -0.125rem;
right: -0.125rem;

View File

@ -56,6 +56,7 @@ type OwnProps = {
forPremiumPromo?: boolean;
withStoryGap?: boolean;
withStorySolid?: boolean;
forceUnreadStorySolid?: boolean;
storyViewerOrigin?: StoryViewerOrigin;
storyViewerMode?: 'full' | 'single-peer' | 'disabled';
loopIndefinitely?: boolean;
@ -76,6 +77,7 @@ const Avatar: FC<OwnProps> = ({
forPremiumPromo,
withStoryGap,
withStorySolid,
forceUnreadStorySolid,
storyViewerOrigin,
storyViewerMode = 'single-peer',
loopIndefinitely,
@ -217,7 +219,7 @@ const Avatar: FC<OwnProps> = ({
isForum && 'forum',
((withStory && peer?.hasStories) || forPremiumPromo) && 'with-story-circle',
withStorySolid && peer?.hasStories && 'with-story-solid',
withStorySolid && peer?.hasUnreadStories && 'has-unread-story',
withStorySolid && (peer?.hasUnreadStories || forceUnreadStorySolid) && 'has-unread-story',
onClick && 'interactive',
(!isSavedMessages && !imgBlobUrl) && 'no-photo',
);

View File

@ -295,7 +295,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
}
const global = getGlobal();
const ids = messageIds.filter((id) => selectThreadInfo(global, chatId, id)?.isCommentsInfo
|| messagesById[id]?.views !== undefined);
|| messagesById[id]?.viewsCount !== undefined);
if (!ids.length) return;

View File

@ -613,7 +613,7 @@ const Message: FC<OwnProps & StateProps> = ({
isLastInDocumentGroup && 'last-in-document-group',
isLastInList && 'last-in-list',
isOwn && 'own',
Boolean(message.views) && 'has-views',
Boolean(message.viewsCount) && 'has-views',
message.isEdited && 'was-edited',
hasMessageReply && 'has-reply',
isContextMenuOpen && 'has-menu-open',
@ -1338,7 +1338,7 @@ const Message: FC<OwnProps & StateProps> = ({
data-has-unread-mention={message.hasUnreadMention || undefined}
data-has-unread-reaction={hasUnreadReaction || undefined}
data-is-pinned={isPinned || undefined}
data-should-update-views={message.views !== undefined}
data-should-update-views={message.viewsCount !== undefined}
/>
{!isInDocumentGroup && (
<div className="message-select-control">

View File

@ -105,10 +105,10 @@ const MessageMeta: FC<OwnProps> = ({
{isTranslated && (
<i className="icon icon-language message-translated" onClick={onTranslationClick} />
)}
{Boolean(message.views) && (
{Boolean(message.viewsCount) && (
<>
<span className="message-views">
{formatIntegerCompact(message.views!)}
{formatIntegerCompact(message.viewsCount!)}
</span>
<i className="icon icon-channelviews" />
</>

View File

@ -33,6 +33,7 @@ import RightSearch from './RightSearch.async';
import BoostStatistics from './statistics/BoostStatistics';
import MessageStatistics from './statistics/MessageStatistics.async';
import Statistics from './statistics/Statistics.async';
import StoryStatistics from './statistics/StoryStatistics.async';
import StickerSearch from './StickerSearch.async';
import './RightColumn.scss';
@ -86,6 +87,7 @@ const RightColumn: FC<OwnProps & StateProps> = ({
setEditingExportedInvite,
toggleStatistics,
toggleMessageStatistics,
toggleStoryStatistics,
setOpenedInviteInfo,
requestNextManagementScreen,
resetNextProfileTab,
@ -107,6 +109,7 @@ const RightColumn: FC<OwnProps & StateProps> = ({
const isManagement = contentKey === RightColumnContent.Management;
const isStatistics = contentKey === RightColumnContent.Statistics;
const isMessageStatistics = contentKey === RightColumnContent.MessageStatistics;
const isStoryStatistics = contentKey === RightColumnContent.StoryStatistics;
const isBoostStatistics = contentKey === RightColumnContent.BoostStatistics;
const isStickerSearch = contentKey === RightColumnContent.StickerSearch;
const isGifSearch = contentKey === RightColumnContent.GifSearch;
@ -176,6 +179,9 @@ const RightColumn: FC<OwnProps & StateProps> = ({
case RightColumnContent.MessageStatistics:
toggleMessageStatistics();
break;
case RightColumnContent.StoryStatistics:
toggleStoryStatistics();
break;
case RightColumnContent.Statistics:
toggleStatistics();
break;
@ -322,6 +328,8 @@ const RightColumn: FC<OwnProps & StateProps> = ({
return <BoostStatistics />;
case RightColumnContent.MessageStatistics:
return <MessageStatistics chatId={chatId!} isActive={isOpen && isActive} />;
case RightColumnContent.StoryStatistics:
return <StoryStatistics chatId={chatId!} isActive={isOpen && isActive} />;
case RightColumnContent.StickerSearch:
return <StickerSearch onClose={close} isActive={isOpen && isActive} />;
case RightColumnContent.GifSearch:
@ -356,6 +364,7 @@ const RightColumn: FC<OwnProps & StateProps> = ({
isStatistics={isStatistics}
isBoostStatistics={isBoostStatistics}
isMessageStatistics={isMessageStatistics}
isStoryStatistics={isStoryStatistics}
isStickerSearch={isStickerSearch}
isGifSearch={isGifSearch}
isPollResults={isPollResults}
@ -373,7 +382,8 @@ const RightColumn: FC<OwnProps & StateProps> = ({
activeKey={isManagement ? MAIN_SCREENS_COUNT + managementScreen : renderingContentKey}
shouldCleanup
cleanupExceptionKey={
renderingContentKey === RightColumnContent.MessageStatistics
(renderingContentKey === RightColumnContent.MessageStatistics
|| renderingContentKey === RightColumnContent.StoryStatistics)
? RightColumnContent.Statistics : undefined
}
>

View File

@ -48,6 +48,7 @@ type OwnProps = {
isStatistics?: boolean;
isBoostStatistics?: boolean;
isMessageStatistics?: boolean;
isStoryStatistics?: boolean;
isStickerSearch?: boolean;
isGifSearch?: boolean;
isPollResults?: boolean;
@ -89,6 +90,7 @@ enum HeaderContent {
Search,
Statistics,
MessageStatistics,
StoryStatistics,
BoostStatistics,
Management,
ManageInitial,
@ -128,6 +130,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
isManagement,
isStatistics,
isMessageStatistics,
isStoryStatistics,
isBoostStatistics,
isStickerSearch,
isGifSearch,
@ -291,6 +294,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
HeaderContent.Statistics
) : isMessageStatistics ? (
HeaderContent.MessageStatistics
) : isStoryStatistics ? (
HeaderContent.StoryStatistics
) : isBoostStatistics ? (
HeaderContent.BoostStatistics
) : isCreatingTopic ? (
@ -442,6 +447,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
return <h3>{lang(isChannel ? 'ChannelStats.Title' : 'GroupStats.Title')}</h3>;
case HeaderContent.MessageStatistics:
return <h3>{lang('Stats.MessageTitle')}</h3>;
case HeaderContent.StoryStatistics:
return <h3>{lang('Stats.StoryTitle')}</h3>;
case HeaderContent.BoostStatistics:
return <h3>{lang('Boosts')}</h3>;
case HeaderContent.SharedMedia:
@ -522,6 +529,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|| contentKey === HeaderContent.StoryList
|| contentKey === HeaderContent.AddingMembers
|| contentKey === HeaderContent.MessageStatistics
|| contentKey === HeaderContent.StoryStatistics
|| isManagement
);

View File

@ -1,24 +1,30 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useEffect, useRef,
useState,
memo, useEffect, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiMessagePublicForward, ApiMessageStatistics, StatisticsGraph } from '../../../api/types';
import type {
ApiMessagePublicForward,
ApiPostStatistics,
StatisticsGraph,
} from '../../../api/types';
import { LoadMoreDirection } from '../../../types';
import { STATISTICS_PUBLIC_FORWARDS_LIMIT } from '../../../config';
import { selectChatFullInfo, selectTabState } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { callApi } from '../../../api/gramjs';
import useForceUpdate from '../../../hooks/useForceUpdate';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import InfiniteScroll from '../../ui/InfiniteScroll';
import Loading from '../../ui/Loading';
import StatisticsMessagePublicForward from './StatisticsMessagePublicForward';
import StatisticsOverview from './StatisticsOverview';
import StatisticsPublicForward from './StatisticsPublicForward';
import './Statistics.scss';
import styles from './Statistics.module.scss';
type ILovelyChart = { create: Function };
let lovelyChartPromise: Promise<ILovelyChart>;
@ -35,8 +41,9 @@ async function ensureLovelyChart() {
const GRAPH_TITLES = {
viewsGraph: 'Stats.MessageInteractionsTitle',
reactionsGraph: 'ReactionsByEmotionChartTitle',
};
const GRAPHS = Object.keys(GRAPH_TITLES) as (keyof ApiMessageStatistics)[];
const GRAPHS = Object.keys(GRAPH_TITLES) as (keyof ApiPostStatistics)[];
export type OwnProps = {
chatId: string;
@ -44,25 +51,25 @@ export type OwnProps = {
};
export type StateProps = {
statistics?: ApiMessageStatistics;
statistics?: ApiPostStatistics;
messageId?: number;
dcId?: number;
};
const Statistics: FC<OwnProps & StateProps> = ({
function Statistics({
chatId,
isActive,
statistics,
dcId,
messageId,
}) => {
}: OwnProps & StateProps) {
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 { loadMessageStatistics, loadMessagePublicForwards, loadStatisticsAsyncGraph } = getActions();
const forceUpdate = useForceUpdate();
useEffect(() => {
@ -144,34 +151,48 @@ const Statistics: FC<OwnProps & StateProps> = ({
isReady, statistics, lang, chatId, messageId, loadStatisticsAsyncGraph, dcId, forceUpdate,
]);
const handleLoadMore = useLastCallback(({ direction }: { direction: LoadMoreDirection }) => {
if (direction === LoadMoreDirection.Backwards && messageId) {
loadMessagePublicForwards({ chatId, messageId });
}
});
if (!isReady || !statistics || !messageId) {
return <Loading />;
}
return (
<div className={buildClassName('Statistics custom-scroll', isReady && 'ready')}>
<div className={buildClassName(styles.root, 'custom-scroll', isReady && styles.ready)}>
<StatisticsOverview statistics={statistics} type="message" title={lang('StatisticOverview')} />
{!loadedCharts.current.length && <Loading />}
<div ref={containerRef}>
{GRAPHS.map((graph) => (
<div className={buildClassName('Statistics__graph', !loadedCharts.current.includes(graph) && 'hidden')} />
<div className={buildClassName(styles.graph, !loadedCharts.current.includes(graph) && styles.hidden)} />
))}
</div>
{Boolean(statistics.publicForwards) && (
<div className="Statistics__public-forwards">
<h2 className="Statistics__public-forwards-title">{lang('Stats.Message.PublicShares')}</h2>
<div className={styles.publicForwards}>
<h2 className={styles.publicForwardsTitle}>{lang('Stats.Message.PublicShares')}</h2>
{statistics.publicForwardsData!.map((item: ApiMessagePublicForward) => (
<StatisticsPublicForward data={item} />
))}
<InfiniteScroll
items={statistics.publicForwardsData}
itemSelector=".statistic-public-forward"
onLoadMore={handleLoadMore}
preloadBackwards={STATISTICS_PUBLIC_FORWARDS_LIMIT}
noFastList
>
{(statistics.publicForwardsData as ApiMessagePublicForward[]).map((item) => (
<StatisticsMessagePublicForward key={item.messageId} data={item} />
))}
</InfiniteScroll>
</div>
)}
</div>
);
};
}
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {

View File

@ -0,0 +1,104 @@
.root {
height: 100%;
overflow-x: hidden;
overflow-y: hidden;
:global(.lovely-chart--container) {
font: inherit !important;
font-size: 13px !important;
}
:global(.lovely-chart--header) {
margin: 0 0.75rem;
}
:global(.lovely-chart--header),
:global(.lovely-chart--tooltip-title),
:global(.lovely-chart--tooltip-dataset-value),
:global(.lovely-chart--percentage-title) {
font-weight: 500 !important;
}
:global(.lovely-chart--container-type-pie) {
&:global(.lovely-chart--state-zoomed-in > canvas) {
animation-name: pie-slim-in !important;
}
&:global(:not(.lovely-chart--state-zoomed-in) > canvas) {
animation-name: pie-slim-out !important;
}
}
}
.messages, .publicForwards {
padding: 1rem 0;
border-top: 1px solid var(--color-borders);
&-title {
padding-left: 0.75rem;
font-size: 16px;
color: var(--text-color);
line-height: 30px;
text-transform: lowercase;
&:first-letter {
text-transform: uppercase;
}
}
}
.ready {
overflow-y: scroll !important;
}
.graph {
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-borders);
opacity: 1;
transition: opacity 0.3s ease;
&:last-of-type {
margin-bottom: 0;
border-bottom: none;
}
&.hidden {
opacity: 0;
margin: 0;
}
}
@keyframes pie-slim-in {
0% {
clip-path: circle(80% at center calc(50% - 7.5px));
transform: rotate(-360deg);
}
25% {
clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(-360deg);
}
75% {
clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(0);
}
}
@keyframes pie-slim-out {
0% {
clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(360deg);
}
50% {
clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(0);
}
75% {
clip-path: circle(80% at center calc(50% - 7.5px));
transform: rotate(0);
}
}

View File

@ -1,110 +0,0 @@
.Statistics {
height: 100%;
overflow-x: hidden;
overflow-y: hidden;
&__messages, &__public-forwards {
padding: 1rem 0;
border-top: 1px solid var(--color-borders);
&-title {
padding-left: 0.75rem;
font-size: 16px;
color: var(--text-color);
line-height: 30px;
text-transform: lowercase;
&:first-letter {
text-transform: uppercase;
}
}
}
&.ready {
overflow-y: scroll !important;
}
&__graph {
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-borders);
opacity: 1;
transition: opacity 0.3s ease;
&:last-of-type {
margin-bottom: 0;
border-bottom: none;
}
&.hidden {
opacity: 0;
margin: 0;
}
}
.lovely-chart--container {
font: inherit !important;
font-size: 13px !important;
}
.lovely-chart--header {
margin: 0 0.75rem;
}
.lovely-chart--header,
.lovely-chart--tooltip-title,
.lovely-chart--tooltip-dataset-value,
.lovely-chart--percentage-title {
font-weight: 500 !important;
}
.lovely-chart--container-type-pie {
&.lovely-chart--state-zoomed-in > canvas {
animation-name: pie-slim-in !important;
}
&:not(.lovely-chart--state-zoomed-in) > canvas {
animation-name: pie-slim-out !important;
}
}
}
@keyframes pie-slim-in {
0% {
clip-path: circle(80% at center calc(50% - 7.5px));
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
transform: rotate(-360deg);
}
25% {
clip-path: circle(40% at center calc(50% - 7.5px));
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(-360deg);
}
75% {
clip-path: circle(40% at center calc(50% - 7.5px));
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(0);
}
}
@keyframes pie-slim-out {
0% {
clip-path: circle(40% at center calc(50% - 7.5px));
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(360deg);
}
50% {
clip-path: circle(40% at center calc(50% - 7.5px));
-webkit-clip-path: circle(40% at center calc(50% - 7.5px));
transform: rotate(0);
}
75% {
clip-path: circle(80% at center calc(50% - 7.5px));
-webkit-clip-path: circle(80% at center calc(50% - 7.5px));
transform: rotate(0);
}
}

View File

@ -7,13 +7,20 @@ import { getActions, withGlobal } from '../../../global';
import type {
ApiChannelStatistics,
ApiChat,
ApiGroupStatistics,
ApiMessage,
ApiTypeStory,
StatisticsGraph,
StatisticsMessageInteractionCounter,
} from '../../../api/types';
import { selectChat, selectChatFullInfo, selectStatistics } from '../../../global/selectors';
import {
selectChat,
selectChatFullInfo,
selectChatMessages,
selectPeerStories,
selectStatistics,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { callApi } from '../../../api/gramjs';
@ -23,8 +30,9 @@ import useLang from '../../../hooks/useLang';
import Loading from '../../ui/Loading';
import StatisticsOverview from './StatisticsOverview';
import StatisticsRecentMessage from './StatisticsRecentMessage';
import StatisticsRecentStory from './StatisticsRecentStory';
import './Statistics.scss';
import styles from './Statistics.module.scss';
type ILovelyChart = { create: Function };
let lovelyChartPromise: Promise<ILovelyChart>;
@ -48,6 +56,9 @@ const CHANNEL_GRAPHS_TITLES = {
newFollowersBySourceGraph: 'ChannelStats.Graph.NewFollowersBySource',
languagesGraph: 'ChannelStats.Graph.Language',
interactionsGraph: 'ChannelStats.Graph.Interactions',
reactionsByEmotionGraph: 'ChannelStats.Graph.Reactions',
storyInteractionsGraph: 'ChannelStats.Graph.Stories',
storyReactionsByEmotionGraph: 'ChannelStats.Graph.StoriesReactions',
};
const CHANNEL_GRAPHS = Object.keys(CHANNEL_GRAPHS_TITLES) as (keyof ApiChannelStatistics)[];
@ -66,16 +77,22 @@ export type OwnProps = {
};
export type StateProps = {
chat?: ApiChat;
statistics: ApiChannelStatistics | ApiGroupStatistics;
dcId?: number;
isGroup: boolean;
messagesById: Record<string, ApiMessage>;
storiesById?: Record<string, ApiTypeStory>;
};
const Statistics: FC<OwnProps & StateProps> = ({
chatId,
chat,
statistics,
dcId,
isGroup,
messagesById,
storiesById,
}) => {
const lang = useLang();
// eslint-disable-next-line no-null/no-null
@ -163,7 +180,7 @@ const Statistics: FC<OwnProps & StateProps> = ({
loadedCharts.current.push(name);
containerRef.current!.children[index].classList.remove('hidden');
containerRef.current!.children[index].classList.remove(styles.hidden);
});
forceUpdate();
@ -177,7 +194,7 @@ const Statistics: FC<OwnProps & StateProps> = ({
}
return (
<div className={buildClassName('Statistics custom-scroll', isReady && 'ready')}>
<div className={buildClassName(styles.root, 'custom-scroll', isReady && styles.ready)}>
<StatisticsOverview
statistics={statistics}
type={isGroup ? 'group' : 'channel'}
@ -188,17 +205,43 @@ const Statistics: FC<OwnProps & StateProps> = ({
<div ref={containerRef}>
{graphs.map((graph) => (
<div key={graph} className="Statistics__graph hidden" />
<div key={graph} className={buildClassName(styles.graph, styles.hidden)} />
))}
</div>
{Boolean((statistics as ApiChannelStatistics).recentTopMessages?.length) && (
<div className="Statistics__messages">
<h2 className="Statistics__messages-title">{lang('ChannelStats.Recent.Header')}</h2>
{Boolean((statistics as ApiChannelStatistics).recentPosts?.length) && (
<div className={styles.messages}>
<h2 className={styles.messagesTitle}>{lang('ChannelStats.Recent.Header')}</h2>
{(statistics as ApiChannelStatistics).recentTopMessages.map((message) => (
<StatisticsRecentMessage message={message as ApiMessage & StatisticsMessageInteractionCounter} />
))}
{(statistics as ApiChannelStatistics).recentPosts.map((postStatistic) => {
if ('msgId' in postStatistic) {
const message = messagesById[postStatistic.msgId];
if (!message || !('content' in message)) return undefined;
return (
<StatisticsRecentMessage
key={`statistic_message_${postStatistic.msgId}`}
message={message}
postStatistic={postStatistic}
/>
);
}
if ('storyId' in postStatistic && chat) {
const story = storiesById?.[postStatistic.storyId];
return (
<StatisticsRecentStory
key={`statistic_story_${postStatistic.storyId}`}
chat={chat}
story={story}
postStatistic={postStatistic}
/>
);
}
return undefined;
})}
</div>
)}
</div>
@ -211,9 +254,11 @@ export default memo(withGlobal<OwnProps>(
const chat = selectChat(global, chatId);
const dcId = selectChatFullInfo(global, chatId)?.statisticsDcId;
const isGroup = chat?.type === 'chatTypeSuperGroup';
const messagesById = selectChatMessages(global, chatId);
const storiesById = selectPeerStories(global, chatId)?.byId;
return {
statistics, dcId, isGroup,
statistics, dcId, isGroup, chat, messagesById, storiesById,
};
},
)(Statistics));

View File

@ -5,36 +5,37 @@ import { getActions } from '../../../global';
import type { ApiMessagePublicForward } from '../../../api/types';
import { getMainUsername } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import Avatar from '../../common/Avatar';
import './StatisticsPublicForward.scss';
import styles from './StatisticsPublicForward.module.scss';
export type OwnProps = {
data: ApiMessagePublicForward;
};
const StatisticsPublicForward: FC<OwnProps> = ({ data }) => {
const StatisticsMessagePublicForward: FC<OwnProps> = ({ data }) => {
const lang = useLang();
const { openChatByUsername } = getActions();
const username = useMemo(() => getMainUsername(data.chat), [data.chat]);
const username = useMemo(() => (data.chat ? getMainUsername(data.chat) : undefined), [data.chat]);
const handleClick = useCallback(() => {
openChatByUsername({ username: username!, messageId: data.messageId });
}, [data.messageId, openChatByUsername, username]);
return (
<div className="StatisticsPublicForward" onClick={handleClick}>
<div className={buildClassName(styles.root, 'statistic-public-forward')} onClick={handleClick}>
<Avatar size="medium" peer={data.chat} />
<div className="StatisticsPublicForward__info">
<div className="StatisticsPublicForward__title">
<div>
<div className={styles.title}>
{data.title}
</div>
<div className="StatisticsPublicForward__views">
<div className={styles.views}>
{lang('ChannelStats.ViewsCount', data.views, 'i')}
</div>
</div>
@ -42,4 +43,4 @@ const StatisticsPublicForward: FC<OwnProps> = ({ data }) => {
);
};
export default memo(StatisticsPublicForward);
export default memo(StatisticsMessagePublicForward);

View File

@ -0,0 +1,64 @@
.root {
padding: 1rem 0.75rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-borders);
}
.header {
margin-bottom: 0.5rem;
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
margin-right: 2em;
font-size: 16px;
color: var(--text-color);
line-height: 30px;
text-transform: lowercase;
&:first-letter {
text-transform: uppercase;
}
}
.caption {
font-size: 0.75rem;
text-align: right;
}
.table {
width: 100%;
}
.tableCell {
width: 50%;
}
.tableHeading {
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
.tableValue {
font-weight: 500;
font-size: 1.25rem;
}
.tableSecondaryValue {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin-inline-start: 0.25rem;
}
.value {
font-size: 0.6875rem;
color: var(--color-text-green);
&.negative {
color: var(--color-error);
}
}

View File

@ -1,63 +0,0 @@
.StatisticsOverview {
padding: 1rem 0.75rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-borders);
&__header {
margin-bottom: 0.5rem;
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
}
&__title {
margin-right: 2em;
font-size: 16px;
color: var(--text-color);
line-height: 30px;
text-transform: lowercase;
&:first-letter {
text-transform: uppercase;
}
}
&__caption {
font-size: 0.75rem;
text-align: right;
}
&__table {
width: 100%;
&-cell {
width: 50%;
}
&-heading {
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
&-value {
font-weight: 500;
font-size: 1.25rem;
}
&-secondary-value {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin-inline-start: 0.25rem;
}
}
&__value {
font-size: 0.6875rem;
color: var(--color-text-green);
&.negative {
color: var(--color-error);
}
}
}

View File

@ -3,7 +3,7 @@ import React, { memo } from '../../../lib/teact/teact';
import type {
ApiBoostStatistics,
ApiChannelStatistics, ApiGroupStatistics, ApiMessageStatistics, StatisticsOverviewItem,
ApiChannelStatistics, ApiGroupStatistics, ApiPostStatistics, StatisticsOverviewItem,
} from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
@ -12,7 +12,7 @@ import { formatInteger, formatIntegerCompact } from '../../../util/textFormat';
import useLang from '../../../hooks/useLang';
import './StatisticsOverview.scss';
import styles from './StatisticsOverview.module.scss';
type OverviewCell = {
name: string;
@ -30,7 +30,15 @@ const CHANNEL_OVERVIEW: OverviewCell[][] = [
],
[
{ name: 'viewsPerPost', title: 'ChannelStats.Overview.ViewsPerPost' },
{ name: 'viewsPerStory', title: 'ChannelStats.Overview.ViewsPerStory' },
],
[
{ name: 'sharesPerPost', title: 'ChannelStats.Overview.SharesPerPost' },
{ name: 'sharesPerStory', title: 'ChannelStats.Overview.SharesPerStory' },
],
[
{ name: 'reactionsPerPost', title: 'ChannelStats.Overview.ReactionsPerPost' },
{ name: 'reactionsPerStory', title: 'ChannelStats.Overview.ReactionsPerStory' },
],
];
@ -47,13 +55,25 @@ const GROUP_OVERVIEW: OverviewCell[][] = [
const MESSAGE_OVERVIEW: OverviewCell[][] = [
[
{ name: 'views', title: 'Stats.Message.Views', isPlain: true },
{
name: 'forwards', title: 'Stats.Message.PrivateShares', isPlain: true, isApproximate: true,
},
{ name: 'viewsCount', title: 'Stats.Message.Views', isPlain: true },
{ name: 'publicForwards', title: 'Stats.Message.PublicShares', isPlain: true },
],
[
{ name: 'publicForwards', title: 'Stats.Message.PublicShares', isPlain: true },
{ name: 'reactionsCount', title: 'Channel.Stats.Overview.Reactions', isPlain: true },
{
name: 'forwardsCount', title: 'Stats.Message.PrivateShares', isPlain: true, isApproximate: true,
},
],
];
const STORY_OVERVIEW: OverviewCell[][] = [
[
{ name: 'viewsCount', title: 'Channel.Stats.Overview.Views', isPlain: true },
{ name: 'publicForwards', title: 'PublicShares', isPlain: true },
],
[
{ name: 'reactionsCount', title: 'Channel.Stats.Overview.Reactions', isPlain: true },
{ name: 'forwardsCount', title: 'PrivateShares', isPlain: true },
],
];
@ -74,13 +94,13 @@ const BOOST_OVERVIEW: OverviewCell[][] = [
],
];
type StatisticsType = 'channel' | 'group' | 'message' | 'boost';
type StatisticsType = 'channel' | 'group' | 'message' | 'boost' | 'story';
export type OwnProps = {
type: StatisticsType;
title?: string;
className?: string;
statistics: ApiChannelStatistics | ApiGroupStatistics | ApiMessageStatistics | ApiBoostStatistics;
statistics: ApiChannelStatistics | ApiGroupStatistics | ApiPostStatistics | ApiBoostStatistics;
};
const StatisticsOverview: FC<OwnProps> = ({
@ -99,7 +119,7 @@ const StatisticsOverview: FC<OwnProps> = ({
const isChangeNegative = Number(change) < 0;
return (
<span className={buildClassName('StatisticsOverview__value', isChangeNegative && 'negative')}>
<span className={buildClassName(styles.value, isChangeNegative && styles.negative)}>
{isChangeNegative ? `-${formatIntegerCompact(Math.abs(change))}` : `+${formatIntegerCompact(change)}`}
{percentage && (
<>
@ -113,26 +133,25 @@ const StatisticsOverview: FC<OwnProps> = ({
const { period } = (statistics as ApiGroupStatistics);
const schema = type === 'boost' ? BOOST_OVERVIEW : type === 'message' ? MESSAGE_OVERVIEW : type === 'group'
? GROUP_OVERVIEW : CHANNEL_OVERVIEW;
const schema = getSchemaByType(type);
return (
<div className={buildClassName('StatisticsOverview', className)}>
<div className="StatisticsOverview__header">
<div className={buildClassName(styles.root, className)}>
<div className={styles.header}>
{title && (
<div className="StatisticsOverview__title">
<div className={styles.title}>
{title}
</div>
)}
{period && (
<div className="StatisticsOverview__caption">
<div className={styles.caption}>
{formatFullDate(lang, period.minDate * 1000)} {formatFullDate(lang, period.maxDate * 1000)}
</div>
)}
</div>
<table className="StatisticsOverview__table">
<table className={styles.table}>
{schema.map((row) => (
<tr>
{row.map((cell: OverviewCell) => {
@ -140,39 +159,39 @@ const StatisticsOverview: FC<OwnProps> = ({
if (cell.isPlain) {
return (
<td className="StatisticsOverview__table-cell">
<b className="StatisticsOverview__table-value">
<td className={styles.tableCell}>
<b className={styles.tableValue}>
{`${cell.isApproximate ? '≈' : ''}${formatInteger(field)}`}
</b>
<h3 className="StatisticsOverview__table-heading">{lang(cell.title)}</h3>
<h3 className={styles.tableHeading}>{lang(cell.title)}</h3>
</td>
);
}
if (cell.isPercentage) {
return (
<td className="StatisticsOverview__table-cell">
<td className={styles.tableCell}>
{cell.withAbsoluteValue && (
<span className="StatisticsOverview__table-value">
<span className={styles.tableValue}>
{`${cell.isApproximate ? '≈' : ''}${formatInteger(field.part)}`}
</span>
)}
<span className={`StatisticsOverview__table-${cell.withAbsoluteValue ? 'secondary-' : ''}value`}>
<span className={cell.withAbsoluteValue ? styles.tableSecondaryValue : styles.tableValue}>
{field.percentage}%
</span>
<h3 className="StatisticsOverview__table-heading">{lang(cell.title)}</h3>
<h3 className={styles.tableHeading}>{lang(cell.title)}</h3>
</td>
);
}
return (
<td className="StatisticsOverview__table-cell">
<b className="StatisticsOverview__table-value">
<td className={styles.tableCell}>
<b className={styles.tableValue}>
{formatIntegerCompact(field.current)}
</b>
{' '}
{renderOverviewItemValue(field)}
<h3 className="StatisticsOverview__table-heading">{lang(cell.title)}</h3>
<h3 className={styles.tableHeading}>{lang(cell.title)}</h3>
</td>
);
})}
@ -183,4 +202,20 @@ const StatisticsOverview: FC<OwnProps> = ({
);
};
function getSchemaByType(type: StatisticsType) {
switch (type) {
case 'group':
return GROUP_OVERVIEW;
case 'message':
return MESSAGE_OVERVIEW;
case 'boost':
return BOOST_OVERVIEW;
case 'story':
return STORY_OVERVIEW;
case 'channel':
default:
return CHANNEL_OVERVIEW;
}
}
export default memo(StatisticsOverview);

View File

@ -1,4 +1,4 @@
.StatisticsPublicForward {
.root {
position: relative;
cursor: var(--custom-cursor, pointer);
padding: 0.5rem 0.75rem;
@ -9,17 +9,17 @@
background-color: var(--color-chat-hover);
}
.Avatar {
:global(.Avatar) {
flex-shrink: 0;
margin-right: 0.5rem;
}
&__title {
line-height: 1.25rem;
}
&__views {
color: var(--color-text-meta);
font-size: 0.8125rem;
}
}
.title {
line-height: 1.25rem;
}
.views {
color: var(--color-text-meta);
font-size: 0.8125rem;
}

View File

@ -1,68 +0,0 @@
.StatisticsRecentMessage {
position: relative;
cursor: var(--custom-cursor, pointer);
padding: 0.5rem 0.75rem;
&:hover, &:active {
background-color: var(--color-chat-hover);
}
&--with-image {
padding-left: 3.5rem;
}
&__summary {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 0.75rem;
.media-preview__image {
width: 2.5rem;
height: 2.5rem;
position: absolute;
left: 0.5rem;
top: 0.5rem;
object-fit: cover;
border-radius: 0.25rem;
margin-inline-end: 0.25rem;
&.round {
border-radius: 0.625rem;
}
}
.icon-play {
position: relative;
display: inline-block;
font-size: 0.75rem;
color: #fff;
margin-inline-start: -1.25rem;
margin-inline-end: 0.5rem;
bottom: 0.0625rem;
}
}
&__title {
display: flex;
align-items: center;
line-height: 1.25rem;
}
&__info {
display: flex;
align-items: center;
width: 100%;
color: var(--color-text-meta);
}
&__meta {
font-size: 0.75rem;
}
&__date {
flex: 1;
font-size: 0.8125rem;
}
}

View File

@ -18,13 +18,17 @@ import { renderMessageSummary } from '../../common/helpers/renderMessageText';
import useLang from '../../../hooks/useLang';
import useMedia from '../../../hooks/useMedia';
import './StatisticsRecentMessage.scss';
import Icon from '../../common/Icon';
import StatisticsRecentPostMeta from './StatisticsRecentPostMeta';
import styles from './StatisticsRecentPost.module.scss';
export type OwnProps = {
message: ApiMessage & StatisticsMessageInteractionCounter;
postStatistic: StatisticsMessageInteractionCounter;
message: ApiMessage;
};
const StatisticsRecentMessage: FC<OwnProps> = ({ message }) => {
const StatisticsRecentMessage: FC<OwnProps> = ({ postStatistic, message }) => {
const lang = useLang();
const { toggleMessageStatistics } = getActions();
@ -39,27 +43,25 @@ const StatisticsRecentMessage: FC<OwnProps> = ({ message }) => {
return (
<div
className={buildClassName(
'StatisticsRecentMessage',
Boolean(mediaBlobUrl || mediaThumbnail) && 'StatisticsRecentMessage--with-image',
styles.root,
Boolean(mediaBlobUrl || mediaThumbnail) && styles.withImage,
)}
onClick={handleClick}
>
<div className="StatisticsRecentMessage__title">
<div className="StatisticsRecentMessage__summary">
<div className={styles.title}>
<div className={styles.summary}>
{renderSummary(lang, message, mediaBlobUrl || mediaThumbnail, isRoundVideo)}
</div>
<div className="StatisticsRecentMessage__meta">
{lang('ChannelStats.ViewsCount', message.views, 'i')}
<div className={styles.meta}>
{lang('ChannelStats.ViewsCount', postStatistic.viewsCount, 'i')}
</div>
</div>
<div className="StatisticsRecentMessage__info">
<div className="StatisticsRecentMessage__date">
<div className={styles.info}>
<div className={styles.date}>
{formatDateTimeToString(message.date * 1000, lang.code)}
</div>
<div className="StatisticsRecentMessage__meta">
{message.forwards ? lang('ChannelStats.SharesCount', message.forwards) : 'No shares'}
</div>
<StatisticsRecentPostMeta postStatistic={postStatistic} />
</div>
</div>
);
@ -71,14 +73,14 @@ function renderSummary(lang: LangFn, message: ApiMessage, blobUrl?: string, isRo
}
return (
<span className="media-preview">
<span>
<img
src={blobUrl}
alt=""
draggable={false}
className={buildClassName('media-preview__image', isRoundVideo && 'round')}
className={buildClassName(styles.image, isRoundVideo && styles.round)}
/>
{getMessageVideo(message) && <i className="icon icon-play" />}
{getMessageVideo(message) && <Icon name="play" />}
{renderMessageSummary(lang, message, true)}
</span>
);

View File

@ -0,0 +1,127 @@
.root {
position: relative;
cursor: var(--custom-cursor, pointer);
padding: 0.5rem 0.75rem;
&:hover, &:active {
background-color: var(--color-chat-hover);
}
}
.withImage {
padding-left: 3.5rem;
}
.imageContainer {
width: 2.5rem;
height: 2.5rem;
position: absolute;
left: 0.5rem;
top: 0.5rem;
&::before {
content: "";
position: absolute;
width: 2.75rem;
height: 2.75rem;
left: -0.25rem;
top: -0.25rem;
border-radius: 50%;
background: var(--color-borders-read-story);
background-image: linear-gradient(215.87deg, var(--color-avatar-story-unread-from) -1.61%, var(--color-avatar-story-unread-to) 97.44%);
}
&::after {
content: "";
position: absolute;
width: 2.5rem;
height: 2.5rem;
left: -0.125rem;
top: -0.125rem;
border-radius: 50%;
z-index: 0;
background: var(--color-background);
}
}
.image {
width: 2.5rem;
height: 2.5rem;
position: absolute;
left: 0.5rem;
top: 0.5rem;
object-fit: cover;
border-radius: 0.25rem;
margin-inline-end: 0.25rem;
&.round {
border-radius: 0.625rem;
}
&.circle {
border-radius: 50%;
}
&:global(.Avatar) {
left: 0.375rem;
top: 0.375rem;
}
&.withStoryCircle {
top: 0;
left: 0;
width: 2.25rem;
height: 2.25rem;
z-index: 1;
}
}
.summary {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 0.75rem;
:global(.icon-play) {
position: relative;
display: inline-block;
font-size: 0.75rem;
color: #fff;
margin-inline-start: -1.25rem;
margin-inline-end: 0.5rem;
bottom: 0.0625rem;
}
}
.title {
display: flex;
align-items: center;
line-height: 1.25rem;
}
.info {
display: flex;
align-items: center;
width: 100%;
color: var(--color-text-meta);
}
.meta {
font-size: 0.75rem;
}
.metaWithIcon {
margin-inline-start: 0.375rem;
}
.metaIcon {
font-size: 0.875rem;
vertical-align: -0.125rem;
margin-inline-end: 0.125rem;
}
.date {
flex: 1;
font-size: 0.8125rem;
}

View File

@ -0,0 +1,41 @@
import React, { memo } from '../../../lib/teact/teact';
import type { StatisticsMessageInteractionCounter, StatisticsStoryInteractionCounter } from '../../../api/types';
import { formatIntegerCompact } from '../../../util/textFormat';
import useLang from '../../../hooks/useLang';
import Icon from '../../common/Icon';
import styles from './StatisticsRecentPost.module.scss';
interface OwnProps {
postStatistic: StatisticsStoryInteractionCounter | StatisticsMessageInteractionCounter;
}
function StatisticsRecentPostMeta({ postStatistic }: OwnProps) {
const lang = useLang();
return (
<div className={styles.meta}>
{postStatistic.reactionsCount > 0 && (
<span className={styles.metaWithIcon}>
<Icon name="heart-outline" className={styles.metaIcon} />
{formatIntegerCompact(postStatistic.reactionsCount)}
</span>
)}
{postStatistic.forwardsCount > 0 && (
<span className={styles.metaWithIcon}>
<Icon name="forward" className={styles.metaIcon} />
{formatIntegerCompact(postStatistic.forwardsCount)}
</span>
)}
{!postStatistic.forwardsCount && !postStatistic.reactionsCount
&& lang('ChannelStats.SharesCount_ZeroValueHolder')}
</div>
);
}
export default memo(StatisticsRecentPostMeta);

View File

@ -0,0 +1,95 @@
import React, { memo } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type {
ApiChat,
ApiTypeStory,
StatisticsStoryInteractionCounter,
} from '../../../api/types';
import type { LangFn } from '../../../hooks/useLang';
import { getStoryMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import { formatDateTimeToString } from '../../../util/dateFormat';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia';
import Avatar from '../../common/Avatar';
import StatisticsRecentPostMeta from './StatisticsRecentPostMeta';
import styles from './StatisticsRecentPost.module.scss';
export type OwnProps = {
chat: ApiChat;
story?: ApiTypeStory;
postStatistic: StatisticsStoryInteractionCounter;
};
function StatisticsRecentStory({ chat, story, postStatistic }: OwnProps) {
const lang = useLang();
const { toggleStoryStatistics } = getActions();
const isLoaded = story && 'content' in story;
const video = isLoaded ? story.content.video : undefined;
const imageHash = isLoaded ? getStoryMediaHash(story) : undefined;
const imgBlobUrl = useMedia(imageHash);
const mediaThumbnail = imgBlobUrl || video?.thumbnail?.dataUri;
const handleClick = useLastCallback(() => {
toggleStoryStatistics({ storyId: postStatistic.storyId });
});
return (
<div
className={buildClassName(styles.root, styles.withImage)}
onClick={handleClick}
>
<div className={styles.title}>
<div className={styles.summary}>
{renderSummary(lang, chat, imgBlobUrl || mediaThumbnail)}
</div>
<div className={styles.meta}>
{lang('ChannelStats.ViewsCount', postStatistic.viewsCount, 'i')}
</div>
</div>
<div className={styles.info}>
<div className={styles.date}>
{isLoaded && Boolean(story.date) && formatDateTimeToString(story.date * 1000, lang.code)}
</div>
<StatisticsRecentPostMeta postStatistic={postStatistic} />
</div>
</div>
);
}
function renderSummary(lang: LangFn, chat: ApiChat, blobUrl?: string) {
return (
<span>
{blobUrl ? (
<span className={styles.imageContainer}>
<img
src={blobUrl}
alt=""
draggable={false}
className={buildClassName(styles.image, styles.circle, styles.withStoryCircle)}
/>
</span>
) : (
<Avatar
peer={chat}
size="small-mobile"
className={styles.image}
withStorySolid
forceUnreadStorySolid
/>
)}
{lang('Story')}
</span>
);
}
export default memo(StatisticsRecentStory);

View File

@ -0,0 +1,51 @@
import React, { memo } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type {
ApiChat, ApiStoryPublicForward, ApiUser,
} from '../../../api/types';
import { getChatTitle, getUserFullName } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Avatar from '../../common/Avatar';
import styles from './StatisticsPublicForward.module.scss';
export type OwnProps = {
data: ApiStoryPublicForward;
usersById: Record<string, ApiUser>;
chatsById: Record<string, ApiChat>;
};
function StatisticsMessagePublicForward({ data, chatsById, usersById }: OwnProps) {
const lang = useLang();
const { openChat } = getActions();
const user = usersById[data.peerId];
const chat = chatsById[data.peerId];
const handleClick = useLastCallback(() => {
openChat({ id: user.id });
});
return (
<div className={buildClassName(styles.root, 'statistic-public-forward')} onClick={handleClick}>
<Avatar size="medium" peer={user || chat} withStorySolid forceUnreadStorySolid />
<div>
<div className={styles.title}>
{user ? getUserFullName(user) : getChatTitle(lang, chat)}
</div>
<div className={styles.views}>
{data.viewsCount ? lang('ChannelStats.ViewsCount', data.viewsCount, 'i') : lang('NoViews')}
</div>
</div>
</div>
);
}
export default memo(StatisticsMessagePublicForward);

View File

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

View File

@ -0,0 +1,228 @@
import React, {
memo, useEffect, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type {
ApiChat,
ApiPostStatistics,
ApiUser,
StatisticsGraph,
} from '../../../api/types';
import { STATISTICS_PUBLIC_FORWARDS_LIMIT } from '../../../config';
import { selectChatFullInfo, selectTabState } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { callApi } from '../../../api/gramjs';
import useForceUpdate from '../../../hooks/useForceUpdate';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import InfiniteScroll from '../../ui/InfiniteScroll';
import Loading from '../../ui/Loading';
import StatisticsMessagePublicForward from './StatisticsMessagePublicForward';
import StatisticsOverview from './StatisticsOverview';
import StatisticsStoryPublicForward from './StatisticsStoryPublicForward';
import styles from './Statistics.module.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.StoryInteractionsTitle',
reactionsGraph: 'ReactionsByEmotionChartTitle',
};
const GRAPHS = Object.keys(GRAPH_TITLES) as (keyof ApiPostStatistics)[];
export type OwnProps = {
chatId: string;
isActive: boolean;
};
export type StateProps = {
statistics?: ApiPostStatistics;
storyId?: number;
dcId?: number;
chatsById: Record<string, ApiChat>;
usersById: Record<string, ApiUser>;
};
function StoryStatistics({
chatId,
isActive,
statistics,
dcId,
storyId,
chatsById,
usersById,
}: OwnProps & StateProps) {
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 { loadStoryStatistics, loadStoryPublicForwards, loadStatisticsAsyncGraph } = getActions();
const forceUpdate = useForceUpdate();
useEffect(() => {
if (storyId) {
loadStoryStatistics({ chatId, storyId });
}
}, [chatId, storyId]);
useEffect(() => {
if (!isActive || storyId) {
loadedCharts.current = [];
setIsReady(false);
}
}, [isActive, storyId]);
// 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 || !containerRef.current) {
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, storyId, loadStatisticsAsyncGraph, dcId, forceUpdate,
]);
const handleLoadMore = useLastCallback(() => {
if (!storyId) return;
loadStoryPublicForwards({ chatId, storyId });
});
if (!isReady || !statistics || !storyId) {
return <Loading />;
}
return (
<div className={buildClassName(styles.root, 'custom-scroll', isReady && styles.ready)}>
<StatisticsOverview statistics={statistics} type="story" title={lang('StatisticOverview')} />
{!loadedCharts.current.length && <Loading />}
<div ref={containerRef}>
{GRAPHS.map((graph) => (
<div className={buildClassName(styles.graph, !loadedCharts.current.includes(graph) && styles.hidden)} />
))}
</div>
{Boolean(statistics.publicForwards) && (
<div className={styles.publicForwards}>
<h2 className={styles.publicForwardsTitle}>{lang('Stats.Message.PublicShares')}</h2>
<InfiniteScroll
items={statistics.publicForwardsData}
itemSelector=".statistic-public-forward"
onLoadMore={handleLoadMore}
preloadBackwards={STATISTICS_PUBLIC_FORWARDS_LIMIT}
noFastList
>
{statistics.publicForwardsData!.map((item) => {
if ('messageId' in item) {
return (
<StatisticsMessagePublicForward key={`message_${item.messageId}`} data={item} />
);
}
return (
<StatisticsStoryPublicForward
key={`story_${item.storyId}`}
data={item}
chatsById={chatsById}
usersById={usersById}
/>
);
})}
</InfiniteScroll>
</div>
)}
</div>
);
}
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const dcId = selectChatFullInfo(global, chatId)?.statisticsDcId;
const tabState = selectTabState(global);
const statistics = tabState.statistics.currentStory;
const storyId = tabState.statistics.currentStoryId;
const { byId: usersById } = global.users;
const { byId: chatsById } = global.chats;
return {
statistics, dcId, storyId, usersById, chatsById,
};
},
)(StoryStatistics));

View File

@ -32,8 +32,9 @@ const StoryFooter = ({
const lang = useLang();
const {
viewsCount, forwardsCount, reactionsCount, isOut, peerId, id: storyId, sentReaction,
views, isOut, peerId, id: storyId, sentReaction,
} = story;
const { viewsCount, forwardsCount, reactionsCount } = views || {};
const isChannel = !isUserId(peerId);
const isSentStoryReactionHeart = sentReaction && 'emoticon' in sentReaction
@ -50,11 +51,11 @@ const StoryFooter = ({
const recentViewers = useMemo(() => {
const { users: { byId: usersById } } = getGlobal();
const recentViewerIds = story && 'recentViewerIds' in story ? story.recentViewerIds : undefined;
const recentViewerIds = views && 'recentViewerIds' in views ? views.recentViewerIds : undefined;
if (!recentViewerIds) return undefined;
return recentViewerIds.map((id) => usersById[id]).filter(Boolean);
}, [story]);
}, [views]);
const handleOpenStoryViewModal = useLastCallback(() => {
openStoryViewModal({ storyId });

View File

@ -69,8 +69,7 @@ function StoryViewModal({
const isOpen = Boolean(story);
const isExpired = Boolean(story?.date) && (story!.date + viewersExpirePeriod) < getServerTime();
const viewsCount = story?.viewsCount || 0;
const reactionsCount = story?.reactionsCount || 0;
const { viewsCount = 0, reactionsCount = 0 } = story?.views || {};
const shouldShowJustContacts = story?.isPublic && viewsCount > STORY_VIEWS_MIN_CONTACTS_FILTER;
const shouldShowSortByReactions = reactionsCount > STORY_MIN_REACTIONS_SORT;

View File

@ -44,7 +44,8 @@ const MediaAreaSuggestedReaction = ({
const ref = useRef<HTMLDivElement>(null);
const [customEmojiSize, setCustomEmojiSize] = useState(1.5 * REM);
const { peerId, id, reactions } = story;
const { peerId, id, views } = story;
const { reactions } = views || {};
const { reaction, isDark, isFlipped } = mediaArea;
const isChannel = !isUserId(peerId);

View File

@ -90,6 +90,7 @@ export const COMMON_CHATS_LIMIT = 100;
export const GROUP_CALL_PARTICIPANTS_LIMIT = 100;
export const STORY_LIST_LIMIT = 100;
export const API_GENERAL_ID_LIMIT = 100;
export const STATISTICS_PUBLIC_FORWARDS_LIMIT = 50;
export const STORY_VIEWS_MIN_SEARCH = 15;
export const STORY_MIN_REACTIONS_SORT = 10;

View File

@ -1776,8 +1776,8 @@ addActionHandler('loadMessageViews', async (global, actions, payload): Promise<v
global = addChats(global, buildCollectionByKey(result.chats, 'id'));
result.viewsInfo.forEach((update) => {
global = updateChatMessage(global, chatId, update.id, {
views: update.views,
forwards: update.forwards,
viewsCount: update.views,
forwardsCount: update.forwards,
});
global = updateThreadInfo(global, chatId, update.id, update.threadInfo);

View File

@ -1,12 +1,23 @@
import { areDeepEqual } from '../../../util/areDeepEqual';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { buildCollectionByKey } from '../../../util/iteratees';
import { callApi } from '../../../api/gramjs';
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import {
addChats,
addUsers,
updateMessageStatistics, updateStatistics, updateStatisticsGraph,
updateMessageStatistics,
updateStatistics,
updateStatisticsGraph,
updateStoryStatistics,
} from '../../reducers';
import { selectChat, selectChatFullInfo, selectChatMessages } from '../../selectors';
import {
selectChat,
selectChatFullInfo,
selectChatMessages,
selectPeerStory,
selectTabState,
} from '../../selectors';
addActionHandler('loadStatistics', async (global, actions, payload): Promise<void> => {
const { chatId, isGroup, tabId = getCurrentTabId() } = payload;
@ -28,13 +39,6 @@ addActionHandler('loadStatistics', async (global, actions, payload): Promise<voi
const { stats, users } = result;
global = addUsers(global, buildCollectionByKey(users, 'id'));
if ('recentTopMessages' in stats && stats.recentTopMessages.length) {
const messages = selectChatMessages(global, chatId);
stats.recentTopMessages = stats.recentTopMessages.map((message) => ({ ...message, ...messages[message.msgId] }));
}
global = updateStatistics(global, chatId, stats, tabId);
setGlobal(global);
});
@ -55,18 +59,63 @@ addActionHandler('loadMessageStatistics', async (global, actions, payload): Prom
global = getGlobal();
const { views, forwards } = selectChatMessages(global, chatId)[messageId];
result.views = views;
result.forwards = forwards;
const publicForwards = await callApi('fetchMessagePublicForwards', { chat, messageId, dcId });
result.publicForwards = publicForwards?.length;
result.publicForwardsData = publicForwards;
global = getGlobal();
const {
viewsCount,
forwardsCount,
reactions,
} = selectChatMessages(global, chatId)[messageId] || {};
result.viewsCount = viewsCount;
result.forwardsCount = forwardsCount;
result.reactionsCount = reactions?.results
? reactions?.results.reduce((acc, reaction) => acc + reaction.count, 0)
: undefined;
global = updateMessageStatistics(global, result, tabId);
setGlobal(global);
actions.loadMessagePublicForwards({
chatId,
messageId,
tabId,
});
});
addActionHandler('loadMessagePublicForwards', async (global, actions, payload): Promise<void> => {
const { chatId, messageId, tabId = getCurrentTabId() } = payload;
const chat = selectChat(global, chatId);
const fullInfo = selectChatFullInfo(global, chatId);
if (!chat || !fullInfo) {
return;
}
const dcId = fullInfo.statisticsDcId;
const stats = selectTabState(global, tabId).statistics.currentMessage || {};
if (stats?.publicForwards && !stats.nextRate) return;
const publicForwards = await callApi('fetchMessagePublicForwards', {
chat, messageId, dcId, offsetRate: stats?.nextRate,
});
const {
forwards,
nextRate,
count,
} = publicForwards || {};
// Api returns the last element from the previous page as the first element
const shouldOmitFirstElement = stats.publicForwardsData?.length && forwards?.length
&& areDeepEqual(stats.publicForwardsData[stats.publicForwardsData.length - 1], forwards[0]);
global = getGlobal();
global = updateMessageStatistics(global, {
...stats,
publicForwards: count || forwards?.length,
publicForwardsData: (stats.publicForwardsData || []).concat(
shouldOmitFirstElement ? forwards.slice(1) : (forwards || []),
),
nextRate,
}, tabId);
setGlobal(global);
});
addActionHandler('loadStatisticsAsyncGraph', async (global, actions, payload): Promise<void> => {
@ -89,3 +138,80 @@ addActionHandler('loadStatisticsAsyncGraph', async (global, actions, payload): P
global = updateStatisticsGraph(global, chatId, name, result, tabId);
setGlobal(global);
});
addActionHandler('loadStoryStatistics', async (global, actions, payload): Promise<void> => {
const { chatId, storyId, tabId = getCurrentTabId() } = payload;
const chat = selectChat(global, chatId);
const fullInfo = selectChatFullInfo(global, chatId);
if (!chat || !fullInfo) {
return;
}
const dcId = fullInfo.statisticsDcId;
let result = await callApi('fetchStoryStatistics', { chat, storyId, dcId });
if (!result) {
result = {};
}
global = getGlobal();
const story = selectPeerStory(global, chatId, storyId);
const {
forwardsCount = 0,
viewsCount = 0,
reactionsCount = 0,
} = story && 'views' in story && story.views ? story.views : {};
result.viewsCount = viewsCount;
result.forwardsCount = forwardsCount;
result.reactionsCount = reactionsCount;
global = getGlobal();
global = updateStoryStatistics(global, result, tabId);
setGlobal(global);
actions.loadStoryPublicForwards({
chatId,
storyId,
tabId,
});
});
addActionHandler('loadStoryPublicForwards', async (global, actions, payload): Promise<void> => {
const { chatId, storyId, tabId = getCurrentTabId() } = payload;
const chat = selectChat(global, chatId);
const fullInfo = selectChatFullInfo(global, chatId);
if (!chat || !fullInfo) {
return;
}
const dcId = fullInfo.statisticsDcId;
const stats = selectTabState(global, tabId).statistics.currentStory || {};
if (stats?.publicForwards && !stats.nextOffsetId) return;
const {
publicForwards,
users,
chats,
count,
nextOffsetId,
} = await callApi('fetchStoryPublicForwards', {
chat, storyId, dcId, offsetId: stats.nextOffsetId,
}) || {};
global = getGlobal();
if (chats) {
global = addChats(global, buildCollectionByKey(chats, 'id'));
}
if (users) {
global = addUsers(global, buildCollectionByKey(users, 'id'));
}
global = updateStoryStatistics(global, {
...stats,
publicForwards: count || publicForwards?.length,
publicForwardsData: (stats.publicForwardsData || []).concat(
publicForwards || [],
),
nextOffsetId,
}, tabId);
setGlobal(global);
});

View File

@ -20,6 +20,7 @@ import {
updatePeerPinnedStory,
updatePeerStoriesHidden,
updatePeerStory,
updatePeerStoryViews,
updatePeersWithStories,
updateSentStoryReaction,
updateStealthMode,
@ -360,7 +361,7 @@ addActionHandler('loadStoryViews', async (global, actions, payload): Promise<voi
if (isPreload && result.views?.length) {
const recentViewerIds = result.views.map((view) => view.userId);
global = updatePeerStory(global, peerId, storyId, {
global = updatePeerStoryViews(global, peerId, storyId, {
recentViewerIds,
viewsCount: result.viewsCount,
reactionsCount: result.reactionsCount,

View File

@ -162,6 +162,7 @@ addActionHandler('toggleStatistics', (global, actions, payload): ActionReturnTyp
statistics: {
...tabState.statistics,
currentMessageId: undefined,
currentStoryId: undefined,
},
}, tabId);
});
@ -172,6 +173,18 @@ addActionHandler('toggleMessageStatistics', (global, actions, payload): ActionRe
statistics: {
...selectTabState(global, tabId).statistics,
currentMessageId: messageId,
currentStoryId: undefined,
},
}, tabId);
});
addActionHandler('toggleStoryStatistics', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId(), storyId } = payload || {};
return updateTabState(global, {
statistics: {
...selectTabState(global, tabId).statistics,
currentStoryId: storyId,
currentMessageId: undefined,
},
}, tabId);
});

View File

@ -1,5 +1,5 @@
import type {
ApiChannelStatistics, ApiGroupStatistics, ApiMessageStatistics, StatisticsGraph,
ApiChannelStatistics, ApiGroupStatistics, ApiPostStatistics, StatisticsGraph,
} from '../../api/types';
import type { GlobalState, TabArgs } from '../types';
@ -22,13 +22,27 @@ export function updateStatistics<T extends GlobalState>(
}
export function updateMessageStatistics<T extends GlobalState>(
global: T, statistics: ApiMessageStatistics,
global: T, statistics: ApiPostStatistics,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
return updateTabState(global, {
statistics: {
...selectTabState(global, tabId).statistics,
currentMessage: statistics,
currentStory: undefined,
},
}, tabId);
}
export function updateStoryStatistics<T extends GlobalState>(
global: T, statistics: ApiPostStatistics,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
return updateTabState(global, {
statistics: {
...selectTabState(global, tabId).statistics,
currentStory: statistics,
currentMessage: undefined,
},
}, tabId);
}

View File

@ -6,6 +6,7 @@ import type {
ApiStoryDeleted,
ApiStorySkipped,
ApiStoryView,
ApiStoryViews,
ApiTypeStory,
} from '../../api/types';
import type { GlobalState, TabArgs } from '../types';
@ -317,17 +318,21 @@ export function updateSentStoryReaction<T extends GlobalState>(
const story = selectPeerStory(global, peerId, storyId);
if (!story || !('content' in story)) return global;
const reactionsCount = story.reactionsCount || 0;
const hasReaction = story.reactions?.some((r) => r.chosenOrder !== undefined);
const reactions = updateReactionCount(story.reactions || [], [reaction].filter(Boolean));
const { views } = story;
const reactionsCount = views?.reactionsCount || 0;
const hasReaction = views?.reactions?.some((r) => r.chosenOrder !== undefined);
const reactions = updateReactionCount(views?.reactions || [], [reaction].filter(Boolean));
const countDiff = !reaction ? -1 : hasReaction ? 0 : 1;
const newReactionsCount = reactionsCount + countDiff;
global = updatePeerStory(global, peerId, storyId, {
sentReaction: reaction,
reactionsCount: newReactionsCount,
reactions,
views: {
...views,
reactionsCount: newReactionsCount,
reactions,
},
});
return global;
@ -364,6 +369,25 @@ export function updatePeerStory<T extends GlobalState>(
};
}
export function updatePeerStoryViews<T extends GlobalState>(
global: T,
peerId: string,
storyId: number,
viewsUpdate: Partial<ApiStoryViews>,
): T {
const story = selectPeerStory(global, peerId, storyId);
if (!story || !('content' in story)) return global;
const { views } = story;
return updatePeerStory(global, peerId, storyId, {
views: {
...views,
...viewsUpdate,
},
});
}
export function updatePeerPinnedStory<T extends GlobalState>(
global: T,
peerId: string,

View File

@ -37,6 +37,8 @@ export function selectRightColumnContentKey<T extends GlobalState>(
RightColumnContent.Management
) : tabState.isStatisticsShown && tabState.statistics.currentMessageId ? (
RightColumnContent.MessageStatistics
) : tabState.isStatisticsShown && tabState.statistics.currentStoryId ? (
RightColumnContent.StoryStatistics
) : selectIsStatisticsShown(global, tabId) ? (
RightColumnContent.Statistics
) : tabState.boostStatistics ? (

View File

@ -33,7 +33,6 @@ import type {
ApiKeyboardButton,
ApiMessage,
ApiMessageEntity,
ApiMessageStatistics,
ApiMyBoost,
ApiNewPoll,
ApiNotification,
@ -44,6 +43,7 @@ import type {
ApiPeerStories,
ApiPhoneCall,
ApiPhoto,
ApiPostStatistics,
ApiPremiumPromo,
ApiReaction,
ApiReceipt,
@ -499,8 +499,10 @@ export type TabState = {
statistics: {
byChatId: Record<string, ApiChannelStatistics | ApiGroupStatistics>;
currentMessage?: ApiMessageStatistics;
currentMessage?: ApiPostStatistics;
currentMessageId?: number;
currentStory?: ApiPostStatistics;
currentStoryId?: number;
};
newContact?: {
@ -1495,6 +1497,9 @@ export interface ActionPayloads {
toggleMessageStatistics: ({
messageId?: number;
} & WithTabId) | undefined;
toggleStoryStatistics: ({
storyId?: number;
} & WithTabId) | undefined;
loadStatistics: {
chatId: string;
isGroup: boolean;
@ -1503,6 +1508,18 @@ export interface ActionPayloads {
chatId: string;
messageId: number;
} & WithTabId;
loadMessagePublicForwards: {
chatId: string;
messageId: number;
} & WithTabId;
loadStoryStatistics: {
chatId: string;
storyId: number;
} & WithTabId;
loadStoryPublicForwards: {
chatId: string;
storyId: number;
} & WithTabId;
loadStatisticsAsyncGraph: {
chatId: string;
token: string;

View File

@ -1482,6 +1482,8 @@ stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph;
stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats;
stats.getMessagePublicForwards#5630281b channel:InputChannel msg_id:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages;
stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats;
stats.getStoryStats#374fef40 flags:# dark:flags.0?true peer:InputPeer id:int = stats.StoryStats;
stats.getStoryPublicForwards#a6437ef6 peer:InputPeer id:int offset:string limit:int = stats.PublicForwards;
chatlists.exportChatlistInvite#8472478e chatlist:InputChatlist title:string peers:Vector<InputPeer> = chatlists.ExportedChatlistInvite;
chatlists.deleteExportedInvite#719c5c5e chatlist:InputChatlist slug:string = Bool;
chatlists.editExportedInvite#653db63d flags:# chatlist:InputChatlist slug:string title:flags.1?string peers:flags.2?Vector<InputPeer> = ExportedChatlistInvite;

View File

@ -256,8 +256,10 @@
"help.getPeerColors",
"stats.getBroadcastStats",
"stats.getMegagroupStats",
"stats.getMessageStats",
"stats.getMessagePublicForwards",
"stats.getMessageStats",
"stats.getStoryStats",
"stats.getStoryPublicForwards",
"stats.loadAsyncGraph",
"messages.getAttachMenuBots",
"messages.getAttachMenuBot",

View File

@ -22,7 +22,7 @@ export function createProjection(params) {
if (end === 1) {
effectiveWidth -= xPadding;
}
const xFactor = effectiveWidth / ((end - begin) * totalXWidth);
const xFactor = effectiveWidth / ((end !== begin ? end - begin : 1) * totalXWidth);
let xOffsetPx = (begin * totalXWidth) * xFactor;
if (begin === 0) {
xOffsetPx -= xPadding;

View File

@ -277,6 +277,7 @@ export enum RightColumnContent {
Statistics,
BoostStatistics,
MessageStatistics,
StoryStatistics,
StickerSearch,
GifSearch,
PollResults,