From bcfeb464b7258729f02c7763f9a526ac8f1e7995 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 22:55:31 -0400 Subject: [PATCH 1/8] feat(analytics): add group chat and mute events to the catalogue Adds a Group domain (GroupEvents) with eleven events covering create, edit, invite, gate, join, leave and info, and Chat Muted / Chat Unmuted in the Chat domain. MuteDuration matches the four options both apps offer. TokenInfoSource CHAT_GATE gets a drift note: iOS's gate buy button pushes the buy screen and never opens Token Info. --- .../SharedCoreKit/AnalyticsEvents.swift | 8 + .../AnalyticsEventsTests.swift | 6 + libs/analytics-events/EVENTS.md | 186 ++++++++++++++++- libs/analytics-events/events.toml | 165 +++++++++++++++- .../com/flipcash/analytics/ChatEventsTest.kt | 36 ++++ .../com/flipcash/analytics/GroupEventsTest.kt | 187 ++++++++++++++++++ 6 files changed, 586 insertions(+), 2 deletions(-) create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/GroupEventsTest.kt 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), + ) + } +} From 1a107f1e69b4ea6b6abb5f9f80eb35ea344aed3d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 23:00:01 -0400 Subject: [PATCH 2/8] feat(analytics): convert chat RPC errors and group rules for the group events chatResult gives the proto result name the Error property carries (Denied, NotFound, RulesNotSatisfied, ...), or Network for a transport failure; class names are spelled out because R8 renames them. gateMint is the listener rule's single mint, or null for an ungated or any-currency group. --- .../com/flipcash/app/analytics/Conversions.kt | 45 +++++++++++++++++++ .../flipcash/app/analytics/ConversionsTest.kt | 42 +++++++++++++++++ 2 files changed, 87 insertions(+) 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) + } } From 19ebb7a453f11f7a47082779bf5bf2fb875b0e62 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 23:00:01 -0400 Subject: [PATCH 3/8] feat(tipping): send Group: New Opened and Group: Created New Opened fires on the Create Public Group row, the flow's only entry. Created fires when StartChat returns, with the proto result name on a failure. --- .../features/tipping/build.gradle.kts | 2 + .../com/flipcash/app/tipping/NewChatScreen.kt | 10 +++- .../tipping/internal/CreateGroupViewModel.kt | 17 +++++- .../internal/CreateGroupViewModelErrorTest.kt | 57 +++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) 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/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..81ea2983da 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,11 @@ import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.compose.foundation.text.input.TextFieldState import androidx.lifecycle.viewModelScope +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.app.blob.BlobStorageCoordinator import com.flipcash.app.blob.ImageUploadPreparer import com.flipcash.app.core.data.Loadable @@ -83,6 +88,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 +386,16 @@ internal class CreateGroupViewModel @Inject constructor( rules = state.rules, ), idempotencyKey = idempotencyKey, - ).onSuccess { chat -> + ).also { result -> + analytics.track( + GroupEvents.created( + state = if (result.isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE, + error = result.exceptionOrNull()?.chatResult, + gateMint = state.rules.gateMint, + hasPicture = picture != null, + ) + ) + }.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/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, + ) + } } From 957a9f008e9530ad12851c9d0cf1e3c4eb0def88 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 23:02:18 -0400 Subject: [PATCH 4/8] feat(messenger): send Group: Edited from the rename and picture screens Fires when EditChat returns. A picture the blob store refuses never reaches EditChat, so it sends nothing. --- .../profile/edit/EditGroupNameViewModel.kt | 10 +- .../profile/edit/EditGroupPictureViewModel.kt | 11 +- .../screens/profile/edit/EditedEvent.kt | 18 +++ .../profile/edit/EditGroupConfirmationTest.kt | 3 + .../profile/edit/GroupEditedAnalyticsTest.kt | 147 ++++++++++++++++++ 5 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditedEvent.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupEditedAnalyticsTest.kt 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/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) + } +} From 1199f9c60a219cae51f7670b576faba2e7069490 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 23:05:27 -0400 Subject: [PATCH 5/8] feat(messenger): send Chat Muted and Chat Unmuted from the mute sheet The sheet now hands the view model the MuteOption instead of the MuteState built from it, so the picked duration is still known when the event fires. MuteChat carries the chat type because the sheet only had the id; ConversationReference gains chatType so the chat list can pass a DM's kind rather than only isGroup. --- .../ui/navigation/AppScreenContent.kt | 2 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 3 +- .../flipcash/app/messenger/MuteChatScreen.kt | 10 +- .../messenger/internal/MuteChatViewModel.kt | 45 ++++++- .../internal/screens/MuteChatSheet.kt | 5 +- .../screens/profile/ChatProfileScreen.kt | 2 +- .../screens/profile/GroupProfileScreen.kt | 3 +- .../internal/MuteChatAnalyticsTest.kt | 114 ++++++++++++++++++ .../com/flipcash/app/tipping/ChatsScreen.kt | 2 +- .../shared/chat/ui/ChatSummaryMapping.kt | 1 + .../shared/chat/ui/ConversationReference.kt | 3 + 11 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatAnalyticsTest.kt 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/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/MuteChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt index 584ef9f359..98cf14c536 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,15 @@ package com.flipcash.app.messenger.internal import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +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.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 +37,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 +64,35 @@ 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 { result -> + analytics.track( + ChatEvents.muted( + chatType = chatType.analytics, + duration = option.analytics, + state = result.analyticsState, + error = result.exceptionOrNull()?.chatResult, + ) + ) + } } - 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) + chatCoordinator.unmute(chatId).also { result -> + analytics.track( + ChatEvents.unmuted( + chatType = chatType.analytics, + state = result.analyticsState, + error = result.exceptionOrNull()?.chatResult, + ) + ) + } } private fun request( @@ -92,3 +116,14 @@ internal class MuteChatViewModel @Inject constructor( } } } + +private val Result<*>.analyticsState: AnalyticsState + get() = if (isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE + +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/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..617a524374 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 @@ -42,6 +42,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 @@ -145,7 +146,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // 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/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/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/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. From 70885257c62e6323a5b065d085fc99b23c472e34 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 23:12:16 -0400 Subject: [PATCH 6/8] feat(messenger): send the group gate, invite, join, leave and info events ChatViewModel sends Group: Gate Shown, Invite Sheet Opened, Invite Shared, Gate Funding Tapped, Joined, Left and Info Opened. The screens dispatch the taps as events, so the member count and gate mint come from the group the view model already holds, read before the join or leave moves the roster. Gate Shown is taken once per view model, which the chat flow scopes to one visit, so a balance change under an open chat does not send it again. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 1 + .../app/messenger/internal/ChatViewModel.kt | 103 +++++++ .../internal/screens/GroupInviteSheet.kt | 2 + .../internal/screens/MessengerScreen.kt | 14 +- .../screens/profile/GroupProfileScreen.kt | 8 +- .../internal/ChatGroupAnalyticsTest.kt | 276 ++++++++++++++++++ 6 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt 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/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 0ad2a6126b..556712e8dd 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.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.chatResult +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,18 @@ 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 + /** The group profile's "Leave Chat" row, which puts the confirmation up. */ data object LeaveChat : Event @@ -991,6 +1011,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 +1583,58 @@ 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, 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 { + 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 +1645,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 +1732,22 @@ 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 { result -> + analytics.track( + GroupEvents.joined( + state = result.analyticsState, + error = result.exceptionOrNull()?.chatResult, + memberCount = group?.memberCount?.toInt() ?: 0, + gated = group?.rules?.listener.orEmpty().isNotEmpty(), + ) + ) + } .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 +1806,19 @@ 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 { result -> + analytics.track( + GroupEvents.left( + state = result.analyticsState, + error = result.exceptionOrNull()?.chatResult, + memberCount = memberCount, + ) + ) + } .onSuccess { dispatchEvent(Event.LeftChat) } .onFailure { trace("failed to leave chat - ${it.localizedMessage}") @@ -2275,6 +2371,10 @@ internal class ChatViewModel @Inject constructor( state.copy(viewerState = event.viewerState) } Event.CopyInviteLink, + is Event.InviteSheetOpened, + Event.InviteLinkShared, + is Event.GateFundingTapped, + Event.GroupInfoOpened, Event.LeaveChat, Event.LeaveConfirmed, Event.LeftChat -> { state -> state } @@ -2431,3 +2531,6 @@ internal class ChatViewModel @Inject constructor( } } } + +private val Result<*>.analyticsState: AnalyticsState + get() = if (isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE 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..0d5d518792 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) } @@ -160,6 +168,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 +179,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/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index 617a524374..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 @@ -140,7 +141,12 @@ 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 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..2d2f0e9e08 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -0,0 +1,276 @@ +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"]) + } +} From 1334f620ff6965559494104ebed99e372884692d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 23:14:58 -0400 Subject: [PATCH 7/8] feat(group): send Group: Invite Followed from links, QR codes and invite cards A link is reported from the app's DeepLinkListener, which hears each link once; the cold-start path re-derives its routes on every auth re-emission and would count one link several times. The scanner reports QR in its GroupChatInvite branch, and the transcript reports Chat Card when an invite card opens a different group. --- .../main/kotlin/com/flipcash/app/internal/ui/App.kt | 9 +++++++++ .../app/messenger/internal/ChatViewModel.kt | 11 ++++++++++- .../messenger/internal/screens/MessengerScreen.kt | 1 + .../messenger/internal/ChatGroupAnalyticsTest.kt | 11 +++++++++++ .../com/flipcash/app/scanner/internal/Scanner.kt | 13 +++++++++---- 5 files changed, 40 insertions(+), 5 deletions(-) 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/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 556712e8dd..2eb4002f38 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 @@ -17,6 +17,7 @@ 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 @@ -494,6 +495,9 @@ internal class ChatViewModel @Inject constructor( /** 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 @@ -1585,7 +1589,7 @@ internal class ChatViewModel @Inject constructor( /** * The group events that are not an RPC's answer: the gate appearing, and the taps on the - * invite sheet, the gate's funding button and the group profile. The screens report the taps + * 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() { @@ -1625,6 +1629,10 @@ internal class ChatViewModel @Inject constructor( } .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 @@ -2375,6 +2383,7 @@ internal class ChatViewModel @Inject constructor( 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/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 0d5d518792..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 @@ -112,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( 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 index 2d2f0e9e08..204e527d77 100644 --- 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 @@ -273,4 +273,15 @@ class ChatGroupAnalyticsTest { 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/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()) { From 7b02decf9bef78392e8577d883aef145f1fc2a3d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 23:39:40 -0400 Subject: [PATCH 8/8] refactor(analytics): move the chat RPC events into track helpers Created, Joined, Left, Muted and Unmuted now go through a FlipcashAnalytics extension next to their callers, as Edited already did, so each call site is one line and the State and Error derivation lives with the event. This drops the two private Result.analyticsState copies. --- .../app/messenger/internal/ChatViewModel.kt | 25 +----------- .../messenger/internal/MembershipEvents.kt | 32 +++++++++++++++ .../messenger/internal/MuteChatViewModel.kt | 38 ++---------------- .../app/messenger/internal/MuteEvents.kt | 40 +++++++++++++++++++ .../tipping/internal/CreateGroupViewModel.kt | 15 +------ .../app/tipping/internal/CreatedEvent.kt | 20 ++++++++++ 6 files changed, 99 insertions(+), 71 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MembershipEvents.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteEvents.kt create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreatedEvent.kt 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 2eb4002f38..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 @@ -26,7 +26,6 @@ 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.chatResult import com.flipcash.app.analytics.gateMint import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.AppRoute @@ -1746,16 +1745,7 @@ internal class ChatViewModel @Inject constructor( // 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 { result -> - analytics.track( - GroupEvents.joined( - state = result.analyticsState, - error = result.exceptionOrNull()?.chatResult, - memberCount = group?.memberCount?.toInt() ?: 0, - gated = group?.rules?.listener.orEmpty().isNotEmpty(), - ) - ) - } + .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 @@ -1818,15 +1808,7 @@ internal class ChatViewModel @Inject constructor( // `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 { result -> - analytics.track( - GroupEvents.left( - state = result.analyticsState, - error = result.exceptionOrNull()?.chatResult, - memberCount = memberCount, - ) - ) - } + .also { analytics.trackLeft(memberCount, it) } .onSuccess { dispatchEvent(Event.LeftChat) } .onFailure { trace("failed to leave chat - ${it.localizedMessage}") @@ -2540,6 +2522,3 @@ internal class ChatViewModel @Inject constructor( } } } - -private val Result<*>.analyticsState: AnalyticsState - get() = if (isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE 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 98cf14c536..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,12 +2,7 @@ package com.flipcash.app.messenger.internal import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -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.features.messenger.R import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType @@ -68,31 +63,16 @@ internal class MuteChatViewModel @Inject constructor( errorTitle = R.string.error_title_failedToMute, errorDescription = R.string.error_description_failedToMute, ) { - chatCoordinator.mute(chatId, option.toMuteState()).also { result -> - analytics.track( - ChatEvents.muted( - chatType = chatType.analytics, - duration = option.analytics, - state = result.analyticsState, - error = result.exceptionOrNull()?.chatResult, - ) - ) - } + chatCoordinator.mute(chatId, option.toMuteState()) + .also { analytics.trackMuted(chatType, option, it) } } fun unmute(chatId: ChatId, chatType: ChatType) = request( errorTitle = R.string.error_title_failedToUnmute, errorDescription = R.string.error_description_failedToUnmute, ) { - chatCoordinator.unmute(chatId).also { result -> - analytics.track( - ChatEvents.unmuted( - chatType = chatType.analytics, - state = result.analyticsState, - error = result.exceptionOrNull()?.chatResult, - ) - ) - } + chatCoordinator.unmute(chatId) + .also { analytics.trackUnmuted(chatType, it) } } private fun request( @@ -117,13 +97,3 @@ internal class MuteChatViewModel @Inject constructor( } } -private val Result<*>.analyticsState: AnalyticsState - get() = if (isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE - -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/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/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 81ea2983da..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,11 +5,7 @@ import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.compose.foundation.text.input.TextFieldState import androidx.lifecycle.viewModelScope -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.app.blob.BlobStorageCoordinator import com.flipcash.app.blob.ImageUploadPreparer import com.flipcash.app.core.data.Loadable @@ -386,16 +382,7 @@ internal class CreateGroupViewModel @Inject constructor( rules = state.rules, ), idempotencyKey = idempotencyKey, - ).also { result -> - analytics.track( - GroupEvents.created( - state = if (result.isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE, - error = result.exceptionOrNull()?.chatResult, - gateMint = state.rules.gateMint, - hasPicture = picture != null, - ) - ) - }.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, + ) + ) +}