From 78dac9bbafccaf73c6a1d0852546034a3eb3c734 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 12 Dec 2023 12:34:46 +0100 Subject: [PATCH] Statistics: Story Stats for Channels (#4097) --- src/api/gramjs/apiBuilders/messages.ts | 4 +- src/api/gramjs/apiBuilders/statistics.ts | 93 +++++-- src/api/gramjs/apiBuilders/stories.ts | 22 +- src/api/gramjs/methods/index.ts | 2 +- src/api/gramjs/methods/statistics.ts | 101 +++++++- src/api/gramjs/updater.ts | 2 +- src/api/types/messages.ts | 4 +- src/api/types/statistics.ts | 42 +++- src/api/types/stories.ts | 12 +- src/bundles/extra.ts | 1 + src/components/common/Avatar.scss | 15 ++ src/components/common/Avatar.tsx | 4 +- src/components/middle/MessageList.tsx | 2 +- src/components/middle/message/Message.tsx | 4 +- src/components/middle/message/MessageMeta.tsx | 4 +- src/components/right/RightColumn.tsx | 12 +- src/components/right/RightHeader.tsx | 8 + .../right/statistics/MessageStatistics.tsx | 59 +++-- .../right/statistics/Statistics.module.scss | 104 ++++++++ .../right/statistics/Statistics.scss | 110 --------- .../right/statistics/Statistics.tsx | 71 +++++- ...tsx => StatisticsMessagePublicForward.tsx} | 17 +- .../statistics/StatisticsOverview.module.scss | 64 +++++ .../right/statistics/StatisticsOverview.scss | 63 ----- .../right/statistics/StatisticsOverview.tsx | 89 ++++--- ...ss => StatisticsPublicForward.module.scss} | 22 +- .../statistics/StatisticsRecentMessage.scss | 68 ------ .../statistics/StatisticsRecentMessage.tsx | 36 +-- .../StatisticsRecentPost.module.scss | 127 ++++++++++ .../statistics/StatisticsRecentPostMeta.tsx | 41 ++++ .../statistics/StatisticsRecentStory.tsx | 95 ++++++++ .../StatisticsStoryPublicForward.tsx | 51 ++++ .../statistics/StoryStatistics.async.tsx | 19 ++ .../right/statistics/StoryStatistics.tsx | 228 ++++++++++++++++++ src/components/story/StoryFooter.tsx | 7 +- src/components/story/StoryViewModal.tsx | 3 +- .../mediaArea/MediaAreaSuggestedReaction.tsx | 3 +- src/config.ts | 1 + src/global/actions/api/messages.ts | 4 +- src/global/actions/api/statistics.ts | 162 +++++++++++-- src/global/actions/api/stories.ts | 3 +- src/global/actions/ui/misc.ts | 13 + src/global/reducers/statistics.ts | 18 +- src/global/reducers/stories.ts | 34 ++- src/global/selectors/ui.ts | 2 + src/global/types.ts | 21 +- src/lib/gramjs/tl/apiTl.js | 2 + src/lib/gramjs/tl/static/api.json | 4 +- src/lib/lovely-chart/Projection.js | 2 +- src/types/index.ts | 1 + 50 files changed, 1439 insertions(+), 437 deletions(-) create mode 100644 src/components/right/statistics/Statistics.module.scss delete mode 100644 src/components/right/statistics/Statistics.scss rename src/components/right/statistics/{StatisticsPublicForward.tsx => StatisticsMessagePublicForward.tsx} (63%) create mode 100644 src/components/right/statistics/StatisticsOverview.module.scss delete mode 100644 src/components/right/statistics/StatisticsOverview.scss rename src/components/right/statistics/{StatisticsPublicForward.scss => StatisticsPublicForward.module.scss} (61%) delete mode 100644 src/components/right/statistics/StatisticsRecentMessage.scss create mode 100644 src/components/right/statistics/StatisticsRecentPost.module.scss create mode 100644 src/components/right/statistics/StatisticsRecentPostMeta.tsx create mode 100644 src/components/right/statistics/StatisticsRecentStory.tsx create mode 100644 src/components/right/statistics/StatisticsStoryPublicForward.tsx create mode 100644 src/components/right/statistics/StoryStatistics.async.tsx create mode 100644 src/components/right/statistics/StoryStatistics.tsx diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 3e52a1d68..8f1fe5d88 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -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, diff --git a/src/api/gramjs/apiBuilders/statistics.ts b/src/api/gramjs/apiBuilders/statistics.ts index fef6b34b2..d9977974c 100644 --- a/src/api/gramjs/apiBuilders/statistics.ts +++ b/src/api/gramjs/apiBuilders/statistics.ts @@ -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 | 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, + }, + }; +} diff --git a/src/api/gramjs/apiBuilders/stories.ts b/src/api/gramjs/apiBuilders/stories.ts index 8027580c4..03e61a29f 100644 --- a/src/api/gramjs/apiBuilders/stories.ts +++ b/src/api/gramjs/apiBuilders/stories.ts @@ -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, diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 2176fd5e0..91908dbb3 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -94,7 +94,7 @@ export { export { fetchChannelStatistics, fetchGroupStatistics, fetchMessageStatistics, - fetchMessagePublicForwards, fetchStatisticsAsyncGraph, + fetchMessagePublicForwards, fetchStatisticsAsyncGraph, fetchStoryStatistics, fetchStoryPublicForwards, } from './statistics'; export { diff --git a/src/api/gramjs/methods/statistics.ts b/src/api/gramjs/methods/statistics.ts index e305884cd..c367659da 100644 --- a/src/api/gramjs/methods/statistics.ts +++ b/src/api/gramjs/methods/statistics.ts @@ -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 { +}): Promise { 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 { + 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 { + 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, + }; +} diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index bd629c4c1..0d530849c 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -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 diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 8dacd10f0..9bf4b5684 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -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; diff --git a/src/api/types/statistics.ts b/src/api/types/statistics.ts index 1b4669a4a..b0c9cefd1 100644 --- a/src/api/types/statistics.ts +++ b/src/api/types/statistics.ts @@ -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; + reactionsPerPost: StatisticsOverviewItem; + viewsPerStory: StatisticsOverviewItem; + sharesPerStory: StatisticsOverviewItem; + reactionsPerStory: StatisticsOverviewItem; + recentPosts: Array; } 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; } diff --git a/src/api/types/stories.ts b/src/api/types/stories.ts index a5b09928c..13fd8fe92 100644 --- a/src/api/types/stories.ts +++ b/src/api/types/stories.ts @@ -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 { diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 5b6890c56..0dcc5e7cc 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -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'; diff --git a/src/components/common/Avatar.scss b/src/components/common/Avatar.scss index a4be623dd..031ce5cfa 100644 --- a/src/components/common/Avatar.scss +++ b/src/components/common/Avatar.scss @@ -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; diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index 3e5a8ec7f..3a9a8ed36 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -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 = ({ forPremiumPromo, withStoryGap, withStorySolid, + forceUnreadStorySolid, storyViewerOrigin, storyViewerMode = 'single-peer', loopIndefinitely, @@ -217,7 +219,7 @@ const Avatar: FC = ({ 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', ); diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index 9885459dc..5f924df5b 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -295,7 +295,7 @@ const MessageList: FC = ({ } 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; diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 6d183288f..b3f910136 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -613,7 +613,7 @@ const Message: FC = ({ 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 = ({ 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 && (
diff --git a/src/components/middle/message/MessageMeta.tsx b/src/components/middle/message/MessageMeta.tsx index 8707fa54a..3ed490ca3 100644 --- a/src/components/middle/message/MessageMeta.tsx +++ b/src/components/middle/message/MessageMeta.tsx @@ -105,10 +105,10 @@ const MessageMeta: FC = ({ {isTranslated && ( )} - {Boolean(message.views) && ( + {Boolean(message.viewsCount) && ( <> - {formatIntegerCompact(message.views!)} + {formatIntegerCompact(message.viewsCount!)} diff --git a/src/components/right/RightColumn.tsx b/src/components/right/RightColumn.tsx index ce66d7023..3df28551b 100644 --- a/src/components/right/RightColumn.tsx +++ b/src/components/right/RightColumn.tsx @@ -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 = ({ setEditingExportedInvite, toggleStatistics, toggleMessageStatistics, + toggleStoryStatistics, setOpenedInviteInfo, requestNextManagementScreen, resetNextProfileTab, @@ -107,6 +109,7 @@ const RightColumn: FC = ({ 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 = ({ case RightColumnContent.MessageStatistics: toggleMessageStatistics(); break; + case RightColumnContent.StoryStatistics: + toggleStoryStatistics(); + break; case RightColumnContent.Statistics: toggleStatistics(); break; @@ -322,6 +328,8 @@ const RightColumn: FC = ({ return ; case RightColumnContent.MessageStatistics: return ; + case RightColumnContent.StoryStatistics: + return ; case RightColumnContent.StickerSearch: return ; case RightColumnContent.GifSearch: @@ -356,6 +364,7 @@ const RightColumn: FC = ({ isStatistics={isStatistics} isBoostStatistics={isBoostStatistics} isMessageStatistics={isMessageStatistics} + isStoryStatistics={isStoryStatistics} isStickerSearch={isStickerSearch} isGifSearch={isGifSearch} isPollResults={isPollResults} @@ -373,7 +382,8 @@ const RightColumn: FC = ({ activeKey={isManagement ? MAIN_SCREENS_COUNT + managementScreen : renderingContentKey} shouldCleanup cleanupExceptionKey={ - renderingContentKey === RightColumnContent.MessageStatistics + (renderingContentKey === RightColumnContent.MessageStatistics + || renderingContentKey === RightColumnContent.StoryStatistics) ? RightColumnContent.Statistics : undefined } > diff --git a/src/components/right/RightHeader.tsx b/src/components/right/RightHeader.tsx index a9d4d2043..0e74563fb 100644 --- a/src/components/right/RightHeader.tsx +++ b/src/components/right/RightHeader.tsx @@ -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 = ({ isManagement, isStatistics, isMessageStatistics, + isStoryStatistics, isBoostStatistics, isStickerSearch, isGifSearch, @@ -291,6 +294,8 @@ const RightHeader: FC = ({ HeaderContent.Statistics ) : isMessageStatistics ? ( HeaderContent.MessageStatistics + ) : isStoryStatistics ? ( + HeaderContent.StoryStatistics ) : isBoostStatistics ? ( HeaderContent.BoostStatistics ) : isCreatingTopic ? ( @@ -442,6 +447,8 @@ const RightHeader: FC = ({ return

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

; case HeaderContent.MessageStatistics: return

{lang('Stats.MessageTitle')}

; + case HeaderContent.StoryStatistics: + return

{lang('Stats.StoryTitle')}

; case HeaderContent.BoostStatistics: return

{lang('Boosts')}

; case HeaderContent.SharedMedia: @@ -522,6 +529,7 @@ const RightHeader: FC = ({ || contentKey === HeaderContent.StoryList || contentKey === HeaderContent.AddingMembers || contentKey === HeaderContent.MessageStatistics + || contentKey === HeaderContent.StoryStatistics || isManagement ); diff --git a/src/components/right/statistics/MessageStatistics.tsx b/src/components/right/statistics/MessageStatistics.tsx index cd5339903..9fd81d9e0 100644 --- a/src/components/right/statistics/MessageStatistics.tsx +++ b/src/components/right/statistics/MessageStatistics.tsx @@ -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; @@ -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 = ({ +function Statistics({ chatId, isActive, statistics, dcId, messageId, -}) => { +}: OwnProps & StateProps) { 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 { loadMessageStatistics, loadMessagePublicForwards, loadStatisticsAsyncGraph } = getActions(); const forceUpdate = useForceUpdate(); useEffect(() => { @@ -144,34 +151,48 @@ const Statistics: FC = ({ 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 ; } return ( -
+
{!loadedCharts.current.length && }
{GRAPHS.map((graph) => ( -
+
))}
{Boolean(statistics.publicForwards) && ( -
-

{lang('Stats.Message.PublicShares')}

+
+

{lang('Stats.Message.PublicShares')}

- {statistics.publicForwardsData!.map((item: ApiMessagePublicForward) => ( - - ))} + + {(statistics.publicForwardsData as ApiMessagePublicForward[]).map((item) => ( + + ))} +
)}
); -}; +} export default memo(withGlobal( (global, { chatId }): StateProps => { diff --git a/src/components/right/statistics/Statistics.module.scss b/src/components/right/statistics/Statistics.module.scss new file mode 100644 index 000000000..4c497c9f0 --- /dev/null +++ b/src/components/right/statistics/Statistics.module.scss @@ -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); + } +} diff --git a/src/components/right/statistics/Statistics.scss b/src/components/right/statistics/Statistics.scss deleted file mode 100644 index 13fc76ca0..000000000 --- a/src/components/right/statistics/Statistics.scss +++ /dev/null @@ -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); - } -} diff --git a/src/components/right/statistics/Statistics.tsx b/src/components/right/statistics/Statistics.tsx index e60fb713d..dc7a7a8b0 100644 --- a/src/components/right/statistics/Statistics.tsx +++ b/src/components/right/statistics/Statistics.tsx @@ -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; @@ -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; + storiesById?: Record; }; const Statistics: FC = ({ 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 = ({ 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 = ({ } return ( -
+
= ({
{graphs.map((graph) => ( -
+
))}
- {Boolean((statistics as ApiChannelStatistics).recentTopMessages?.length) && ( -
-

{lang('ChannelStats.Recent.Header')}

+ {Boolean((statistics as ApiChannelStatistics).recentPosts?.length) && ( +
+

{lang('ChannelStats.Recent.Header')}

- {(statistics as ApiChannelStatistics).recentTopMessages.map((message) => ( - - ))} + {(statistics as ApiChannelStatistics).recentPosts.map((postStatistic) => { + if ('msgId' in postStatistic) { + const message = messagesById[postStatistic.msgId]; + if (!message || !('content' in message)) return undefined; + + return ( + + ); + } + + if ('storyId' in postStatistic && chat) { + const story = storiesById?.[postStatistic.storyId]; + + return ( + + ); + } + + return undefined; + })}
)}
@@ -211,9 +254,11 @@ export default memo(withGlobal( 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)); diff --git a/src/components/right/statistics/StatisticsPublicForward.tsx b/src/components/right/statistics/StatisticsMessagePublicForward.tsx similarity index 63% rename from src/components/right/statistics/StatisticsPublicForward.tsx rename to src/components/right/statistics/StatisticsMessagePublicForward.tsx index 1457a10e8..78c43b0ed 100644 --- a/src/components/right/statistics/StatisticsPublicForward.tsx +++ b/src/components/right/statistics/StatisticsMessagePublicForward.tsx @@ -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 = ({ data }) => { +const StatisticsMessagePublicForward: FC = ({ 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 ( -
+
-
-
+
+
{data.title}
-
+
{lang('ChannelStats.ViewsCount', data.views, 'i')}
@@ -42,4 +43,4 @@ const StatisticsPublicForward: FC = ({ data }) => { ); }; -export default memo(StatisticsPublicForward); +export default memo(StatisticsMessagePublicForward); diff --git a/src/components/right/statistics/StatisticsOverview.module.scss b/src/components/right/statistics/StatisticsOverview.module.scss new file mode 100644 index 000000000..35bef4f25 --- /dev/null +++ b/src/components/right/statistics/StatisticsOverview.module.scss @@ -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); + } +} + diff --git a/src/components/right/statistics/StatisticsOverview.scss b/src/components/right/statistics/StatisticsOverview.scss deleted file mode 100644 index 7eb85b769..000000000 --- a/src/components/right/statistics/StatisticsOverview.scss +++ /dev/null @@ -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); - } - } -} diff --git a/src/components/right/statistics/StatisticsOverview.tsx b/src/components/right/statistics/StatisticsOverview.tsx index ea4ba01d0..c016f703e 100644 --- a/src/components/right/statistics/StatisticsOverview.tsx +++ b/src/components/right/statistics/StatisticsOverview.tsx @@ -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 = ({ @@ -99,7 +119,7 @@ const StatisticsOverview: FC = ({ const isChangeNegative = Number(change) < 0; return ( - + {isChangeNegative ? `-${formatIntegerCompact(Math.abs(change))}` : `+${formatIntegerCompact(change)}`} {percentage && ( <> @@ -113,26 +133,25 @@ const StatisticsOverview: FC = ({ 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 ( -
-
+
+
{title && ( -
+
{title}
)} {period && ( -
+
{formatFullDate(lang, period.minDate * 1000)} — {formatFullDate(lang, period.maxDate * 1000)}
)}
- +
{schema.map((row) => ( {row.map((cell: OverviewCell) => { @@ -140,39 +159,39 @@ const StatisticsOverview: FC = ({ if (cell.isPlain) { return ( - ); } if (cell.isPercentage) { return ( - ); } return ( - ); })} @@ -183,4 +202,20 @@ const StatisticsOverview: FC = ({ ); }; +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); diff --git a/src/components/right/statistics/StatisticsPublicForward.scss b/src/components/right/statistics/StatisticsPublicForward.module.scss similarity index 61% rename from src/components/right/statistics/StatisticsPublicForward.scss rename to src/components/right/statistics/StatisticsPublicForward.module.scss index 3c9466771..0e346b70e 100644 --- a/src/components/right/statistics/StatisticsPublicForward.scss +++ b/src/components/right/statistics/StatisticsPublicForward.module.scss @@ -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; } diff --git a/src/components/right/statistics/StatisticsRecentMessage.scss b/src/components/right/statistics/StatisticsRecentMessage.scss deleted file mode 100644 index 8b6463026..000000000 --- a/src/components/right/statistics/StatisticsRecentMessage.scss +++ /dev/null @@ -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; - } -} diff --git a/src/components/right/statistics/StatisticsRecentMessage.tsx b/src/components/right/statistics/StatisticsRecentMessage.tsx index 4cc5a374c..07340b852 100644 --- a/src/components/right/statistics/StatisticsRecentMessage.tsx +++ b/src/components/right/statistics/StatisticsRecentMessage.tsx @@ -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 = ({ message }) => { +const StatisticsRecentMessage: FC = ({ postStatistic, message }) => { const lang = useLang(); const { toggleMessageStatistics } = getActions(); @@ -39,27 +43,25 @@ const StatisticsRecentMessage: FC = ({ message }) => { return (
-
-
+
+
{renderSummary(lang, message, mediaBlobUrl || mediaThumbnail, isRoundVideo)}
-
- {lang('ChannelStats.ViewsCount', message.views, 'i')} +
+ {lang('ChannelStats.ViewsCount', postStatistic.viewsCount, 'i')}
-
-
+
+
{formatDateTimeToString(message.date * 1000, lang.code)}
-
- {message.forwards ? lang('ChannelStats.SharesCount', message.forwards) : 'No shares'} -
+
); @@ -71,14 +73,14 @@ function renderSummary(lang: LangFn, message: ApiMessage, blobUrl?: string, isRo } return ( - + - {getMessageVideo(message) && } + {getMessageVideo(message) && } {renderMessageSummary(lang, message, true)} ); diff --git a/src/components/right/statistics/StatisticsRecentPost.module.scss b/src/components/right/statistics/StatisticsRecentPost.module.scss new file mode 100644 index 000000000..7b75c78e6 --- /dev/null +++ b/src/components/right/statistics/StatisticsRecentPost.module.scss @@ -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; +} diff --git a/src/components/right/statistics/StatisticsRecentPostMeta.tsx b/src/components/right/statistics/StatisticsRecentPostMeta.tsx new file mode 100644 index 000000000..0a10b9b68 --- /dev/null +++ b/src/components/right/statistics/StatisticsRecentPostMeta.tsx @@ -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 ( +
+ {postStatistic.reactionsCount > 0 && ( + + + {formatIntegerCompact(postStatistic.reactionsCount)} + + )} + + {postStatistic.forwardsCount > 0 && ( + + + {formatIntegerCompact(postStatistic.forwardsCount)} + + )} + + {!postStatistic.forwardsCount && !postStatistic.reactionsCount + && lang('ChannelStats.SharesCount_ZeroValueHolder')} +
+ ); +} + +export default memo(StatisticsRecentPostMeta); diff --git a/src/components/right/statistics/StatisticsRecentStory.tsx b/src/components/right/statistics/StatisticsRecentStory.tsx new file mode 100644 index 000000000..195c81dc4 --- /dev/null +++ b/src/components/right/statistics/StatisticsRecentStory.tsx @@ -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 ( +
+
+
+ {renderSummary(lang, chat, imgBlobUrl || mediaThumbnail)} +
+
+ {lang('ChannelStats.ViewsCount', postStatistic.viewsCount, 'i')} +
+
+ +
+
+ {isLoaded && Boolean(story.date) && formatDateTimeToString(story.date * 1000, lang.code)} +
+ +
+
+ ); +} + +function renderSummary(lang: LangFn, chat: ApiChat, blobUrl?: string) { + return ( + + {blobUrl ? ( + + + + ) : ( + + )} + + {lang('Story')} + + ); +} + +export default memo(StatisticsRecentStory); diff --git a/src/components/right/statistics/StatisticsStoryPublicForward.tsx b/src/components/right/statistics/StatisticsStoryPublicForward.tsx new file mode 100644 index 000000000..6fd9427c7 --- /dev/null +++ b/src/components/right/statistics/StatisticsStoryPublicForward.tsx @@ -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; + chatsById: Record; +}; + +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 ( +
+ + +
+
+ {user ? getUserFullName(user) : getChatTitle(lang, chat)} +
+ +
+ {data.viewsCount ? lang('ChannelStats.ViewsCount', data.viewsCount, 'i') : lang('NoViews')} +
+
+
+ ); +} + +export default memo(StatisticsMessagePublicForward); diff --git a/src/components/right/statistics/StoryStatistics.async.tsx b/src/components/right/statistics/StoryStatistics.async.tsx new file mode 100644 index 000000000..c2059367a --- /dev/null +++ b/src/components/right/statistics/StoryStatistics.async.tsx @@ -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 = (props) => { + const StoryStatistics = useModuleLoader(Bundles.Extra, 'StoryStatistics'); + + // eslint-disable-next-line react/jsx-props-no-spreading + return StoryStatistics ? : ; +}; + +export default StoryStatisticsAsync; diff --git a/src/components/right/statistics/StoryStatistics.tsx b/src/components/right/statistics/StoryStatistics.tsx new file mode 100644 index 000000000..9f15e0145 --- /dev/null +++ b/src/components/right/statistics/StoryStatistics.tsx @@ -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; +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.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; + usersById: Record; +}; + +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(null); + const [isReady, setIsReady] = useState(false); + const loadedCharts = useRef([]); + + 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)[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 ; + } + + return ( +
+ + + {!loadedCharts.current.length && } + +
+ {GRAPHS.map((graph) => ( +
+ ))} +
+ + {Boolean(statistics.publicForwards) && ( +
+

{lang('Stats.Message.PublicShares')}

+ + + {statistics.publicForwardsData!.map((item) => { + if ('messageId' in item) { + return ( + + ); + } + + return ( + + ); + })} + +
+ )} +
+ ); +} + +export default memo(withGlobal( + (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)); diff --git a/src/components/story/StoryFooter.tsx b/src/components/story/StoryFooter.tsx index 632314a09..2b9a74715 100644 --- a/src/components/story/StoryFooter.tsx +++ b/src/components/story/StoryFooter.tsx @@ -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 }); diff --git a/src/components/story/StoryViewModal.tsx b/src/components/story/StoryViewModal.tsx index 80298e264..19b859c06 100644 --- a/src/components/story/StoryViewModal.tsx +++ b/src/components/story/StoryViewModal.tsx @@ -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; diff --git a/src/components/story/mediaArea/MediaAreaSuggestedReaction.tsx b/src/components/story/mediaArea/MediaAreaSuggestedReaction.tsx index d03a65f4d..8bd31bea7 100644 --- a/src/components/story/mediaArea/MediaAreaSuggestedReaction.tsx +++ b/src/components/story/mediaArea/MediaAreaSuggestedReaction.tsx @@ -44,7 +44,8 @@ const MediaAreaSuggestedReaction = ({ const ref = useRef(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); diff --git a/src/config.ts b/src/config.ts index b22ac609f..378194872 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index fe10b1292..0391518c2 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -1776,8 +1776,8 @@ addActionHandler('loadMessageViews', async (global, actions, payload): Promise { 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); diff --git a/src/global/actions/api/statistics.ts b/src/global/actions/api/statistics.ts index 00fc7905d..c3b1cbf3b 100644 --- a/src/global/actions/api/statistics.ts +++ b/src/global/actions/api/statistics.ts @@ -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 => { const { chatId, isGroup, tabId = getCurrentTabId() } = payload; @@ -28,13 +39,6 @@ addActionHandler('loadStatistics', async (global, actions, payload): Promise ({ ...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 => { + 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 => { @@ -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 => { + 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 => { + 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); +}); diff --git a/src/global/actions/api/stories.ts b/src/global/actions/api/stories.ts index bcb31a0db..b4ac1b2e7 100644 --- a/src/global/actions/api/stories.ts +++ b/src/global/actions/api/stories.ts @@ -20,6 +20,7 @@ import { updatePeerPinnedStory, updatePeerStoriesHidden, updatePeerStory, + updatePeerStoryViews, updatePeersWithStories, updateSentStoryReaction, updateStealthMode, @@ -360,7 +361,7 @@ addActionHandler('loadStoryViews', async (global, actions, payload): Promise view.userId); - global = updatePeerStory(global, peerId, storyId, { + global = updatePeerStoryViews(global, peerId, storyId, { recentViewerIds, viewsCount: result.viewsCount, reactionsCount: result.reactionsCount, diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 620f47c10..8e995a55c 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -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); }); diff --git a/src/global/reducers/statistics.ts b/src/global/reducers/statistics.ts index 3505fbdb2..284722786 100644 --- a/src/global/reducers/statistics.ts +++ b/src/global/reducers/statistics.ts @@ -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( } export function updateMessageStatistics( - global: T, statistics: ApiMessageStatistics, + global: T, statistics: ApiPostStatistics, ...[tabId = getCurrentTabId()]: TabArgs ): T { return updateTabState(global, { statistics: { ...selectTabState(global, tabId).statistics, currentMessage: statistics, + currentStory: undefined, + }, + }, tabId); +} + +export function updateStoryStatistics( + global: T, statistics: ApiPostStatistics, + ...[tabId = getCurrentTabId()]: TabArgs +): T { + return updateTabState(global, { + statistics: { + ...selectTabState(global, tabId).statistics, + currentStory: statistics, + currentMessage: undefined, }, }, tabId); } diff --git a/src/global/reducers/stories.ts b/src/global/reducers/stories.ts index 07009046e..9355f5a3d 100644 --- a/src/global/reducers/stories.ts +++ b/src/global/reducers/stories.ts @@ -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( 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( }; } +export function updatePeerStoryViews( + global: T, + peerId: string, + storyId: number, + viewsUpdate: Partial, +): 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( global: T, peerId: string, diff --git a/src/global/selectors/ui.ts b/src/global/selectors/ui.ts index 595d5bc12..4c3d77ed2 100644 --- a/src/global/selectors/ui.ts +++ b/src/global/selectors/ui.ts @@ -37,6 +37,8 @@ export function selectRightColumnContentKey( RightColumnContent.Management ) : tabState.isStatisticsShown && tabState.statistics.currentMessageId ? ( RightColumnContent.MessageStatistics + ) : tabState.isStatisticsShown && tabState.statistics.currentStoryId ? ( + RightColumnContent.StoryStatistics ) : selectIsStatisticsShown(global, tabId) ? ( RightColumnContent.Statistics ) : tabState.boostStatistics ? ( diff --git a/src/global/types.ts b/src/global/types.ts index 0ef53392a..7d5f9b82e 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -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; - 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; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 52d3483d6..640ebeddb 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -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 = 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 = ExportedChatlistInvite; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index bd2391d50..0dbc70371 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -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", diff --git a/src/lib/lovely-chart/Projection.js b/src/lib/lovely-chart/Projection.js index 6ed8e0c09..de44e9218 100644 --- a/src/lib/lovely-chart/Projection.js +++ b/src/lib/lovely-chart/Projection.js @@ -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; diff --git a/src/types/index.ts b/src/types/index.ts index 45b6c16bb..8e382faf0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -277,6 +277,7 @@ export enum RightColumnContent { Statistics, BoostStatistics, MessageStatistics, + StoryStatistics, StickerSearch, GifSearch, PollResults,
- + + {`${cell.isApproximate ? '≈' : ''}${formatInteger(field)}`} -

{lang(cell.title)}

+

{lang(cell.title)}

+ {cell.withAbsoluteValue && ( - + {`${cell.isApproximate ? '≈' : ''}${formatInteger(field.part)}`} )} - + {field.percentage}% -

{lang(cell.title)}

+

{lang(cell.title)}

- + + {formatIntegerCompact(field.current)} {' '} {renderOverviewItemValue(field)} -

{lang(cell.title)}

+

{lang(cell.title)}