Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class TokenInfoEntryTest {
TokenInfoEntry.Wallet to TokenInfoPresentation.CardExpand,
TokenInfoEntry.Deeplink to TokenInfoPresentation.CardExpand,
TokenInfoEntry.Discovery to TokenInfoPresentation.Push,
TokenInfoEntry.Search to TokenInfoPresentation.Push,
TokenInfoEntry.Chat to TokenInfoPresentation.Push,
TokenInfoEntry.ChatGate to TokenInfoPresentation.Push,
),
Expand All @@ -40,6 +41,7 @@ class TokenInfoEntryTest {
TokenInfoEntry.Wallet to TokenInfoSwapOutcome.Stay,
TokenInfoEntry.Deeplink to TokenInfoSwapOutcome.Stay,
TokenInfoEntry.Discovery to TokenInfoSwapOutcome.Stay,
TokenInfoEntry.Search to TokenInfoSwapOutcome.Stay,
TokenInfoEntry.Chat to TokenInfoSwapOutcome.Stay,
TokenInfoEntry.ChatGate to TokenInfoSwapOutcome.Pop,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ sealed interface TokenInfoEntry : Parcelable {
override val afterBuy get() = AfterBuy.Stay
}

/** A currency result tapped on the Chats search screen. */
@Serializable
data object Search : TokenInfoEntry {
override val presentation get() = TokenInfoPresentation.Push
override val afterBuy get() = AfterBuy.Stay
}

/** A currency mentioned in a chat transcript. */
@Serializable
data object Chat : TokenInfoEntry {
Expand All @@ -56,7 +63,7 @@ sealed interface TokenInfoEntry : Parcelable {

companion object {
/** Every origin. Covered by a test against the serializer's subclass list. */
val all: List<TokenInfoEntry> = listOf(Wallet, Deeplink, Discovery, Chat, ChatGate)
val all: List<TokenInfoEntry> = listOf(Wallet, Deeplink, Discovery, Search, Chat, ChatGate)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3295,7 +3295,9 @@ internal class ChatViewModel @AssistedInject constructor(
if (userId in stateFlow.value.joinRequestCalls) return@onEach
dispatchEvent(Event.JoinRequestCallChanged(userId, JoinRequestCall.Approve))
viewModelScope.launch {
hostLobbies.approve(chatId, event.member).onFailure {
hostLobbies.approve(chatId, event.member).also {
analytics.trackJoinRequestApproved(it)
}.onFailure {
trace("failed to admit lobby member - ${it.localizedMessage}")
BottomBarManager.showError(
title = resources.getString(R.string.error_title_failedToApproveJoinRequest),
Expand All @@ -3314,7 +3316,9 @@ internal class ChatViewModel @AssistedInject constructor(
if (userId in stateFlow.value.joinRequestCalls) return@onEach
dispatchEvent(Event.JoinRequestCallChanged(userId, JoinRequestCall.Deny))
viewModelScope.launch {
hostLobbies.deny(chatId, userId).onFailure {
hostLobbies.deny(chatId, userId).also {
analytics.trackJoinRequestDenied(it)
}.onFailure {
trace("failed to deny lobby member - ${it.localizedMessage}")
BottomBarManager.showError(
title = resources.getString(R.string.error_title_failedToDenyJoinRequest),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.flipcash.app.messenger.internal

import com.flipcash.analytics.SilenceDuration
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.shared.chat.InviteCreation

private fun Result<*>.state() = if (isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE

private fun Result<*>.error() = exceptionOrNull()?.chatResult

/**
* `Group: Invite Link Created` for the answer CreateInvite gave. [InviteCreation] folds the
* server's TOO_MANY_INVITES and DENIED into its own cases, so they are named again here.
*/
internal fun FlipcashAnalytics.trackInviteLinkCreated(creation: InviteCreation) {
val (state, error) = when (creation) {
is InviteCreation.Created -> AnalyticsState.SUCCESS to null
InviteCreation.TooMany -> AnalyticsState.FAILURE to "TooManyInvites"
InviteCreation.NotAvailable -> AnalyticsState.FAILURE to "Denied"
is InviteCreation.Failed -> AnalyticsState.FAILURE to creation.cause.chatResult
}
track(GroupEvents.inviteLinkCreated(state = state, error = error))
}

/** `Group: Invite Link Revoked` for the answer RevokeInvite gave, after the host confirmed. */
internal fun FlipcashAnalytics.trackInviteLinkRevoked(result: Result<*>) {
track(GroupEvents.inviteLinkRevoked(state = result.state(), error = result.error()))
}

/** `Group: Join Request Approved` for the answer AdmitLobbyMember gave. */
internal fun FlipcashAnalytics.trackJoinRequestApproved(result: Result<*>) {
track(GroupEvents.joinRequestApproved(state = result.state(), error = result.error()))
}

/** `Group: Join Request Denied` for the answer DenyLobbyMember gave. */
internal fun FlipcashAnalytics.trackJoinRequestDenied(result: Result<*>) {
track(GroupEvents.joinRequestDenied(state = result.state(), error = result.error()))
}

/** `Group: Member Silenced` for the answer SilenceUser gave to the option picked. */
internal fun FlipcashAnalytics.trackMemberSilenced(option: SilenceOption, result: Result<*>) {
track(
GroupEvents.memberSilenced(
duration = option.analytics,
state = result.state(),
error = result.error(),
)
)
}

/** `Group: Member Removed` for the answer BanUser gave after the host confirmed Remove. */
internal fun FlipcashAnalytics.trackMemberRemoved(result: Result<*>) {
track(GroupEvents.memberRemoved(state = result.state(), error = result.error()))
}

private val SilenceOption.analytics: SilenceDuration
get() = when (this) {
SilenceOption.OneHour -> SilenceDuration.ONE_HOUR
SilenceOption.EightHours -> SilenceDuration.EIGHT_HOURS
SilenceOption.OneWeek -> SilenceDuration.ONE_WEEK
SilenceOption.Forever -> SilenceDuration.FOREVER
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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.controllers.ChatController
import com.flipcash.shared.chat.GroupRosters
Expand All @@ -27,6 +28,7 @@ internal class MemberModerationViewModel @Inject constructor(
private val chatController: ChatController,
private val resources: ResourceHelper,
private val groupRosters: GroupRosters,
private val analytics: FlipcashAnalytics,
) : ViewModel() {

/**
Expand All @@ -52,7 +54,9 @@ internal class MemberModerationViewModel @Inject constructor(
viewModelScope.launch {
// The alert closes on the tap and this scope can go with the screen before the answer lands.
withContext(NonCancellable) {
chatController.banUser(chatId, userId).onSuccess {
chatController.banUser(chatId, userId).also {
analytics.trackMemberRemoved(it)
}.onSuccess {
groupRosters.remove(chatId, userId)
}.onFailure {
trace("failed to remove member - ${it.localizedMessage}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flipcash.app.analytics.FlipcashAnalytics
import com.flipcash.app.core.toast.SystemToastController
import com.flipcash.features.messenger.R
import com.flipcash.services.controllers.ChatController
Expand All @@ -28,13 +29,16 @@ internal class SilenceUserViewModel @Inject constructor(
private val chatController: ChatController,
private val resources: ResourceHelper,
private val toastController: SystemToastController,
private val analytics: FlipcashAnalytics,
) : ViewModel() {

fun silence(chatId: ChatId, userId: ID, firstName: String, option: SilenceOption) {
viewModelScope.launch {
// Scoped to the sheet's entry, which is gone by the time the answer lands.
withContext(NonCancellable) {
chatController.silenceUser(chatId, userId, option.toSilenceState()).onSuccess {
chatController.silenceUser(chatId, userId, option.toSilenceState()).also {
analytics.trackMemberSilenced(option, it)
}.onSuccess {
toastController.showToast(option.toastRes, firstName, replacePrevious = true)
}.onFailure {
trace("failed to silence user - ${it.localizedMessage}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package com.flipcash.app.messenger.internal.invites
import android.content.ClipboardManager
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flipcash.app.analytics.FlipcashAnalytics
import com.flipcash.app.core.extensions.setSensitiveText
import com.flipcash.app.messenger.internal.trackInviteLinkCreated
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.ChatId
import com.flipcash.shared.chat.HostInvite
Expand Down Expand Up @@ -63,6 +65,7 @@ internal class InviteLinkViewModel @Inject constructor(
private val hostInvites: HostInvites,
private val resources: ResourceHelper,
private val clipboardManager: ClipboardManager,
private val analytics: FlipcashAnalytics,
) : ViewModel() {

enum class Action { Share, Copy, Send }
Expand Down Expand Up @@ -160,7 +163,7 @@ internal class InviteLinkViewModel @Inject constructor(
}

private suspend fun create(chatId: ChatId) {
when (val result = hostInvites.currentOrCreate(chatId)) {
when (val result = hostInvites.currentOrCreate(chatId).also { analytics.trackInviteLinkCreated(it) }) {
is InviteCreation.Created -> {
val link = result.invite.toState()
_state.update { it.copy(link = link) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package com.flipcash.app.messenger.internal.invites

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flipcash.app.analytics.FlipcashAnalytics
import com.flipcash.app.messenger.internal.trackInviteLinkCreated
import com.flipcash.app.messenger.internal.trackInviteLinkRevoked
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.ChatId
import com.flipcash.shared.chat.HostInvite
Expand All @@ -28,6 +31,7 @@ import kotlin.time.Clock
internal class ManageInvitesViewModel @Inject constructor(
private val hostInvites: HostInvites,
private val resources: ResourceHelper,
private val analytics: FlipcashAnalytics,
) : ViewModel() {

data class State(
Expand Down Expand Up @@ -88,6 +92,7 @@ internal class ManageInvitesViewModel @Inject constructor(
val chatId = chatId ?: return
viewModelScope.launch {
hostInvites.revoke(chatId, invite.invite.id)
.also { analytics.trackInviteLinkRevoked(it) }
.onSuccess { publish(chatId) }
.onFailure { error ->
if (error.isLostInviteAccess()) {
Expand All @@ -108,7 +113,7 @@ internal class ManageInvitesViewModel @Inject constructor(
if (_state.value.creating) return
_state.update { it.copy(creating = true) }
viewModelScope.launch {
when (val result = hostInvites.create(chatId, settings)) {
when (val result = hostInvites.create(chatId, settings).also { analytics.trackInviteLinkCreated(it) }) {
is InviteCreation.Created -> {
_state.update { it.copy(creating = false) }
publish(chatId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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.analytics.events.GroupEvents
import com.flipcash.app.analytics.RecordingAnalytics
import com.flipcash.app.contacts.ContactCoordinator
import com.flipcash.app.persistence.sources.UserProfileDataSource
Expand All @@ -19,7 +20,10 @@ import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.libs.coroutines.TestDispatcherProvider
import com.flipcash.services.chat.E2eePolicy
import com.flipcash.services.models.JoinChatError
import com.flipcash.services.models.AdmitLobbyMemberError
import com.flipcash.services.models.DenyLobbyMemberError
import com.flipcash.services.models.LeaveChatError
import com.flipcash.services.models.chat.LobbyMember
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMetadata
import com.flipcash.services.models.chat.ChatRuleRequirement
Expand All @@ -30,6 +34,7 @@ import com.flipcash.shared.chat.ChatDraftStore
import com.flipcash.shared.chat.ChatMembership
import com.flipcash.shared.chat.FeaturedGroupsStore
import com.flipcash.shared.chat.GroupAccess
import com.flipcash.shared.chat.HostLobbies
import com.flipcash.shared.payments.ContactPaymentDelegate
import com.flipcash.shared.payments.TipPaymentDelegate
import com.getcode.libs.emojis.reactions.EmojiCatalogLoader
Expand Down Expand Up @@ -108,6 +113,7 @@ class ChatGroupAnalyticsTest {
private val recentReactionsStore = mockk<RecentReactionsStore>(relaxed = true)
private val emojiCatalogLoader = mockk<EmojiCatalogLoader>(relaxed = true)
private val userProfileDataSource = mockk<UserProfileDataSource>(relaxed = true)
private val hostLobbies = mockk<HostLobbies>(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }

private val chatId = ChatId(UUID.randomUUID().bytes)
private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa")
Expand Down Expand Up @@ -169,7 +175,7 @@ class ChatGroupAnalyticsTest {
rosterSearch = mockk(relaxed = true),
featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)),
lobbyRequests = mockk(relaxed = true),
hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) },
hostLobbies = hostLobbies,
groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) },
dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher),
identifier = null,
Expand Down Expand Up @@ -315,4 +321,76 @@ class ChatGroupAnalyticsTest {
val event = analytics.events.single { it.name == "Group: Invite Followed" }
assertEquals(text("Chat Card"), event.properties["Source"])
}

private fun lobbyMember(): LobbyMember = mockk(relaxed = true) {
every { userProfile.userId } returns listOf<Byte>(9)
}

@Test
fun `an approved request reports Join Request Approved as a success`() = runTest(mainCoroutineRule.dispatcher) {
coEvery { hostLobbies.approve(any(), any()) } returns Result.success(Unit)

val vm = createViewModel()
vm.openGroup(isMember = true)
vm.dispatchEvent(ChatViewModel.Event.ApproveJoinRequest(lobbyMember()))
advanceUntilIdle()

assertEquals(
listOf(GroupEvents.joinRequestApproved(state = AnalyticsState.SUCCESS, error = null)),
analytics.events.filter { it.name.startsWith("Group: Join Request") },
)
}

@Test
fun `a failed approve reports the result name`() = runTest(mainCoroutineRule.dispatcher) {
coEvery { hostLobbies.approve(any(), any()) } returns Result.failure(AdmitLobbyMemberError.Denied())

val vm = createViewModel()
vm.openGroup(isMember = true)
vm.dispatchEvent(ChatViewModel.Event.ApproveJoinRequest(lobbyMember()))
advanceUntilIdle()

assertEquals(
listOf(GroupEvents.joinRequestApproved(state = AnalyticsState.FAILURE, error = "Denied")),
analytics.events.filter { it.name.startsWith("Group: Join Request") },
)
}

@Test
fun `a denied request reports Join Request Denied, and a failure carries its error`() =
runTest(mainCoroutineRule.dispatcher) {
coEvery { hostLobbies.deny(any(), any()) } returnsMany listOf(
Result.success(Unit),
Result.failure(DenyLobbyMemberError.Other(RuntimeException("UNAVAILABLE"))),
)

val vm = createViewModel()
vm.openGroup(isMember = true)
vm.dispatchEvent(ChatViewModel.Event.DenyJoinRequest(lobbyMember()))
advanceUntilIdle()
vm.dispatchEvent(ChatViewModel.Event.DenyJoinRequest(lobbyMember()))
advanceUntilIdle()

assertEquals(
listOf(
GroupEvents.joinRequestDenied(state = AnalyticsState.SUCCESS, error = null),
GroupEvents.joinRequestDenied(state = AnalyticsState.FAILURE, error = "Network"),
),
analytics.events.filter { it.name.startsWith("Group: Join Request") },
)
}

@Test
fun `a request with no user id sends nothing`() = runTest(mainCoroutineRule.dispatcher) {
val vm = createViewModel()
vm.openGroup(isMember = true)
vm.dispatchEvent(
ChatViewModel.Event.ApproveJoinRequest(
mockk(relaxed = true) { every { userProfile.userId } returns null }
)
)
advanceUntilIdle()

assertTrue(analytics.events.none { it.name.startsWith("Group: Join Request") })
}
}
Loading
Loading