Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ fun appEntryProvider(
annotatedEntry<AppRoute.Messaging.Report> { key ->
ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
annotatedEntry<AppRoute.Messaging.MuteChat> { key -> MuteChatScreen(key.chatId) }
annotatedEntry<AppRoute.Messaging.MuteChat> { key -> MuteChatScreen(key.chatId, key.chatType) }

// Tokens
annotatedEntry<AppRoute.Token.Info>(testTag = "token_info_screen") { key ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MuteChatViewModel>()
val viewerStates = remember(viewModel, chatId) { viewModel.observeViewerState(chatId) }
val initial = remember(viewModel, chatId) { viewModel.currentViewerState(chatId) }
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Event.InviteSheetOpened>()
.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<Event.InviteLinkShared>()
.onEach { analytics.track(GroupEvents.inviteShared(GroupInviteMethod.SHARE)) }
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.GateFundingTapped>()
.onEach { event ->
val group = stateFlow.value.subject as? ChatSubject.Group
analytics.track(GroupEvents.gateFundingTapped(event.method, group?.rules.gateMint))
}
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.InviteCardFollowed>()
.onEach { analytics.track(GroupEvents.inviteFollowed(GroupInviteSource.CHAT_CARD)) }
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.GroupInfoOpened>()
.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.
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1720,9 +1804,11 @@ internal class ChatViewModel @Inject constructor(
eventFlow.filterIsInstance<Event.LeaveConfirmed>()
.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}")
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {

/**
Expand All @@ -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(
Expand All @@ -92,3 +96,4 @@ internal class MuteChatViewModel @Inject constructor(
}
}
}

Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading