diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index a8f8efdbbc..b27653a7d4 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt @@ -37,7 +37,9 @@ import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.SinglePaneSceneStrategy +import com.flipcash.analytics.GroupInviteSource import com.flipcash.analytics.events.DeeplinkEvents +import com.flipcash.analytics.events.GroupEvents import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.analytics.withoutQueryOrFragment import com.flipcash.app.android.BuildConfig @@ -47,6 +49,7 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.navigation.DeeplinkAction +import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.navigation.NavBarButton import com.flipcash.app.core.ui.NavigationBar import com.flipcash.app.core.verification.email.LocalEmailCodeChannel @@ -120,6 +123,12 @@ internal fun App( val userManager = LocalUserManager.current!! DeepLinkListener { analytics.track(DeeplinkEvents.open(it.data.withoutQueryOrFragment())) + // Here rather than where the link is routed: a cold start re-derives its routes on every + // auth re-emission, and this listener hears each link once. The scanner and the in-chat + // invite card report their own sources. + if (router.classify(it) is DeeplinkType.GroupChatInvite) { + analytics.track(GroupEvents.inviteFollowed(GroupInviteSource.LINK)) + } deepLink = it } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 0f8ce18b8e..d01b698ac7 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -124,7 +124,7 @@ fun appEntryProvider( annotatedEntry { key -> ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } - annotatedEntry { key -> MuteChatScreen(key.chatId) } + annotatedEntry { key -> MuteChatScreen(key.chatId, key.chatType) } // Tokens annotatedEntry(testTag = "token_info_screen") { key -> diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 826bb72b0a..252e04a1a6 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -8,6 +8,7 @@ import com.flipcash.app.core.chat.ReportSubject import com.flipcash.app.core.reporting.ReportStep import com.flipcash.app.core.chat.NewGroupStep import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType import com.flipcash.app.core.deposit.DepositResult import com.flipcash.app.core.deposit.DepositStep import com.flipcash.app.core.onboarding.OnboardingStep @@ -416,7 +417,7 @@ sealed interface AppRoute : NavKey, Parcelable { */ @Serializable @Parcelize - data class MuteChat(val chatId: ChatId) : + data class MuteChat(val chatId: ChatId, val chatType: ChatType) : Messaging, com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 99764c26ee..621c64f6af 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -267,6 +267,7 @@ private fun FlowGroupInviteSheet() { GroupInviteSheet( inviteUrl = state.groupInviteUrl, group = state.subject as? ChatSubject.Group, + onShare = { viewModel.dispatchEvent(ChatViewModel.Event.InviteLinkShared) }, onCopy = { viewModel.dispatchEvent(ChatViewModel.Event.CopyInviteLink) }, onDismiss = dismissSheet, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt index 25240839e1..e26c2bd98d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt @@ -8,15 +8,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.messenger.internal.MuteChatViewModel import com.flipcash.app.messenger.internal.screens.MuteChatSheet import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.chat.ui.rememberIsMuted import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher /** * The mute sheet for [chatId], behind `AppRoute.Messaging.MuteChat`, wherever it was opened from: - * a chat profile, a group profile, or a swipe on the chat list. + * a chat profile, a group profile, or a swipe on the chat list. [chatType] is for the mute events + * only; the requests themselves are keyed by id. */ @Composable -fun MuteChatScreen(chatId: ChatId) { +fun MuteChatScreen(chatId: ChatId, chatType: ChatType) { val viewModel = hiltViewModel() val viewerStates = remember(viewModel, chatId) { viewModel.observeViewerState(chatId) } val initial = remember(viewModel, chatId) { viewModel.currentViewerState(chatId) } @@ -32,11 +34,11 @@ fun MuteChatScreen(chatId: ChatId) { // Dismissed on the tap rather than on the result: the request is fire-and-forget from here, // and a failure is reported by the error bar, which draws over whatever is on screen by then. onMute = { - viewModel.mute(chatId, it) + viewModel.mute(chatId, chatType, it) dismissSheet() }, onUnmute = { - viewModel.unmute(chatId) + viewModel.unmute(chatId, chatType) dismissSheet() }, onDismiss = dismissSheet, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 0ad2a6126b..22e41d114b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -14,12 +14,19 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.flatMap import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.GroupAccess as AnalyticsGroupAccess +import com.flipcash.analytics.GroupGateFunding +import com.flipcash.analytics.GroupInviteMethod +import com.flipcash.analytics.GroupInviteSource +import com.flipcash.analytics.GroupInviteSheetSource import com.flipcash.analytics.State as AnalyticsState import com.flipcash.analytics.events.AddMoneyEvents import com.flipcash.analytics.events.ChatEvents +import com.flipcash.analytics.events.GroupEvents import com.flipcash.analytics.events.TransferEvents import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.analytics.analytics +import com.flipcash.app.analytics.gateMint import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier @@ -134,6 +141,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive @@ -474,6 +482,21 @@ internal class ChatViewModel @Inject constructor( /** The invite sheet's "Copy Invite Link" row. */ data object CopyInviteLink : Event + /** The invite sheet was presented, from the transcript's CTA or the group profile. */ + data class InviteSheetOpened(val source: GroupInviteSheetSource) : Event + + /** The invite sheet's "Send Invite Link" row, which hands the link to the share sheet. */ + data object InviteLinkShared : Event + + /** The gate's buy or add-cash button. The navigation is the screen's; this is the record. */ + data class GateFundingTapped(val method: GroupGateFunding) : Event + + /** The group's profile was pushed from the transcript. */ + data object GroupInfoOpened : Event + + /** A group invite card in the transcript was tapped through to another group. */ + data object InviteCardFollowed : Event + /** The group profile's "Leave Chat" row, which puts the confirmation up. */ data object LeaveChat : Event @@ -991,6 +1014,7 @@ internal class ChatViewModel @Inject constructor( initLinkCardFreshness() initClaimReplies() initDraftHandlers() + initGroupAnalytics() viewModelScope.launch { // Yield to let the first frame render before setting up remaining collectors @@ -1562,6 +1586,62 @@ internal class ChatViewModel @Inject constructor( .launchIn(viewModelScope) } + /** + * The group events that are not an RPC's answer: the gate appearing, and the taps on the + * invite sheet, an invite card, the gate's funding button and the group profile. The screens report the taps + * as events so the member count and the gate mint come from the state this holds. + */ + private fun initGroupAnalytics() { + // Once per view model, which is once per visit: the chat flow scopes it to the Chat route. + // Keyed on the gate being drawn — outside the group with the access decided — and not on + // every re-decision after, so a balance moving under an open chat does not send it again. + stateFlow + .mapNotNull { state -> + val group = state.subject as? ChatSubject.Group ?: return@mapNotNull null + if (!state.isOutsideGroup) return@mapNotNull null + val access = when (state.groupAccess) { + GroupAccess.Eligible -> AnalyticsGroupAccess.ELIGIBLE + is GroupAccess.Blocked -> AnalyticsGroupAccess.BLOCKED + GroupAccess.Membered, null -> return@mapNotNull null + } + GroupEvents.gateShown(access, group.rules.gateMint, group.memberCount.toInt()) + } + .take(1) + .onEach { analytics.track(it) } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + val group = stateFlow.value.subject as? ChatSubject.Group ?: return@onEach + analytics.track(GroupEvents.inviteSheetOpened(event.source, group.memberCount.toInt())) + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { analytics.track(GroupEvents.inviteShared(GroupInviteMethod.SHARE)) } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + val group = stateFlow.value.subject as? ChatSubject.Group + analytics.track(GroupEvents.gateFundingTapped(event.method, group?.rules.gateMint)) + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { analytics.track(GroupEvents.inviteFollowed(GroupInviteSource.CHAT_CARD)) } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + val group = stateFlow.value.subject as? ChatSubject.Group ?: return@onEach + analytics.track( + GroupEvents.infoOpened(group.memberCount.toInt(), isMember = group.isMember == true) + ) + } + .launchIn(viewModelScope) + } + private fun initMessageActionHandlers() { // Read off the state rather than carried on the event, so the row that copies the link and // the row that shares it are handing out the one url [State.groupInviteUrl] builds. @@ -1572,6 +1652,7 @@ internal class ChatViewModel @Inject constructor( text = url, label = resources.getString(R.string.title_clipboardLabelGroupInviteLink), ) + analytics.track(GroupEvents.inviteShared(GroupInviteMethod.COPY)) } .launchIn(viewModelScope) @@ -1658,10 +1739,13 @@ internal class ChatViewModel @Inject constructor( dispatchEvent(Event.JoinStateUpdated()) return@onEach } + // Read before the call: the roster the count comes from moves with the join. + val group = stateFlow.value.subject as? ChatSubject.Group // No optimistic flip. `join` caches the chat and the membership flag comes back // through observeMetadata, which is the same path a join from another device takes — // one source for the gate rather than two that can disagree. chatCoordinator.join(chatId) + .also { analytics.trackJoined(group, it) } .onSuccess { // The gate holds its own confirmation rather than waiting to be replaced: // membership comes back through the roster, which can land on the next frame @@ -1720,9 +1804,11 @@ internal class ChatViewModel @Inject constructor( eventFlow.filterIsInstance() .onEach { val chatId = stateFlow.value.chatId ?: return@onEach + val memberCount = (stateFlow.value.subject as? ChatSubject.Group)?.memberCount?.toInt() ?: 0 // `leave` clears the membership locally before the call, so the gate is back in // place by the time the profile closes — the same single source the join reads. chatCoordinator.leave(chatId) + .also { analytics.trackLeft(memberCount, it) } .onSuccess { dispatchEvent(Event.LeftChat) } .onFailure { trace("failed to leave chat - ${it.localizedMessage}") @@ -2275,6 +2361,11 @@ internal class ChatViewModel @Inject constructor( state.copy(viewerState = event.viewerState) } Event.CopyInviteLink, + is Event.InviteSheetOpened, + Event.InviteLinkShared, + is Event.GateFundingTapped, + Event.GroupInfoOpened, + Event.InviteCardFollowed, Event.LeaveChat, Event.LeaveConfirmed, Event.LeftChat -> { state -> state } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MembershipEvents.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MembershipEvents.kt new file mode 100644 index 0000000000..ecb04fc9cd --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MembershipEvents.kt @@ -0,0 +1,32 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.GroupEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.chatResult + +/** + * `Group: Joined` for the answer JoinChat gave. [group] is the one held before the call, since the + * roster the count comes from moves with the join. + */ +internal fun FlipcashAnalytics.trackJoined(group: ChatSubject.Group?, result: Result<*>) { + track( + GroupEvents.joined( + state = if (result.isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE, + error = result.exceptionOrNull()?.chatResult, + memberCount = group?.memberCount?.toInt() ?: 0, + gated = group?.rules?.listener.orEmpty().isNotEmpty(), + ) + ) +} + +/** `Group: Left` for the answer LeaveChat gave, with the member count from before the call. */ +internal fun FlipcashAnalytics.trackLeft(memberCount: Int, result: Result<*>) { + track( + GroupEvents.left( + state = if (result.isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE, + error = result.exceptionOrNull()?.chatResult, + memberCount = memberCount, + ) + ) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt index 584ef9f359..50b2e545e0 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt @@ -2,9 +2,10 @@ package com.flipcash.app.messenger.internal import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatId -import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ViewerState import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.currentChatListFeed @@ -31,6 +32,7 @@ import javax.inject.Inject internal class MuteChatViewModel @Inject constructor( private val chatCoordinator: ChatCoordinator, private val resources: ResourceHelper, + private val analytics: FlipcashAnalytics, ) : ViewModel() { /** @@ -57,18 +59,20 @@ internal class MuteChatViewModel @Inject constructor( * Nothing to report on success: `mute` stores the viewer state it answers with, and every * surface showing the mute reads that store. */ - fun mute(chatId: ChatId, mute: MuteState) = request( + fun mute(chatId: ChatId, chatType: ChatType, option: MuteOption) = request( errorTitle = R.string.error_title_failedToMute, errorDescription = R.string.error_description_failedToMute, ) { - chatCoordinator.mute(chatId, mute) + chatCoordinator.mute(chatId, option.toMuteState()) + .also { analytics.trackMuted(chatType, option, it) } } - fun unmute(chatId: ChatId) = request( + fun unmute(chatId: ChatId, chatType: ChatType) = request( errorTitle = R.string.error_title_failedToUnmute, errorDescription = R.string.error_description_failedToUnmute, ) { chatCoordinator.unmute(chatId) + .also { analytics.trackUnmuted(chatType, it) } } private fun request( @@ -92,3 +96,4 @@ internal class MuteChatViewModel @Inject constructor( } } } + diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteEvents.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteEvents.kt new file mode 100644 index 0000000000..faa3061bfa --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteEvents.kt @@ -0,0 +1,40 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.analytics.MuteDuration +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.ChatEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.analytics.chatResult +import com.flipcash.services.models.chat.ChatType + +/** `Chat Muted` for the answer MuteChat gave to the option picked. */ +internal fun FlipcashAnalytics.trackMuted(chatType: ChatType, option: MuteOption, result: Result<*>) { + track( + ChatEvents.muted( + chatType = chatType.analytics, + duration = option.analytics, + state = if (result.isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE, + error = result.exceptionOrNull()?.chatResult, + ) + ) +} + +/** `Chat Unmuted` for the answer UnmuteChat gave. */ +internal fun FlipcashAnalytics.trackUnmuted(chatType: ChatType, result: Result<*>) { + track( + ChatEvents.unmuted( + chatType = chatType.analytics, + state = if (result.isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE, + error = result.exceptionOrNull()?.chatResult, + ) + ) +} + +private val MuteOption.analytics: MuteDuration + get() = when (this) { + MuteOption.OneHour -> MuteDuration.ONE_HOUR + MuteOption.EightHours -> MuteDuration.EIGHT_HOURS + MuteOption.OneWeek -> MuteDuration.ONE_WEEK + MuteOption.Forever -> MuteDuration.ALWAYS + } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 5ac45c79a0..993a220d37 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -41,6 +41,7 @@ import kotlinx.coroutines.launch internal fun GroupInviteSheet( inviteUrl: String?, group: ChatSubject.Group?, + onShare: () -> Unit, onCopy: () -> Unit, onDismiss: () -> Unit, ) { @@ -82,6 +83,7 @@ internal fun GroupInviteSheet( label = stringResource(R.string.action_sendInviteLink), icon = R.drawable.ic_at, onClick = { + onShare() scope.launch { shareController.present( Shareable.GroupInvite( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index a610dc8d03..8c1b733d24 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -16,6 +16,8 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.compose.collectAsLazyPagingItems +import com.flipcash.analytics.GroupGateFunding +import com.flipcash.analytics.GroupInviteSheetSource import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatStep @@ -80,6 +82,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } is ChatAction.ViewToken -> { + // Only the gate asks to come back after a buy, so that flag is what marks its tap. + if (action.returnAfterBuy) { + viewModel.dispatchEvent(ChatViewModel.Event.GateFundingTapped(GroupGateFunding.BUY_TOKEN)) + } keyboard.hideIfVisible { viewModel.dispatchEvent( ChatViewModel.Event.OpenScreen( @@ -96,6 +102,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } ChatAction.AddCash -> { + // The gate is the only place in the transcript that offers it. + viewModel.dispatchEvent(ChatViewModel.Event.GateFundingTapped(GroupGateFunding.ADD_CASH)) keyboard.hideIfVisible { viewModel.dispatchEvent(ChatViewModel.Event.PresentDepositOptions) } @@ -104,6 +112,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { is ChatAction.OpenGroup -> { // An invite to the chat already on screen has nowhere to go. if (action.chatId != state.chatId) { + viewModel.dispatchEvent(ChatViewModel.Event.InviteCardFollowed) keyboard.hideIfVisible { viewModel.dispatchEvent( ChatViewModel.Event.OpenScreen( @@ -160,6 +169,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { // The sheet, not the share sheet: copying the link is the other way to hand it out, // and going straight to the system share picker would bury it. The sheet reads the // url off the same state the CTA that got here is gated on. + viewModel.dispatchEvent(ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.CHAT)) keyboard.hideIfVisible { navigator.push(ChatStep.InviteToGroup) } } @@ -170,7 +180,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { // is its own — it has no participant to open one on. keyboard.hideIfVisible { when (state.subject) { - is ChatSubject.Group -> navigator.push(ChatStep.GroupProfile) + is ChatSubject.Group -> { + viewModel.dispatchEvent(ChatViewModel.Event.GroupInfoOpened) + navigator.push(ChatStep.GroupProfile) + } else -> state.participant?.let { navigator.push(ChatStep.Profile(it)) } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt index ffadef829a..e2a5e996d4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import com.flipcash.app.messenger.internal.MuteOption import com.flipcash.features.messenger.R -import com.flipcash.services.models.chat.MuteState import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle @@ -49,7 +48,7 @@ import com.getcode.ui.components.ChoiceRow @Composable internal fun MuteChatSheet( isMuted: Boolean, - onMute: (MuteState) -> Unit, + onMute: (MuteOption) -> Unit, onUnmute: () -> Unit, onDismiss: () -> Unit, ) { @@ -113,7 +112,7 @@ internal fun MuteChatSheet( ), onClick = { pinnedOffersUnmute = offersUnmute - onMute(option.toMuteState()) + onMute(option) }, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 8d66667538..af25b80020 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -147,7 +147,7 @@ internal fun ChatProfileScreen( // navigator, as with Report: the sheet is a top-level route shared with the // chat list, so it opens over the chat rather than inside it. ChatProfileAction.Mute -> chatState.chatId?.let { chatId -> - navigator.push(AppRoute.Messaging.MuteChat(chatId)) + navigator.push(AppRoute.Messaging.MuteChat(chatId, chatState.chatType)) } // Not flowNavigator: Report is a top-level route rather than a step of // this flow, and LocalCodeNavigator hands a non-FlowStep route up to its diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index 4aa5c060c8..3d647ba321 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.analytics.GroupInviteSheetSource import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.ReportSubject import com.flipcash.app.core.AppRoute @@ -42,6 +43,7 @@ import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ViewerState import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme @@ -139,13 +141,18 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { when (item.action) { // The same sheet the transcript's own invite CTA opens, pushed on this flow so // it sits over the profile the user asked from. - GroupProfileAction.Invite -> flowNavigator.navigateTo(ChatStep.InviteToGroup) + GroupProfileAction.Invite -> { + viewModel.dispatchEvent( + ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.PROFILE) + ) + flowNavigator.navigateTo(ChatStep.InviteToGroup) + } // Both muting and unmuting go through the picker, which is why this row // navigates either way rather than acting on one of them here. The outer // navigator, as with Report: the sheet is a top-level route shared with the // chat list, so it opens over the chat rather than inside it. GroupProfileAction.Mute -> state.chatId?.let { chatId -> - navigator.push(AppRoute.Messaging.MuteChat(chatId)) + navigator.push(AppRoute.Messaging.MuteChat(chatId, ChatType.GROUP)) } GroupProfileAction.Leave -> viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt index 36bea93cd2..709c8faea1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt @@ -3,11 +3,13 @@ package com.flipcash.app.messenger.internal.screens.profile.edit import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.GroupField +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.moderation.moderationDescription import com.flipcash.features.messenger.R import com.flipcash.libs.coroutines.DispatcherProvider -import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.EditChatError +import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ChatCoordinator import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager @@ -15,13 +17,13 @@ import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds /** * The group-title edit behind node 10187:110373's Name row. @@ -39,6 +41,7 @@ class EditGroupNameViewModel @Inject constructor( dispatchers: DispatcherProvider, private val chatCoordinator: ChatCoordinator, private val resources: ResourceHelper, + private val analytics: FlipcashAnalytics, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -139,6 +142,7 @@ class EditGroupNameViewModel @Inject constructor( chatId = chatId, parameters = titleOnly(state.titleFieldState.text), ) + analytics.trackEdited(GroupField.NAME, result) result.onSuccess { dispatchEvent(Event.UpdateProcessingState(success = true)) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt index baf200b340..9c0137b019 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt @@ -2,6 +2,8 @@ package com.flipcash.app.messenger.internal.screens.profile.edit import android.net.Uri import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.GroupField +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.blob.BlobStorageCoordinator import com.flipcash.app.blob.ImageUploadPreparer import com.flipcash.app.core.data.Loadable @@ -10,11 +12,11 @@ import com.flipcash.features.messenger.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.BlobRejectedException import com.flipcash.services.models.EditChatError +import com.flipcash.services.models.blob.UploadPolicy import com.flipcash.services.models.chat.BlobId import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.RejectionReason -import com.flipcash.services.models.blob.UploadPolicy import com.flipcash.shared.chat.ChatCoordinator import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager @@ -23,6 +25,8 @@ import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull @@ -31,8 +35,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds /** * The group-picture edit behind node 10187:110373's Icon row. @@ -54,6 +56,7 @@ class EditGroupPictureViewModel @Inject constructor( private val imagePreparer: ImageUploadPreparer, private val contentReader: ContentReader, private val resources: ResourceHelper, + private val analytics: FlipcashAnalytics, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -194,7 +197,7 @@ class EditGroupPictureViewModel @Inject constructor( chatCoordinator.editChat( chatId = chatId, parameters = pictureOnly(blobId), - ).onSuccess { + ).also { analytics.trackEdited(GroupField.PICTURE, it) }.onSuccess { dispatchEvent(Event.UpdateProcessingState(success = true)) delay(500.milliseconds) dispatchEvent(Event.OnPictureAccepted) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditedEvent.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditedEvent.kt new file mode 100644 index 0000000000..eec9f2eb2d --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditedEvent.kt @@ -0,0 +1,18 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import com.flipcash.analytics.GroupField +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.GroupEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.chatResult + +/** `Group: Edited` for the answer EditChat gave to a rename or a picture change. */ +internal fun FlipcashAnalytics.trackEdited(field: GroupField, result: Result<*>) { + track( + GroupEvents.edited( + field = field, + state = if (result.isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE, + error = result.exceptionOrNull()?.chatResult, + ) + ) +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt new file mode 100644 index 0000000000..204e527d77 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -0,0 +1,287 @@ +package com.flipcash.app.messenger.internal + +import android.content.ClipboardManager +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.flipcash.analytics.GroupGateFunding +import com.flipcash.analytics.GroupInviteSheetSource +import com.flipcash.analytics.PropertyValue +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.messenger.internal.link.LinkCardClassifier +import com.flipcash.app.messenger.internal.link.LinkCardResolver +import com.flipcash.app.session.CashLinkClaims +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.models.JoinChatError +import com.flipcash.services.models.LeaveChatError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatDraftStore +import com.flipcash.shared.chat.ChatMembership +import com.flipcash.shared.chat.GroupAccess +import com.flipcash.shared.payments.ContactPaymentDelegate +import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.core.bytes +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.base58 +import com.getcode.solana.keys.PublicKey +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The group events the transcript sends. The member count is the roster the screen held before the + * action, the gate is reported once per visit rather than on every re-decision, and a failed join + * or leave carries the RPC's result name rather than the throwable's message. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatGroupAnalyticsTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val chatCoordinator = mockk(relaxed = true) + private val contactCoordinator = mockk(relaxed = true) + private val contactPaymentDelegate = mockk(relaxed = true) + private val tipPaymentDelegate = mockk(relaxed = true) + private val transactionController = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val verifiedFiatCalculator = mockk(relaxed = true) + private val purchaseMethodController = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val analytics = RecordingAnalytics() + private val clipboardManager = mockk(relaxed = true) + private val userFlags = mockk(relaxed = true) + private val linkCardClassifier = mockk(relaxed = true) + private val linkCardResolver = mockk(relaxed = true) + private val cashLinkClaims = mockk(relaxed = true) + private val chatDraftStore = mockk(relaxed = true) + + private val chatId = ChatId(UUID.randomUUID().bytes) + private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa") + private val gatedRules = ChatRules( + listener = listOf(ChatRuleRequirement.MinimumBalance(Fiat(10.0), listOf(mint))), + speaker = emptyList(), + ) + + @Before + fun setUp() { + BottomBarManager.clear() + + every { userManager.accountCluster } returns mockk(relaxed = true) + every { exchange.preferredRate } returns Rate.oneToOne + every { transactionController.limits } returns MutableStateFlow(null) + every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0) + every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null) + // The gate's rule currency is looked up by mint; the name it resolves to is not under test. + coEvery { tokenCoordinator.getTokenMetadata(any()) } returns Result.failure(RuntimeException()) + every { tokenCoordinator.observeTokenCache() } returns flowOf(emptyMap()) + coEvery { + contactCoordinator.resolve(any()) + } returns Result.success(mockk(relaxed = true)) + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + private fun createViewModel(): ChatViewModel = ChatViewModel( + chatCoordinator = chatCoordinator, + contactCoordinator = contactCoordinator, + contactPaymentDelegate = contactPaymentDelegate, + tipPaymentDelegate = tipPaymentDelegate, + transactionController = transactionController, + tokenCoordinator = tokenCoordinator, + exchange = exchange, + verifiedFiatCalculator = verifiedFiatCalculator, + purchaseMethodController = purchaseMethodController, + userManager = userManager, + resources = resources, + analytics = analytics, + clipboardManager = clipboardManager, + userFlags = userFlags, + linkCardClassifier = linkCardClassifier, + linkCardResolver = linkCardResolver, + cashLinkClaims = cashLinkClaims, + chatDraftStore = chatDraftStore, + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), + ) + + private fun membership(isMember: Boolean, rules: ChatRules? = gatedRules, memberCount: Long = 4L) = + ChatMembership( + metadata = mockk(relaxed = true) { + every { this@mockk.chatId } returns this@ChatGroupAnalyticsTest.chatId + every { this@mockk.rules } returns rules + every { rosterSummary.memberCount } returns memberCount + }, + isMember = isMember, + ) + + private fun ChatViewModel.openGroup(isMember: Boolean, rules: ChatRules? = gatedRules) { + dispatchEvent(ChatViewModel.Event.ChatFound(chatId)) + dispatchEvent(ChatViewModel.Event.OnGroupResolved(membership(isMember, rules))) + } + + private fun text(value: String) = PropertyValue.Text(value) + + @Test + fun `the gate is reported once, with the access it first showed`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.openGroup(isMember = false) + vm.dispatchEvent(ChatViewModel.Event.OnGroupAccessResolved(GroupAccess.Blocked(gatedRules.listener.single()))) + vm.dispatchEvent(ChatViewModel.Event.OnGroupAccessResolved(GroupAccess.Eligible)) + vm.dispatchEvent(ChatViewModel.Event.OnGroupAccessResolved(GroupAccess.Blocked(gatedRules.listener.single()))) + advanceUntilIdle() + + val event = analytics.events.single { it.name == "Group: Gate Shown" } + assertEquals(text("Blocked"), event.properties["Access"]) + assertEquals(text(mint.base58()), event.properties["Gate Mint"]) + assertEquals(PropertyValue.Number(4.0), event.properties["Member Count"]) + } + + @Test + fun `a member never sees the gate`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.openGroup(isMember = true) + vm.dispatchEvent(ChatViewModel.Event.OnGroupAccessResolved(GroupAccess.Membered)) + advanceUntilIdle() + + assertTrue(analytics.events.none { it.name == "Group: Gate Shown" }) + } + + @Test + fun `an ungated group reports the gate without a mint`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.openGroup(isMember = false, rules = null) + vm.dispatchEvent(ChatViewModel.Event.OnGroupAccessResolved(GroupAccess.Eligible)) + advanceUntilIdle() + + val event = analytics.events.single { it.name == "Group: Gate Shown" } + assertEquals(text("Eligible"), event.properties["Access"]) + assertNull(event.properties["Gate Mint"]) + } + + @Test + fun `a denied join reports the result name and the count before it`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.join(any()) } returns Result.failure(JoinChatError.RulesNotSatisfied()) + + val vm = createViewModel() + vm.openGroup(isMember = false) + vm.dispatchEvent(ChatViewModel.Event.JoinChat) + advanceUntilIdle() + + val event = analytics.events.single { it.name == "Group: Joined" } + assertEquals(text(AnalyticsState.FAILURE.value), event.properties["State"]) + assertEquals(text("RulesNotSatisfied"), event.properties["Error"]) + assertEquals(PropertyValue.Number(4.0), event.properties["Member Count"]) + assertEquals(PropertyValue.Flag(true), event.properties["Gated"]) + } + + @Test + fun `a join that succeeds carries no error`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.join(any()) } returns Result.success(Unit) + + val vm = createViewModel() + vm.openGroup(isMember = false, rules = null) + vm.dispatchEvent(ChatViewModel.Event.JoinChat) + advanceUntilIdle() + + val event = analytics.events.single { it.name == "Group: Joined" } + assertEquals(text(AnalyticsState.SUCCESS.value), event.properties["State"]) + assertNull(event.properties["Error"]) + assertEquals(PropertyValue.Flag(false), event.properties["Gated"]) + } + + @Test + fun `a leave that fails in transport reports Network`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.leave(any()) } returns + Result.failure(LeaveChatError.Other(RuntimeException("UNAVAILABLE"))) + + val vm = createViewModel() + vm.openGroup(isMember = true) + vm.dispatchEvent(ChatViewModel.Event.LeaveConfirmed) + advanceUntilIdle() + + val event = analytics.events.single { it.name == "Group: Left" } + assertEquals(text(AnalyticsState.FAILURE.value), event.properties["State"]) + assertEquals(text("Network"), event.properties["Error"]) + assertEquals(PropertyValue.Number(4.0), event.properties["Member Count"]) + } + + @Test + fun `the invite sheet, its rows, the gate buttons and the profile are each reported`() = + runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.openGroup(isMember = true) + vm.dispatchEvent(ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.PROFILE)) + vm.dispatchEvent(ChatViewModel.Event.InviteLinkShared) + vm.dispatchEvent(ChatViewModel.Event.CopyInviteLink) + vm.dispatchEvent(ChatViewModel.Event.GateFundingTapped(GroupGateFunding.BUY_TOKEN)) + vm.dispatchEvent(ChatViewModel.Event.GroupInfoOpened) + advanceUntilIdle() + + val opened = analytics.events.single { it.name == "Group: Invite Sheet Opened" } + assertEquals(text("Profile"), opened.properties["Source"]) + assertEquals(PropertyValue.Number(4.0), opened.properties["Member Count"]) + + val methods = analytics.events.filter { it.name == "Group: Invite Shared" } + .map { it.properties["Method"] } + assertEquals(listOf(text("Share"), text("Copy")), methods) + + val funding = analytics.events.single { it.name == "Group: Gate Funding Tapped" } + assertEquals(text("Buy Token"), funding.properties["Method"]) + assertEquals(text(mint.base58()), funding.properties["Gate Mint"]) + + val info = analytics.events.single { it.name == "Group: Info Opened" } + assertEquals(PropertyValue.Number(4.0), info.properties["Member Count"]) + assertEquals(PropertyValue.Flag(true), info.properties["Is Member"]) + } + + @Test + fun `an invite card tapped in the transcript is followed from the chat card`() = + runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.dispatchEvent(ChatViewModel.Event.InviteCardFollowed) + advanceUntilIdle() + + val event = analytics.events.single { it.name == "Group: Invite Followed" } + assertEquals(text("Chat Card"), event.properties["Source"]) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatAnalyticsTest.kt new file mode 100644 index 0000000000..4a79d5ba02 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatAnalyticsTest.kt @@ -0,0 +1,114 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.analytics.ChatType as AnalyticsChatType +import com.flipcash.analytics.MuteDuration +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.ChatEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.services.models.MuteChatError +import com.flipcash.services.models.UnmuteChatError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MuteState +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +/** `Chat Muted` and `Chat Unmuted` are sent when the RPC answers, carrying the route's chat type. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class MuteChatAnalyticsTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val chatCoordinator = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val analytics = RecordingAnalytics() + + private val chatId = ChatId(ByteArray(16) { 3 }.toList()) + + @Before + fun setUp() = BottomBarManager.clear() + + @After + fun tearDown() = BottomBarManager.clear() + + private fun viewModel() = MuteChatViewModel(chatCoordinator, resources, analytics) + + @Test + fun `a mute reports the option picked`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.mute(any(), any()) } returns Result.success(Unit) + + viewModel().mute(chatId, ChatType.TIP_DM, MuteOption.EightHours) + advanceUntilIdle() + + coVerify { chatCoordinator.mute(chatId, match { it is MuteState.Until }) } + assertEquals( + listOf( + ChatEvents.muted( + AnalyticsChatType.TIP, MuteDuration.EIGHT_HOURS, AnalyticsState.SUCCESS, error = null, + ) + ), + analytics.events, + ) + } + + @Test + fun `a refused mute reports the result name`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.mute(any(), any()) } returns Result.failure(MuteChatError.NotFound()) + + viewModel().mute(chatId, ChatType.GROUP, MuteOption.Forever) + advanceUntilIdle() + + assertEquals( + listOf( + ChatEvents.muted( + AnalyticsChatType.GROUP, MuteDuration.ALWAYS, AnalyticsState.FAILURE, error = "NotFound", + ) + ), + analytics.events, + ) + } + + @Test + fun `an unmute that fails in transport reports Network`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.unmute(any()) } returns Result.failure(RuntimeException("io")) + + viewModel().unmute(chatId, ChatType.CONTACT_DM) + advanceUntilIdle() + + assertEquals( + listOf(ChatEvents.unmuted(AnalyticsChatType.CONTACT, AnalyticsState.FAILURE, error = "Network")), + analytics.events, + ) + } + + @Test + fun `an unmute reports Success`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.unmute(any()) } returns Result.success(Unit) + + viewModel().unmute(chatId, ChatType.GROUP) + advanceUntilIdle() + + assertEquals( + listOf(ChatEvents.unmuted(AnalyticsChatType.GROUP, AnalyticsState.SUCCESS, error = null)), + analytics.events, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt index 7e6195baee..39b62b1b10 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.screens.profile.edit import android.net.Uri import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata @@ -64,6 +65,7 @@ class EditGroupConfirmationTest { dispatchers = TestDispatchers(UnconfinedTestDispatcher(scheduler)), chatCoordinator = chatCoordinator, resources = resources, + analytics = RecordingAnalytics(), ).apply { dispatchEvent(EditGroupNameViewModel.Event.Initialize(chatId, title = "Dogs")) } @@ -136,6 +138,7 @@ class EditGroupConfirmationTest { imagePreparer = mockk(relaxed = true), contentReader = mockk(relaxed = true), resources = resources, + analytics = RecordingAnalytics(), ).apply { dispatchEvent( EditGroupPictureViewModel.Event.Initialize(chatId, picture = null, title = "Dogs") diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupEditedAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupEditedAnalyticsTest.kt new file mode 100644 index 0000000000..d152167a6f --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupEditedAnalyticsTest.kt @@ -0,0 +1,147 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.net.Uri +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.flipcash.analytics.GroupField +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.GroupEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.blob.BlobStorageCoordinator +import com.flipcash.app.blob.ImageUploadPreparer +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.models.EditChatError +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ContentReader +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +/** `Group: Edited` is sent when EditChat answers a rename or a picture change, either way. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class GroupEditedAnalyticsTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val chatCoordinator = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val blobStorage = mockk(relaxed = true) + private val imagePreparer = mockk(relaxed = true) + private val contentReader = mockk(relaxed = true) + private val analytics = RecordingAnalytics() + + private val chatId = ChatId(ByteArray(16) { 7 }.toList()) + + @Before + fun setUp() { + BottomBarManager.clear() + every { blobStorage.policy } returns flowOf(null) + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + private fun nameViewModel() = EditGroupNameViewModel( + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), + chatCoordinator = chatCoordinator, + resources = resources, + analytics = analytics, + ) + + private fun pictureViewModel() = EditGroupPictureViewModel( + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), + chatCoordinator = chatCoordinator, + blobStorage = blobStorage, + imagePreparer = imagePreparer, + contentReader = contentReader, + resources = resources, + analytics = analytics, + ) + + @Test + fun `a rename reports Name and Success`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.editChat(any(), any()) } returns Result.success(mockk(relaxed = true)) + + val vm = nameViewModel() + vm.dispatchEvent(EditGroupNameViewModel.Event.Initialize(chatId, "Ballers")) + vm.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Shot Callers") + vm.dispatchEvent(EditGroupNameViewModel.Event.SubmitTitle) + advanceUntilIdle() + + assertEquals( + listOf(GroupEvents.edited(GroupField.NAME, AnalyticsState.SUCCESS, error = null)), + analytics.events, + ) + } + + @Test + fun `a refused rename reports the result name`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.editChat(any(), any()) } returns Result.failure(EditChatError.Denied()) + + val vm = nameViewModel() + vm.dispatchEvent(EditGroupNameViewModel.Event.Initialize(chatId, "Ballers")) + vm.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Shot Callers") + vm.dispatchEvent(EditGroupNameViewModel.Event.SubmitTitle) + advanceUntilIdle() + + assertEquals( + listOf(GroupEvents.edited(GroupField.NAME, AnalyticsState.FAILURE, error = "Denied")), + analytics.events, + ) + } + + @Test + fun `a picture change reports Picture`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { contentReader.readBytes(any()) } returns ByteArray(4) + coEvery { blobStorage.upload(any(), any()) } returns Result.success(BlobId(ByteArray(32) { 5 })) + coEvery { chatCoordinator.editChat(any(), any()) } returns + Result.failure(EditChatError.PictureBlobNotAccepted()) + + val vm = pictureViewModel() + vm.dispatchEvent(EditGroupPictureViewModel.Event.Initialize(chatId, picture = null, title = "Ballers")) + vm.dispatchEvent(EditGroupPictureViewModel.Event.OnImageCached(Uri.parse("file:///pick.jpg"), "image/jpeg")) + vm.dispatchEvent(EditGroupPictureViewModel.Event.SubmitPicture) + advanceUntilIdle() + + assertEquals( + listOf(GroupEvents.edited(GroupField.PICTURE, AnalyticsState.FAILURE, error = "PictureBlobNotAccepted")), + analytics.events, + ) + } + + /** A picture the blob store refused never reached EditChat, so there is no edit to report. */ + @Test + fun `a failed upload reports nothing`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { contentReader.readBytes(any()) } returns ByteArray(4) + coEvery { blobStorage.upload(any(), any()) } returns Result.failure(RuntimeException("upload")) + + val vm = pictureViewModel() + vm.dispatchEvent(EditGroupPictureViewModel.Event.Initialize(chatId, picture = null, title = "Ballers")) + vm.dispatchEvent(EditGroupPictureViewModel.Event.OnImageCached(Uri.parse("file:///pick.jpg"), "image/jpeg")) + vm.dispatchEvent(EditGroupPictureViewModel.Event.SubmitPicture) + advanceUntilIdle() + + assertEquals(emptyList(), analytics.events) + } +} diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index ac376c1ea1..bbb3ec61bf 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -22,7 +22,9 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.analytics.GroupInviteSource import com.flipcash.analytics.events.DeeplinkEvents +import com.flipcash.analytics.events.GroupEvents import com.flipcash.analytics.events.ScanEvents import com.flipcash.app.analytics.analytics import com.flipcash.app.analytics.rememberAnalytics @@ -126,10 +128,13 @@ internal fun Scanner() { ) // Same destination as the tapped /chat/{uuid} link: the chat screen is the // gated preview when the viewer is not yet a member. - is DeeplinkType.GroupChatInvite -> listOf( - AppRoute.Tabs.Chats, - AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(deeplink.chatId)), - ) + is DeeplinkType.GroupChatInvite -> { + analytics.track(GroupEvents.inviteFollowed(GroupInviteSource.QR)) + listOf( + AppRoute.Tabs.Chats, + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(deeplink.chatId)), + ) + } else -> emptyList() } if (routes.isNotEmpty()) { diff --git a/apps/flipcash/features/tipping/build.gradle.kts b/apps/flipcash/features/tipping/build.gradle.kts index 81e8e072ca..f8eff908fa 100644 --- a/apps/flipcash/features/tipping/build.gradle.kts +++ b/apps/flipcash/features/tipping/build.gradle.kts @@ -10,8 +10,10 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) testImplementation(testFixtures(project(":ui:resources"))) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) testImplementation(libs.robolectric) + implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":services:flipcash")) implementation(project(":services:opencode")) implementation(project(":libs:messaging")) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 0762b93c02..5eb5223d08 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -146,7 +146,7 @@ fun ChatsScreen() { }, // The same sheet the chat and group profiles open, so the list offers exactly // the durations they do, and unmuting is its "Never" row rather than a toggle. - onMute = { chat -> navigator.push(AppRoute.Messaging.MuteChat(chat.chatId)) }, + onMute = { chat -> navigator.push(AppRoute.Messaging.MuteChat(chat.chatId, chat.chatType)) }, revealGroup = revealGroup, ) } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt index e65805588c..845140ef58 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt @@ -8,12 +8,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import com.flipcash.analytics.events.GroupEvents +import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute -import com.getcode.ui.components.ChoiceRow import com.flipcash.features.tipping.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.ChoiceRow /** * Node 10127:117987 — the two ways to start a chat, behind the Chats list's "+". @@ -26,6 +28,7 @@ import com.getcode.ui.components.AppBarWithTitle @Composable fun NewChatScreen() { val navigator = LocalCodeNavigator.current + val analytics = rememberAnalytics() Column(modifier = Modifier.fillMaxSize()) { AppBarWithTitle( @@ -43,7 +46,10 @@ fun NewChatScreen() { ChoiceRow( label = stringResource(R.string.action_createPublicGroup), icon = R.drawable.ic_group_3, - onClick = { navigator.push(AppRoute.Messaging.NewGroup) }, + onClick = { + analytics.track(GroupEvents.newOpened()) + navigator.push(AppRoute.Messaging.NewGroup) + }, ) ChoiceRow( label = stringResource(R.string.action_findByUsername), diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt index ea8429bbd1..7dc85e4f69 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt @@ -5,6 +5,7 @@ import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.compose.foundation.text.input.TextFieldState import androidx.lifecycle.viewModelScope +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.blob.BlobStorageCoordinator import com.flipcash.app.blob.ImageUploadPreparer import com.flipcash.app.core.data.Loadable @@ -83,6 +84,7 @@ internal class CreateGroupViewModel @Inject constructor( private val imagePreparer: ImageUploadPreparer, private val contentReader: ContentReader, private val resources: ResourceHelper, + private val analytics: FlipcashAnalytics, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -380,7 +382,7 @@ internal class CreateGroupViewModel @Inject constructor( rules = state.rules, ), idempotencyKey = idempotencyKey, - ).onSuccess { chat -> + ).also { analytics.trackCreated(state.rules, hasPicture = picture != null, it) }.onSuccess { chat -> // The chat exists, so the key has done its job — a later Create is a second group. attempt.clear() // The cached local copy is redundant now that the blob is the chat's picture, but the diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreatedEvent.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreatedEvent.kt new file mode 100644 index 0000000000..a456b965e3 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreatedEvent.kt @@ -0,0 +1,20 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.GroupEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.chatResult +import com.flipcash.app.analytics.gateMint +import com.flipcash.services.models.chat.ChatRules + +/** `Group: Created` for the answer StartChat gave to the create form. */ +internal fun FlipcashAnalytics.trackCreated(rules: ChatRules?, hasPicture: Boolean, result: Result<*>) { + track( + GroupEvents.created( + state = if (result.isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE, + error = result.exceptionOrNull()?.chatResult, + gateMint = rules.gateMint, + hasPicture = hasPicture, + ) + ) +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt index 7d169e44fd..334e086e52 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt @@ -2,6 +2,9 @@ package com.flipcash.app.tipping.internal import android.net.Uri import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.GroupEvents +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.blob.BlobStorageCoordinator import com.flipcash.app.blob.ImageUploadPreparer import com.flipcash.app.core.MainCoroutineRule @@ -27,6 +30,7 @@ import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.opencode.model.financial.VmMetadata import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.base58 import com.getcode.util.resources.ContentReader import com.getcode.util.resources.FakeResourceHelper import com.getcode.view.SuccessHoldDuration @@ -73,6 +77,7 @@ class CreateGroupViewModelErrorTest { private val blobStorage = mockk(relaxed = true) private val imagePreparer = mockk(relaxed = true) private val contentReader = mockk(relaxed = true) + private val analytics = RecordingAnalytics() private lateinit var dispatchers: TestDispatchers @@ -100,6 +105,7 @@ class CreateGroupViewModelErrorTest { imagePreparer = imagePreparer, contentReader = contentReader, resources = resources, + analytics = analytics, ) private fun token(seed: Byte, symbol: String) = MintMetadata( @@ -394,4 +400,55 @@ class CreateGroupViewModelErrorTest { assertEquals(created(), vm.stateFlow.value.created) assertEquals(uri, vm.stateFlow.value.image.dataOrNull) } + + @Test + fun `a created group reports its gate mint and no picture`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.completeDraft() + coEvery { chatCoordinator.create(any(), any()) } returns Result.success(created()) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceTimeBy(SuccessHoldDuration) + advanceUntilIdle() + + assertEquals( + listOf( + GroupEvents.created( + state = AnalyticsState.SUCCESS, + error = null, + gateMint = badBoys.base58(), + hasPicture = false, + ) + ), + analytics.events, + ) + } + + /** The proto's result name, not the message; an any-currency group has no gate mint to send. */ + @Test + fun `a refused any-currency group reports the result name and no gate mint`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.completeDraft() + vm.dispatchEvent(CreateGroupViewModel.Event.OnCurrencySelected(GroupCurrency.All)) + failing(StartChatError.RulesNotSatisfied()) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + + assertEquals( + listOf( + GroupEvents.created( + state = AnalyticsState.FAILURE, + error = "RulesNotSatisfied", + gateMint = null, + hasPicture = false, + ) + ), + analytics.events, + ) + } } diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt index e14ec8189f..e6bb7ecbda 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt @@ -12,7 +12,16 @@ import com.getcode.solana.keys.base58 import com.flipcash.analytics.ChatType as AnalyticsChatType import com.flipcash.analytics.DisplayNameSource as AnalyticsDisplayNameSource import com.flipcash.app.core.DisplayNameSource +import com.flipcash.services.models.EditChatError +import com.flipcash.services.models.JoinChatError +import com.flipcash.services.models.LeaveChatError +import com.flipcash.services.models.MuteChatError +import com.flipcash.services.models.StartChatError +import com.flipcash.services.models.UnmuteChatError +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType +import com.getcode.opencode.model.core.errors.ValidationException /* * Converts Android domain types into what the shared builders in `:libs:analytics-events` @@ -31,6 +40,42 @@ val ChatType.analytics: AnalyticsChatType val Throwable.analytics: String get() = message.orEmpty() +/** + * The `Error` property on the group and mute events: the chat RPC's result name as the proto + * spells it, or `Network` when the call failed in transport. Spelled out rather than taken from + * `javaClass.simpleName`, which R8 renames in release builds. + * + * `Validation` is the request failing proto validation before it was sent, which has no result + * name because the server never answered. + */ +val Throwable.chatResult: String + get() = when (this) { + is StartChatError.Denied, is EditChatError.Denied, is JoinChatError.Denied, + is LeaveChatError.Denied, is MuteChatError.Denied, is UnmuteChatError.Denied -> "Denied" + is EditChatError.NotFound, is JoinChatError.NotFound, is LeaveChatError.NotFound, + is MuteChatError.NotFound, is UnmuteChatError.NotFound -> "NotFound" + is StartChatError.TitleModerated, is EditChatError.TitleModerated -> "TitleModerated" + is StartChatError.PictureBlobNotAccepted, is EditChatError.PictureBlobNotAccepted -> "PictureBlobNotAccepted" + is StartChatError.InvalidRules -> "InvalidRules" + is StartChatError.RulesNotSatisfied, is JoinChatError.RulesNotSatisfied -> "RulesNotSatisfied" + is StartChatError.Unrecognized, is EditChatError.Unrecognized, is JoinChatError.Unrecognized, + is LeaveChatError.Unrecognized, is MuteChatError.Unrecognized, is UnmuteChatError.Unrecognized -> "Unrecognized" + is ValidationException -> "Validation" + else -> "Network" + } + +/** + * The `Gate Mint` property: the mint a group's listener balance rule names, or null when the + * group has no token gate — no rules, or a rule any currency satisfies. + */ +val ChatRules?.gateMint: String? + get() = this?.listener + ?.filterIsInstance() + ?.firstOrNull() + ?.mints + ?.singleOrNull() + ?.base58() + /** The native amount and currency, with the token amount as `USDC` and `Quarks`. */ val LocalFiat.analytics: Amount get() = Amount( diff --git a/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt index 6066743337..b792b4d486 100644 --- a/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt +++ b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt @@ -3,15 +3,23 @@ package com.flipcash.app.analytics import com.flipcash.analytics.Amount import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.navigation.DeeplinkType +import com.flipcash.services.models.JoinChatError +import com.flipcash.services.models.MuteChatError +import com.flipcash.services.models.StartChatError import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Rate import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.base58 import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test +import io.mockk.mockk import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -89,4 +97,38 @@ class ConversionsTest { "https://app.flipcash.com/c/abc?a=b#c".withoutQueryOrFragment(), ) } + + @Test + fun `a chat error reports the proto result name, not its message`() { + assertEquals("RulesNotSatisfied", JoinChatError.RulesNotSatisfied().chatResult) + assertEquals("NotFound", JoinChatError.NotFound().chatResult) + // MuteChatError.Denied's message is "Caller is not a member". + assertEquals("Denied", MuteChatError.Denied().chatResult) + assertEquals("TitleModerated", StartChatError.TitleModerated(mockk(relaxed = true)).chatResult) + } + + @Test + fun `a transport failure reports Network`() { + val cause = RuntimeException("UNAVAILABLE: io exception") + assertEquals("Network", JoinChatError.Other(cause).chatResult) + } + + @Test + fun `a gate mint is the one mint the listener rule names`() { + val rules = ChatRules( + listener = listOf(ChatRuleRequirement.MinimumBalance(Fiat(10.0), listOf(mint))), + speaker = emptyList(), + ) + assertEquals(mint.base58(), rules.gateMint) + } + + @Test + fun `an any-currency or ungated group has no gate mint`() { + val anyCurrency = ChatRules( + listener = listOf(ChatRuleRequirement.MinimumBalance(Fiat(10.0), emptyList())), + speaker = emptyList(), + ) + assertNull(anyCurrency.gateMint) + assertNull((null as ChatRules?).gateMint) + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index d708358e15..3c0d5ebcd0 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -41,6 +41,7 @@ fun ChatSummary.toConversationReference( image = if (isGroup) metadata.picture else other?.userProfile?.profilePicture, title = metadata.title, isGroup = isGroup, + chatType = metadata.type, lastMessagePreview = formatPreview(selfId, tokensByMint, resources), hasMessages = metadata.lastMessage != null, lastActivity = metadata.lastActivity, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt index 8ee3c862d7..513a914406 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -3,6 +3,7 @@ package com.flipcash.shared.chat.ui import com.getcode.opencode.model.core.ID import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.nameOrHandle @@ -30,6 +31,8 @@ data class ConversationReference( val title: String? = null, /** Whether this row is a group. Decides where the name and the avatar's authority come from. */ val isGroup: Boolean = false, + /** The chat's type, for what the row hands on — the mute sheet's analytics need a DM's kind. */ + val chatType: ChatType = if (isGroup) ChatType.GROUP else ChatType.UNKNOWN, val lastMessagePreview: String? = null, /** * Whether the chat has any message at all. diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift index b4cf189e91..b406d34184 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift @@ -19,6 +19,13 @@ public typealias PurchaseMethod = SharedCore.PurchaseMethod public typealias WalletProvider = SharedCore.WalletProvider public typealias CashLinkChoice = SharedCore.CashLinkChoice public typealias OnrampStep = SharedCore.OnrampStep +public typealias MuteDuration = SharedCore.MuteDuration +public typealias GroupField = SharedCore.GroupField +public typealias GroupInviteSheetSource = SharedCore.GroupInviteSheetSource +public typealias GroupInviteMethod = SharedCore.GroupInviteMethod +public typealias GroupInviteSource = SharedCore.GroupInviteSource +public typealias GroupAccess = SharedCore.GroupAccess +public typealias GroupGateFunding = SharedCore.GroupGateFunding public typealias AnalyticsButton = SharedCore.Button public typealias AnalyticsAmount = SharedCore.Amount public typealias PeopleCounter = SharedCore.PeopleCounter @@ -37,6 +44,7 @@ public typealias DisplayNameEvents = SharedCore.DisplayNameEvents public typealias ErrorModalEvents = SharedCore.ErrorModalEvents public typealias AccountEvents = SharedCore.AccountEvents public typealias ButtonEvents = SharedCore.ButtonEvents +public typealias GroupEvents = SharedCore.GroupEvents /// A property value as the iOS sender needs it. Mixpanel is not a dependency of this /// package, so the app maps these to `MixpanelType` itself. diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift index 9aa0c9147c..b1b8294a13 100644 --- a/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift @@ -35,4 +35,10 @@ import Testing @Test func tokenInfoSourcesAreTheFiveTheSpecNames() { #expect(TokenInfoSource.entries.map(\.value) == ["Deeplink", "Wallet", "Discovery", "Chat", "Chat Gate"]) } + + @Test func buildsAGroupEventWithAnOptionalLeftOut() { + let event = GroupEvents.shared.gateShown(access: GroupAccess.blocked, gateMint: nil, memberCount: 3) + #expect(event.name == "Group: Gate Shown") + #expect(event.scalarProperties == ["Access": .text("Blocked"), "Member Count": .number(3)]) + } } diff --git a/libs/analytics-events/EVENTS.md b/libs/analytics-events/EVENTS.md index 94a4dd5259..1423a0d382 100644 --- a/libs/analytics-events/EVENTS.md +++ b/libs/analytics-events/EVENTS.md @@ -35,6 +35,124 @@ app has no value for it. |---|---|---|---| | Chat Type | list | no | [ChatType](#chattype) | +### Chat Muted + +| Property | Type | Optional | Values | +|---|---|---|---| +| Chat Type | list | no | [ChatType](#chattype) | +| Duration | list | no | [MuteDuration](#muteduration) | +| State | list | no | [State](#state) | +| Error | text | yes | | + +### Chat Unmuted + +| Property | Type | Optional | Values | +|---|---|---|---| +| Chat Type | list | no | [ChatType](#chattype) | +| State | list | no | [State](#state) | +| Error | text | yes | | + +## Group + +Group chats. `Member Count` is the member count before the action, from what the screen already holds. `Gate Mint` is left out when the group has no token gate. `Error` is the RPC's result name as the proto spells it, such as RulesNotSatisfied, or Network for a transport failure. + +### Group: New Opened + +The New Group form opened. + +No properties. + +### Group: Created + +StartChat returned, on success or failure. + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| Error | text | yes | | +| Gate Mint | text | yes | | +| Has Picture | yes/no | no | | + +### Group: Edited + +EditChat returned after a rename or a picture change. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Field | list | no | [GroupField](#groupfield) | +| State | list | no | [State](#state) | +| Error | text | yes | | + +### Group: Invite Sheet Opened + +| Property | Type | Optional | Values | +|---|---|---|---| +| Source | list | no | [GroupInviteSheetSource](#groupinvitesheetsource) | +| Member Count | count | no | | + +### Group: Invite Shared + +Share was tapped in the invite sheet, or the invite link was copied. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Method | list | no | [GroupInviteMethod](#groupinvitemethod) | + +### Group: Invite Followed + +A group opened from an invite link, a scanned QR code, or an invite card in a chat. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Source | list | no | [GroupInviteSource](#groupinvitesource) | + +### Group: Gate Shown + +The gate first appeared for a chat. Sent once per visit to the chat. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Access | list | no | [GroupAccess](#groupaccess) | +| Gate Mint | text | yes | | +| Member Count | count | no | | + +### Group: Gate Funding Tapped + +| Property | Type | Optional | Values | +|---|---|---|---| +| Method | list | no | [GroupGateFunding](#groupgatefunding) | +| Gate Mint | text | yes | | + +### Group: Joined + +JoinChat returned. + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| Error | text | yes | | +| Member Count | count | no | | +| Gated | yes/no | no | | + +### Group: Left + +LeaveChat returned after the user confirmed. + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| Error | text | yes | | +| Member Count | count | no | | + +### Group: Info Opened + +The group info (profile) screen opened. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Member Count | count | no | | +| Is Member | yes/no | no | | + ## Transfer ### Grab Bill Start @@ -497,7 +615,73 @@ Where Token Info was opened from. Discovery, Chat and Chat Gate are new in part | Wallet | `WALLET` | | | Discovery | `DISCOVERY` | | | Chat | `CHAT` | | -| Chat Gate | `CHAT_GATE` | | +| Chat Gate | `CHAT_GATE` | iOS doesn't send this source: its gate buy button pushes the buy screen and never opens Token Info. Group: Gate Funding Tapped covers that tap on both apps. | + +### MuteDuration + +The `Duration` property on Chat Muted: the mute option picked. Both apps offer the same four. + +| Value sent | Name in code | Drift | +|---|---|---| +| 1 Hour | `ONE_HOUR` | | +| 8 Hours | `EIGHT_HOURS` | | +| 1 Week | `ONE_WEEK` | | +| Always | `ALWAYS` | | + +### GroupField + +The `Field` property on Group: Edited: what the edit changed. + +| Value sent | Name in code | Drift | +|---|---|---| +| Name | `NAME` | | +| Picture | `PICTURE` | | + +### GroupInviteSheetSource + +The `Source` property on Group: Invite Sheet Opened. Chat is Android's transcript CTA and iOS's head card. + +| Value sent | Name in code | Drift | +|---|---|---| +| Chat | `CHAT` | | +| Profile | `PROFILE` | | + +### GroupInviteMethod + +The `Method` property on Group: Invite Shared: how the invite link left the app. + +| Value sent | Name in code | Drift | +|---|---|---| +| Share | `SHARE` | | +| Copy | `COPY` | | + +### GroupInviteSource + +The `Source` property on Group: Invite Followed: what the group was opened from. + +| Value sent | Name in code | Drift | +|---|---|---| +| Link | `LINK` | | +| QR | `QR` | | +| Chat Card | `CHAT_CARD` | | + +### GroupAccess + +The `Access` property on Group: Gate Shown: whether the viewer can join as they are. + +| Value sent | Name in code | Drift | +|---|---|---| +| Eligible | `ELIGIBLE` | | +| Blocked | `BLOCKED` | | + +### GroupGateFunding + +The `Method` property on Group: Gate Funding Tapped: the funding button tapped on the gate. + +| Value sent | Name in code | Drift | +|---|---|---| +| Buy Token | `BUY_TOKEN` | | +| Add Cash | `ADD_CASH` | | ### PurchaseMethod diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 090384e8fb..591f6309f5 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -119,7 +119,52 @@ DEEPLINK = "Deeplink" WALLET = "Wallet" DISCOVERY = "Discovery" CHAT = "Chat" -CHAT_GATE = "Chat Gate" +CHAT_GATE = { value = "Chat Gate", drift = "iOS doesn't send this source: its gate buy button pushes the buy screen and never opens Token Info. Group: Gate Funding Tapped covers that tap on both apps." } + +[lists.MuteDuration] +description = "The `Duration` property on Chat Muted: the mute option picked. Both apps offer the same four." +[lists.MuteDuration.values] +ONE_HOUR = "1 Hour" +EIGHT_HOURS = "8 Hours" +ONE_WEEK = "1 Week" +ALWAYS = "Always" + +[lists.GroupField] +description = "The `Field` property on Group: Edited: what the edit changed." +[lists.GroupField.values] +NAME = "Name" +PICTURE = "Picture" + +[lists.GroupInviteSheetSource] +description = "The `Source` property on Group: Invite Sheet Opened. Chat is Android's transcript CTA and iOS's head card." +[lists.GroupInviteSheetSource.values] +CHAT = "Chat" +PROFILE = "Profile" + +[lists.GroupInviteMethod] +description = "The `Method` property on Group: Invite Shared: how the invite link left the app." +[lists.GroupInviteMethod.values] +SHARE = "Share" +COPY = "Copy" + +[lists.GroupInviteSource] +description = "The `Source` property on Group: Invite Followed: what the group was opened from." +[lists.GroupInviteSource.values] +LINK = "Link" +QR = "QR" +CHAT_CARD = "Chat Card" + +[lists.GroupAccess] +description = "The `Access` property on Group: Gate Shown: whether the viewer can join as they are." +[lists.GroupAccess.values] +ELIGIBLE = "Eligible" +BLOCKED = "Blocked" + +[lists.GroupGateFunding] +description = "The `Method` property on Group: Gate Funding Tapped: the funding button tapped on the gate." +[lists.GroupGateFunding.values] +BUY_TOKEN = "Buy Token" +ADD_CASH = "Add Cash" [lists.PurchaseMethod] description = "How a token is paid for, sent in the event name `Token Purchase With {method}`." @@ -183,6 +228,7 @@ MESSAGES = "Messages Received" Wallet = "The Phantom deeplink flow." Swap = "Buying and selling a token." Scan = "The gallery scan path, and the tip card." +Group = "Group chats. `Member Count` is the member count before the action, from what the screen already holds. `Gate Mint` is left out when the group has no token gate. `Error` is the RPC's result name as the proto spells it, such as RulesNotSatisfied, or Network for a transport failure." Deeplink = "[url] arrives with its query and fragment already stripped. `type` is a string because the two apps' deeplink vocabularies differ." # --------------------------------------------------------------------------------------- @@ -213,6 +259,123 @@ domain = "Chat" name = "Message Received" properties = [{ name = "Chat Type", type = "ChatType" }] +[[event]] +domain = "Chat" +name = "Chat Muted" +builder = "muted" +properties = [ + { name = "Chat Type", type = "ChatType" }, + { name = "Duration", type = "MuteDuration" }, + { name = "State", type = "State" }, + { name = "Error", type = "text", optional = true }, +] + +[[event]] +domain = "Chat" +name = "Chat Unmuted" +builder = "unmuted" +properties = [ + { name = "Chat Type", type = "ChatType" }, + { name = "State", type = "State" }, + { name = "Error", type = "text", optional = true }, +] + +# Group + +[[event]] +domain = "Group" +name = "Group: New Opened" +description = "The New Group form opened." + +[[event]] +domain = "Group" +name = "Group: Created" +properties = [ + { name = "State", type = "State" }, + { name = "Error", type = "text", optional = true }, + { name = "Gate Mint", type = "text", optional = true }, + { name = "Has Picture", type = "yes/no" }, +] +description = "StartChat returned, on success or failure." + +[[event]] +domain = "Group" +name = "Group: Edited" +properties = [ + { name = "Field", type = "GroupField" }, + { name = "State", type = "State" }, + { name = "Error", type = "text", optional = true }, +] +description = "EditChat returned after a rename or a picture change." + +[[event]] +domain = "Group" +name = "Group: Invite Sheet Opened" +properties = [ + { name = "Source", type = "GroupInviteSheetSource" }, + { name = "Member Count", type = "count" }, +] + +[[event]] +domain = "Group" +name = "Group: Invite Shared" +properties = [{ name = "Method", type = "GroupInviteMethod" }] +description = "Share was tapped in the invite sheet, or the invite link was copied." + +[[event]] +domain = "Group" +name = "Group: Invite Followed" +properties = [{ name = "Source", type = "GroupInviteSource" }] +description = "A group opened from an invite link, a scanned QR code, or an invite card in a chat." + +[[event]] +domain = "Group" +name = "Group: Gate Shown" +properties = [ + { name = "Access", type = "GroupAccess" }, + { name = "Gate Mint", type = "text", optional = true }, + { name = "Member Count", type = "count" }, +] +description = "The gate first appeared for a chat. Sent once per visit to the chat." + +[[event]] +domain = "Group" +name = "Group: Gate Funding Tapped" +properties = [ + { name = "Method", type = "GroupGateFunding" }, + { name = "Gate Mint", type = "text", optional = true }, +] + +[[event]] +domain = "Group" +name = "Group: Joined" +properties = [ + { name = "State", type = "State" }, + { name = "Error", type = "text", optional = true }, + { name = "Member Count", type = "count" }, + { name = "Gated", type = "yes/no" }, +] +description = "JoinChat returned." + +[[event]] +domain = "Group" +name = "Group: Left" +properties = [ + { name = "State", type = "State" }, + { name = "Error", type = "text", optional = true }, + { name = "Member Count", type = "count" }, +] +description = "LeaveChat returned after the user confirmed." + +[[event]] +domain = "Group" +name = "Group: Info Opened" +properties = [ + { name = "Member Count", type = "count" }, + { name = "Is Member", type = "yes/no" }, +] +description = "The group info (profile) screen opened." + # Transfers [[event]] diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt index 91c1acd9b9..67336b0087 100644 --- a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt @@ -72,4 +72,40 @@ class ChatEventsTest { ChatEvents.messageReceived(ChatType.UNKNOWN), ) } + @Test + fun mutedCarriesEveryDurationByItsWireString() { + assertEquals( + listOf("1 Hour", "8 Hours", "1 Week", "Always"), + MuteDuration.entries.map { + (ChatEvents.muted(ChatType.GROUP, it, State.SUCCESS, null).properties.getValue("Duration") as PropertyValue.Text).value + }, + ) + } + + @Test + fun mutedCarriesChatTypeDurationStateAndError() { + assertEquals( + AnalyticsEvent( + "Chat Muted", + mapOf( + "Chat Type" to PropertyValue.Text("Contact"), + "Duration" to PropertyValue.Text("8 Hours"), + "State" to PropertyValue.Text("Failure"), + "Error" to PropertyValue.Text("NotFound"), + ), + ), + ChatEvents.muted(ChatType.CONTACT, MuteDuration.EIGHT_HOURS, State.FAILURE, "NotFound"), + ) + } + + @Test + fun unmutedCarriesChatTypeAndState() { + assertEquals( + AnalyticsEvent( + "Chat Unmuted", + mapOf("Chat Type" to PropertyValue.Text("Group"), "State" to PropertyValue.Text("Success")), + ), + ChatEvents.unmuted(ChatType.GROUP, State.SUCCESS, null), + ) + } } diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/GroupEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/GroupEventsTest.kt new file mode 100644 index 0000000000..b6883682c9 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/GroupEventsTest.kt @@ -0,0 +1,187 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.GroupEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class GroupEventsTest { + + @Test + fun newOpenedCarriesNoProperties() { + assertEquals(AnalyticsEvent("Group: New Opened", emptyMap()), GroupEvents.newOpened()) + } + + @Test + fun createdCarriesStateGateMintAndPicture() { + assertEquals( + AnalyticsEvent( + "Group: Created", + mapOf( + "State" to PropertyValue.Text("Success"), + "Gate Mint" to PropertyValue.Text("mint"), + "Has Picture" to PropertyValue.Flag(true), + ), + ), + GroupEvents.created(State.SUCCESS, null, "mint", true), + ) + } + + @Test + fun createdLeavesOutAnAbsentGateMint() { + assertEquals( + AnalyticsEvent( + "Group: Created", + mapOf( + "State" to PropertyValue.Text("Failure"), + "Error" to PropertyValue.Text("RulesNotSatisfied"), + "Has Picture" to PropertyValue.Flag(false), + ), + ), + GroupEvents.created(State.FAILURE, "RulesNotSatisfied", null, false), + ) + } + + @Test + fun editedCarriesEveryFieldByItsWireString() { + assertEquals( + listOf("Name", "Picture"), + GroupField.entries.map { + val event = GroupEvents.edited(it, State.SUCCESS, null) + assertEquals("Group: Edited", event.name) + (event.properties.getValue("Field") as PropertyValue.Text).value + }, + ) + assertEquals( + AnalyticsEvent( + "Group: Edited", + mapOf( + "Field" to PropertyValue.Text("Name"), + "State" to PropertyValue.Text("Failure"), + "Error" to PropertyValue.Text("Denied"), + ), + ), + GroupEvents.edited(GroupField.NAME, State.FAILURE, "Denied"), + ) + } + + @Test + fun inviteSheetOpenedCarriesSourceAndMemberCount() { + assertEquals( + listOf("Chat", "Profile"), + GroupInviteSheetSource.entries.map { + (GroupEvents.inviteSheetOpened(it, 0).properties.getValue("Source") as PropertyValue.Text).value + }, + ) + assertEquals( + AnalyticsEvent( + "Group: Invite Sheet Opened", + mapOf("Source" to PropertyValue.Text("Profile"), "Member Count" to PropertyValue.Number(12.0)), + ), + GroupEvents.inviteSheetOpened(GroupInviteSheetSource.PROFILE, 12), + ) + } + + @Test + fun inviteSharedCarriesEveryMethodByItsWireString() { + assertEquals( + listOf("Share", "Copy"), + GroupInviteMethod.entries.map { + val event = GroupEvents.inviteShared(it) + assertEquals(AnalyticsEvent("Group: Invite Shared", mapOf("Method" to event.properties.getValue("Method"))), event) + (event.properties.getValue("Method") as PropertyValue.Text).value + }, + ) + } + + @Test + fun inviteFollowedCarriesEverySourceByItsWireString() { + assertEquals( + listOf("Link", "QR", "Chat Card"), + GroupInviteSource.entries.map { + val event = GroupEvents.inviteFollowed(it) + assertEquals(AnalyticsEvent("Group: Invite Followed", mapOf("Source" to event.properties.getValue("Source"))), event) + (event.properties.getValue("Source") as PropertyValue.Text).value + }, + ) + } + + @Test + fun gateShownCarriesAccessGateMintAndMemberCount() { + assertEquals( + listOf("Eligible", "Blocked"), + GroupAccess.entries.map { + (GroupEvents.gateShown(it, null, 0).properties.getValue("Access") as PropertyValue.Text).value + }, + ) + assertEquals( + AnalyticsEvent( + "Group: Gate Shown", + mapOf( + "Access" to PropertyValue.Text("Blocked"), + "Gate Mint" to PropertyValue.Text("mint"), + "Member Count" to PropertyValue.Number(3.0), + ), + ), + GroupEvents.gateShown(GroupAccess.BLOCKED, "mint", 3), + ) + } + + @Test + fun gateFundingTappedCarriesMethodAndGateMint() { + assertEquals( + listOf("Buy Token", "Add Cash"), + GroupGateFunding.entries.map { + (GroupEvents.gateFundingTapped(it, null).properties.getValue("Method") as PropertyValue.Text).value + }, + ) + assertEquals( + AnalyticsEvent( + "Group: Gate Funding Tapped", + mapOf("Method" to PropertyValue.Text("Buy Token"), "Gate Mint" to PropertyValue.Text("mint")), + ), + GroupEvents.gateFundingTapped(GroupGateFunding.BUY_TOKEN, "mint"), + ) + assertEquals( + AnalyticsEvent("Group: Gate Funding Tapped", mapOf("Method" to PropertyValue.Text("Add Cash"))), + GroupEvents.gateFundingTapped(GroupGateFunding.ADD_CASH, null), + ) + } + + @Test + fun joinedCarriesStateMemberCountAndGated() { + assertEquals( + AnalyticsEvent( + "Group: Joined", + mapOf( + "State" to PropertyValue.Text("Failure"), + "Error" to PropertyValue.Text("Network"), + "Member Count" to PropertyValue.Number(7.0), + "Gated" to PropertyValue.Flag(true), + ), + ), + GroupEvents.joined(State.FAILURE, "Network", 7, true), + ) + } + + @Test + fun leftCarriesStateAndMemberCount() { + assertEquals( + AnalyticsEvent( + "Group: Left", + mapOf("State" to PropertyValue.Text("Success"), "Member Count" to PropertyValue.Number(4.0)), + ), + GroupEvents.left(State.SUCCESS, null, 4), + ) + } + + @Test + fun infoOpenedCarriesMemberCountAndMembership() { + assertEquals( + AnalyticsEvent( + "Group: Info Opened", + mapOf("Member Count" to PropertyValue.Number(9.0), "Is Member" to PropertyValue.Flag(false)), + ), + GroupEvents.infoOpened(9, false), + ) + } +}