diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 453fbd10f4..6f043189d2 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -586,9 +586,74 @@ export type Events = { | 'SendViaChatDialog' | 'ConvoSettings' } + + // Group chat adoption 'groupchat:create': { logContext: 'NewChatDialog' } + 'groupchat:landingPage:view': { + hasSession: boolean + } + 'groupchat:inviteLink:redeem': {} + + // Group chat user interactions + 'groupchat:message:send': { + convoId: string + isOwner: boolean + } + 'groupchat:mute': { + convoId: string + } + 'groupchat:unmute': { + convoId: string + } + 'groupchat:leave': { + convoId: string + isOwner: boolean + } + 'groupchat:settings:view': { + convoId: string + isOwner: boolean + } + 'groupchat:inviteLink:shareButton:press': { + convoId: string + method: 'post' | 'copy' | 'native' + } + + // Group chat owner actions + 'groupchat:owner:editName': { + convoId: string + } + 'groupchat:owner:lock': { + convoId: string + } + 'groupchat:owner:unlock': { + convoId: string + } + 'groupchat:owner:kickMember': { + convoId: string + } + 'groupchat:owner:inviteMember': { + convoId: string + } + 'groupchat:owner:joinRequest:accept': { + convoId: string + } + 'groupchat:owner:joinRequest:reject': { + convoId: string + } + 'groupchat:owner:inviteLink:create': { + convoId: string + } + 'groupchat:owner:inviteLink:disable': { + convoId: string + } + + // Group chat problems + 'groupchat:join:memberLimitReached': { + convoId: string + } + 'starterPack:addUser': { starterPack?: string } diff --git a/src/components/intents/GroupChatJoinDialog.tsx b/src/components/intents/GroupChatJoinDialog.tsx index 8efe67cbb9..f9d6b11047 100644 --- a/src/components/intents/GroupChatJoinDialog.tsx +++ b/src/components/intents/GroupChatJoinDialog.tsx @@ -1,3 +1,4 @@ +import {useEffect} from 'react' import {View} from 'react-native' import { ChatBskyGroupDefs, @@ -10,6 +11,7 @@ import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {useCallOnce} from '#/lib/once' import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' import {isNetworkError} from '#/lib/strings/errors' @@ -45,6 +47,7 @@ import {InlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import {ProfileBadges} from '../ProfileBadges' export function GroupChatJoinDialog() { @@ -83,6 +86,14 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { const moderationOpts = useModerationOpts() const navigation = useNavigation() const queryClient = useQueryClient() + const ax = useAnalytics() + + const logViewOnce = useCallOnce() + useEffect(() => { + logViewOnce(() => { + ax.metric('groupchat:landingPage:view', {hasSession}) + }) + }, [ax, hasSession, logViewOnce]) const {data, error, isLoading} = useJoinLinkPreviewsQuery({ codes: code ? [code] : undefined, @@ -96,6 +107,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { if (code) void invalidateJoinLinkPreviewsForCode(queryClient, code) switch (data.status) { case 'pending': + ax.metric('groupchat:inviteLink:redeem', {}) control.close(() => { Toast.show( l`Access requested! The group owner will review your request.`, @@ -104,6 +116,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { break case 'joined': { if (data.convo && data.convo.id) { + ax.metric('groupchat:inviteLink:redeem', {}) control.close(() => { Toast.show(l`Successfully joined the group chat!`) navigation.navigate('MessagesConversation', { @@ -140,6 +153,15 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { error instanceof ChatBskyGroupRequestJoin.MemberLimitReachedError ) { errorMessage = l`The member limit has been reached.` + const preview = data?.joinLinkPreviews[0] + if ( + ChatBskyGroupDefs.isJoinLinkPreviewView(preview) && + preview.convo?.id + ) { + ax.metric('groupchat:join:memberLimitReached', { + convoId: preview.convo.id, + }) + } } else if (error instanceof ChatBskyGroupRequestJoin.UserKickedError) { errorMessage = l`You have been previously removed from this group and can’t join it using this link.` } diff --git a/src/screens/Messages/ConversationSettings/AddMembersLink.tsx b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx index c52751fb06..940d5c6bba 100644 --- a/src/screens/Messages/ConversationSettings/AddMembersLink.tsx +++ b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx @@ -15,6 +15,7 @@ import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/ import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' export function AddMembersLink({ convo, @@ -25,6 +26,7 @@ export function AddMembersLink({ }) { const t = useTheme() const {t: l} = useLingui() + const ax = useAnalytics() const addMembersControl = Dialog.useDialogControl() @@ -33,6 +35,7 @@ export function AddMembersLink({ convoId, { onSuccess: data => { + ax.metric('groupchat:owner:inviteMember', {convoId}) addMembersControl.close(() => { const members = data.addedMembers ?? [] diff --git a/src/screens/Messages/ConversationSettings/MemberMenu.tsx b/src/screens/Messages/ConversationSettings/MemberMenu.tsx index 60b5b5e2eb..f128951f8f 100644 --- a/src/screens/Messages/ConversationSettings/MemberMenu.tsx +++ b/src/screens/Messages/ConversationSettings/MemberMenu.tsx @@ -68,6 +68,9 @@ export function MemberMenu({ }) const convoId = convo.view.id const {mutate: removeMembers} = useRemoveFromGroupChat(convoId, { + onSuccess: () => { + ax.metric('groupchat:owner:kickMember', {convoId}) + }, onError: e => { logger.error('Failed to remove group chat member', {message: e}) Toast.show(l`Failed to remove group chat member`, {type: 'error'}) diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx index b83d10bd2d..9d3fdbde05 100644 --- a/src/screens/Messages/ConversationSettings/index.tsx +++ b/src/screens/Messages/ConversationSettings/index.tsx @@ -1,4 +1,4 @@ -import {useState} from 'react' +import {useEffect, useState} from 'react' import {Pressable, View} from 'react-native' import { ChatBskyActorDefs, @@ -13,6 +13,7 @@ import {HITSLOP_10} from '#/lib/constants' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted' +import {useCallOnce} from '#/lib/once' import { type CommonNavigatorParams, type NativeStackScreenProps, @@ -56,6 +57,7 @@ import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' import * as bsky from '#/types/bsky' import {InviteLinkDialog} from '../components/InviteLinkDialog' @@ -337,9 +339,12 @@ function SettingsHeader({ }) { const t = useTheme() const {i18n, t: l} = useLingui() + const ax = useAnalytics() const navigation = useNavigation() + const convoId = convo.view.id + const groupName = convo.details.name const [newGroupName, setNewGroupName] = useState(groupName) @@ -350,9 +355,17 @@ function SettingsHeader({ const reportSubjectDid = convo.primaryMember?.did + const logViewOnce = useCallOnce() + useEffect(() => { + logViewOnce(() => { + ax.metric('groupchat:settings:view', {convoId, isOwner}) + }) + }, [ax, convoId, isOwner, logViewOnce]) + const {mutate: editGroupName, isPending: isEditingName} = - useEditGroupChatName(convo.view.id, { + useEditGroupChatName(convoId, { onSuccess: () => { + ax.metric('groupchat:owner:editName', {convoId}) Toast.show(l({message: 'Group chat name updated', context: 'toast'})) }, onError: e => { @@ -362,11 +375,13 @@ function SettingsHeader({ }, }) - const {mutate: muteConvo, isPending: isMuting} = useMuteConvo(convo.view.id, { + const {mutate: muteConvo, isPending: isMuting} = useMuteConvo(convoId, { onSuccess: data => { if (data.convo.muted) { + ax.metric('groupchat:mute', {convoId}) Toast.show(l({message: 'Group chat muted', context: 'toast'})) } else { + ax.metric('groupchat:unmute', {convoId}) Toast.show(l({message: 'Group chat unmuted', context: 'toast'})) } }, @@ -376,33 +391,32 @@ function SettingsHeader({ }, }) - const {mutate: leaveConvo, isPending: isLeaving} = useLeaveConvo( - convo.view.id, - { - onSuccess: () => { - navigation.replace('Messages', {animation: 'pop'}) - }, - onError: e => { - logger.error('Failed to leave group chat', {message: e}) - Toast.show( - l({message: 'Failed to leave group chat', context: 'toast'}), - {type: 'error'}, - ) - }, + const {mutate: leaveConvo, isPending: isLeaving} = useLeaveConvo(convoId, { + onSuccess: () => { + ax.metric('groupchat:leave', {convoId, isOwner}) + navigation.replace('Messages', {animation: 'pop'}) }, - ) + onError: e => { + logger.error('Failed to leave group chat', {message: e}) + Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), { + type: 'error', + }) + }, + }) const { mutate: lockConvo, mutateAsync: lockConvoAsync, isPending: isLocking, - } = useLockConvo(convo.view.id, { + } = useLockConvo(convoId, { onSuccess: (data, {silent}) => { if (!ChatBskyConvoDefs.isGroupConvo(data.convo.kind)) return if (silent) return if (data.convo.kind.lockStatus === 'locked') { + ax.metric('groupchat:owner:lock', {convoId}) Toast.show(l({message: 'Group chat locked', context: 'toast'})) } else { + ax.metric('groupchat:owner:unlock', {convoId}) Toast.show(l({message: 'Group chat unlocked', context: 'toast'})) } }, diff --git a/src/screens/Messages/JoinRequest.tsx b/src/screens/Messages/JoinRequest.tsx index 5c68eb7b3b..b85a056cb6 100644 --- a/src/screens/Messages/JoinRequest.tsx +++ b/src/screens/Messages/JoinRequest.tsx @@ -1,3 +1,4 @@ +import {useEffect} from 'react' import {View} from 'react-native' import {ImageBackground} from 'expo-image' import {ChatBskyGroupDefs, moderateProfile} from '@atproto/api' @@ -18,6 +19,7 @@ import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/ import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' const desktopDarkBg = require('../../../assets/images/chat-desktop-bg-dark.webp') const desktopDimBg = require('../../../assets/images/chat-desktop-bg-dim.webp') @@ -44,10 +46,15 @@ type Props = { export function JoinRequest({setScreenState}: Props) { const t = useTheme() const {t: l} = useLingui() + const ax = useAnalytics() const {gtMobile, gtTablet} = useBreakpoints() const moderationOpts = useModerationOpts() + useEffect(() => { + ax.metric('groupchat:landingPage:view', {hasSession: false}) + }, [ax]) + // Get code from context (logged-out only) const contextJoinRequest = useActiveGroupChatJoinRequest() const code = contextJoinRequest?.code diff --git a/src/screens/Messages/JoinRequests.tsx b/src/screens/Messages/JoinRequests.tsx index a5bbdd10ce..ecb40ef9c3 100644 --- a/src/screens/Messages/JoinRequests.tsx +++ b/src/screens/Messages/JoinRequests.tsx @@ -42,6 +42,7 @@ import {Loader} from '#/components/Loader' import * as ProfileCard from '#/components/ProfileCard' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import type * as bsky from '#/types/bsky' import {InviteLinkDialog} from './components/InviteLinkDialog' @@ -129,6 +130,7 @@ function JoinRequestsList({ }) { const t = useTheme() const {t: l} = useLingui() + const ax = useAnalytics() const moderationOpts = useModerationOpts() const bottomBarOffset = useBottomBarOffset() const {currentAccount} = useSession() @@ -136,10 +138,12 @@ function JoinRequestsList({ const queryClient = useQueryClient() const inviteLinkControl = Dialog.useDialogControl() + const convoId = convo.view.id + const getRemainingRequestCount = () => { const data = queryClient.getQueryData< InfiniteData - >(createListJoinRequestsQueryKey({convoId: convo.view.id})) + >(createListJoinRequestsQueryKey({convoId})) return data?.pages.reduce((sum, page) => sum + page.requests.length, 0) ?? 0 } @@ -172,12 +176,13 @@ function JoinRequestsList({ ) ?? 0 const {mutate: approveJoinRequest, isPending: isApprovePending} = - useJoinRequestMutation('approve', convo.view.id, { + useJoinRequestMutation('approve', convoId, { onSuccess: () => { + ax.metric('groupchat:owner:joinRequest:accept', {convoId}) Toast.show(l`Request approved.`) if (getRemainingRequestCount() < 1) { navigation.replace('MessagesConversationSettings', { - conversation: convo.view.id, + conversation: convoId, }) } }, @@ -204,12 +209,13 @@ function JoinRequestsList({ }) const {mutate: rejectJoinRequest, isPending: isRejectPending} = - useJoinRequestMutation('reject', convo.view.id, { + useJoinRequestMutation('reject', convoId, { onSuccess: () => { + ax.metric('groupchat:owner:joinRequest:reject', {convoId}) Toast.show(l`Request ignored.`) if (getRemainingRequestCount() < 1) { navigation.replace('MessagesConversationSettings', { - conversation: convo.view.id, + conversation: convoId, }) } }, diff --git a/src/screens/Messages/components/InviteLinkDialog.tsx b/src/screens/Messages/components/InviteLinkDialog.tsx index 580f2f226e..fcb6f3b80b 100644 --- a/src/screens/Messages/components/InviteLinkDialog.tsx +++ b/src/screens/Messages/components/InviteLinkDialog.tsx @@ -35,6 +35,7 @@ import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/Ed import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' import {CopyTextButton} from './CopyTextButton' import {EditTextButton} from './EditTextButton' @@ -63,6 +64,9 @@ export function InviteLinkDialog({ }) { const t = useTheme() const {t: l, i18n} = useLingui() + const ax = useAnalytics() + + const convoId = convo.view.id const ownerName = createSanitizedDisplayName( owner, @@ -92,9 +96,10 @@ export function InviteLinkDialog({ const {openComposer} = useOpenComposer() const {mutate: createJoinLink, isPending: isCreating} = useCreateJoinLink( - convo.view.id, + convoId, { onSuccess: () => { + ax.metric('groupchat:owner:inviteLink:create', {convoId}) setStep(Step.MANAGE) }, onError: () => { @@ -105,7 +110,7 @@ export function InviteLinkDialog({ }, ) const {mutate: editJoinLink, isPending: isEditing} = useEditJoinLink( - convo.view.id, + convoId, { onSuccess: () => { setStep(Step.MANAGE) @@ -118,8 +123,11 @@ export function InviteLinkDialog({ }, ) const {mutate: disableJoinLink, isPending: isDisabling} = useDisableJoinLink( - convo.view.id, + convoId, { + onSuccess: () => { + ax.metric('groupchat:owner:inviteLink:disable', {convoId}) + }, onError: () => { Toast.show(l`Failed to disable invite link`, { type: 'error', @@ -128,7 +136,7 @@ export function InviteLinkDialog({ }, ) const {mutate: enableJoinLink, isPending: isEnabling} = useEnableJoinLink( - convo.view.id, + convoId, { onError: () => { Toast.show(l`Failed to enable invite link`, { @@ -325,7 +333,13 @@ export function InviteLinkDialog({ + value={joinLinkURI} + onPress={() => { + ax.metric('groupchat:inviteLink:shareButton:press', { + convoId, + method: 'copy', + }) + }}> { + ax.metric('groupchat:inviteLink:shareButton:press', { + convoId, + method: 'post', + }) control.close(() => { openComposer({ text: joinLinkURI, @@ -401,6 +419,10 @@ export function InviteLinkDialog({ color="primary_subtle" style={[a.flex_1, a.rounded_full]} onPress={() => { + ax.metric('groupchat:inviteLink:shareButton:press', { + convoId, + method: 'native', + }) void shareUrl(joinLinkURI) }}> Share diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 8c3e2981aa..7017e5ac76 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -135,7 +135,7 @@ export function MessagesList({ const ax = useAnalytics() const convoState = useConvoActive() const agent = useAgent() - const {hasSession} = useSession() + const {hasSession, currentAccount} = useSession() const getPost = useGetPost() const getJoinLinkPreview = useGetJoinLinkPreview() const {embed: messageEmbed, setEmbed} = useMessageEmbed() @@ -471,6 +471,13 @@ export function MessagesList({ }, embedView, ) + + if (convoState.convo.kind === 'group') { + ax.metric('groupchat:message:send', { + convoId: convoState.convo.view.id, + isOwner: convoState.convo.primaryMember?.did === currentAccount?.did, + }) + } }, [ agent, @@ -481,6 +488,8 @@ export function MessagesList({ hasSession, hasScrolled, setHasScrolled, + ax, + currentAccount?.did, ], ) diff --git a/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx b/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx index b1bb4ad976..80b94a92d8 100644 --- a/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx +++ b/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx @@ -16,6 +16,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import {InviteLinkDialog} from './InviteLinkDialog' export function MessagesListGroupInfoPanel({ @@ -25,6 +26,7 @@ export function MessagesListGroupInfoPanel({ }) { const t = useTheme() const {t: l} = useLingui() + const ax = useAnalytics() const moderationOpts = useModerationOpts() const convoId = convo.view.id @@ -35,6 +37,7 @@ export function MessagesListGroupInfoPanel({ const {mutate: addGroupMembers} = useAddGroupMembers(convoId, { onSuccess: () => { + ax.metric('groupchat:owner:inviteMember', {convoId}) addMembersControl.close() }, onError: e => { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index e1d68acea6..cce7c9905c 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -1164,6 +1164,7 @@ export const ComposePost = ({ loadedDraftCreatedAt, emptyPostsPromptControl, getFilteredThread, + linkQueries, ]) const handleConfirmSkipEmpty = () => {