From 88ad895d998c1cdfcdc54f542d4ae434832a9fc2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 20:41:01 -0400 Subject: [PATCH 1/4] feat(analytics): catalogue invite links, moderation, search and Currency Info --- .../com/flipcash/app/tipping/NewChatScreen.kt | 2 +- .../SharedCoreKit/AnalyticsEvents.swift | 6 + libs/analytics-events/EVENTS.md | 161 +++++++++++++++++- libs/analytics-events/events.toml | 137 ++++++++++++++- .../com/flipcash/analytics/GroupEventsTest.kt | 75 +++++++- .../flipcash/analytics/SearchEventsTest.kt | 25 +++ .../flipcash/analytics/TokenInfoEventsTest.kt | 37 ++++ 7 files changed, 435 insertions(+), 8 deletions(-) create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/SearchEventsTest.kt 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 a4f100d352..041f6d5fec 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 @@ -74,7 +74,7 @@ fun NewChatScreen() { if (hasCommunityCurrency == false) { presentNoCommunityCurrencies(resources, navigator) } else { - analytics.track(GroupEvents.newOpened()) + analytics.track(GroupEvents.newOpened(null)) navigator.push(AppRoute.Messaging.NewGroup()) } }, diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift index 8bd947c935..5283dbed71 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift @@ -21,12 +21,17 @@ public typealias WalletProvider = SharedCore.WalletProvider public typealias CashLinkChoice = SharedCore.CashLinkChoice public typealias OnrampStep = SharedCore.OnrampStep public typealias MuteDuration = SharedCore.MuteDuration +public typealias SilenceDuration = SharedCore.SilenceDuration +public typealias GroupType = SharedCore.GroupType 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 SearchResultType = SharedCore.SearchResultType +public typealias CurrencyInfoTile = SharedCore.CurrencyInfoTile +public typealias CurrencyInfoMoreOption = SharedCore.CurrencyInfoMoreOption public typealias ProfileField = SharedCore.ProfileField public typealias ProfileMenuAction = SharedCore.ProfileMenuAction public typealias ProfilePinnedAction = SharedCore.ProfilePinnedAction @@ -51,6 +56,7 @@ public typealias AccountEvents = SharedCore.AccountEvents public typealias ButtonEvents = SharedCore.ButtonEvents public typealias GroupEvents = SharedCore.GroupEvents public typealias ProfileEvents = SharedCore.ProfileEvents +public typealias SearchEvents = SharedCore.SearchEvents /// 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/libs/analytics-events/EVENTS.md b/libs/analytics-events/EVENTS.md index 6b7d957cb4..f848f29b64 100644 --- a/libs/analytics-events/EVENTS.md +++ b/libs/analytics-events/EVENTS.md @@ -58,9 +58,11 @@ Group chats. `Member Count` is the member count before the action, from what the ### Group: New Opened -The New Group form opened. +The New Group form opened. Type says whether the user tapped Create a New Group or Create a New Community Chat. -No properties. +| Property | Type | Optional | Values | +|---|---|---|---| +| Type | list | yes | [GroupType](#grouptype) | ### Group: Created @@ -161,6 +163,79 @@ The group info (profile) screen opened. | Member Count | count | no | | | Is Member | yes/no | no | | +### Group: Invite Link Created + +CreateInvite returned for a private group's invite link, from New Link in Manage Invites or when Share Invite Link makes one because none exists. + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| Error | text | yes | | + +### Group: Invite Link Revoked + +RevokeInvite returned after the host confirmed Revoke Link. + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| Error | text | yes | | + +### Group: Join Request Approved + +A host tapped Approve on a join request and the admit call returned. + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| Error | text | yes | | + +### Group: Join Request Denied + +A host tapped Deny on a join request and the deny call returned. + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| Error | text | yes | | + +### Group: Member Silenced + +A host picked a silence option for a member and the call returned. The member can still read the group but can't send messages. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Duration | list | no | [SilenceDuration](#silenceduration) | +| State | list | no | [State](#state) | +| Error | text | yes | | + +### Group: Member Removed + +A host confirmed Remove on a member and the ban call returned. Both apps remove a member by banning them. + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| Error | text | yes | | + +## Search + +The Chats search screen. + +### Search: Opened + +The Chats search screen opened. + +No properties. + +### Search: Result Tapped + +A result row was tapped on the Chats search screen. Tapping a section's See all is not a result. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Result Type | list | no | [SearchResultType](#searchresulttype) | + ## Transfer ### Grab Bill Start @@ -354,6 +429,32 @@ The Phantom deeplink flow. |---|---|---|---| | Mint | text | no | | +### Token Info: Tile Tapped + +An action tile was tapped on Currency Info. It counts the tap, not whether the Send, Buy or Transactions screen completed. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Tile | list | no | [CurrencyInfoTile](#currencyinfotile) | +| Mint | text | no | | + +### Token Info: More Option Picked + +A row was picked in the More sheet on Currency Info. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Option | list | no | [CurrencyInfoMoreOption](#currencyinfomoreoption) | +| Mint | text | no | | + +### Token Info: Community Chat Tapped + +A community chat row was tapped on Currency Info, in the Top Community Chats preview or its View All list. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Mint | text | no | | + ## Swap Buying and selling a token. @@ -700,13 +801,14 @@ The `Provider` property on the Wallet events: the wallet app the flow goes throu ### TokenInfoSource -Where Token Info was opened from. Discovery, Chat and Chat Gate are new in part 1; Android previously sent Wallet for all three. Give and Send are iOS's, from the Add More Cash prompt on the Give and Send amount screens; the event names iOS sends today are unchanged. +Where Token Info was opened from. Discovery, Chat and Chat Gate are new in part 1; Android previously sent Wallet for all three. Give and Send are iOS's, from the Add More Cash prompt on the Give and Send amount screens; the event names iOS sends today are unchanged. Search is a result tapped on the Chats search screen. | Value sent | Name in code | Drift | |---|---|---| | Deeplink | `DEEPLINK` | | | Wallet | `WALLET` | | | Discovery | `DISCOVERY` | | +| Search | `SEARCH` | | | Chat | `CHAT` | | | 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. | | Give | `GIVE` | Android has no such flow: its Give screen sends Add Money: Opened with source Give Shortfall instead of opening Token Info. | @@ -723,6 +825,26 @@ The `Duration` property on Chat Muted: the mute option picked. Both apps offer t | 1 Week | `ONE_WEEK` | | | Always | `ALWAYS` | | +### SilenceDuration + +The `Duration` property on Group: Member Silenced: the silence option picked. Both apps offer these four. The last reads Forever here, where MuteDuration reads Always. + +| Value sent | Name in code | Drift | +|---|---|---| +| 1 Hour | `ONE_HOUR` | | +| 8 Hours | `EIGHT_HOURS` | | +| 1 Week | `ONE_WEEK` | | +| Forever | `FOREVER` | | + +### GroupType + +The `Type` property on Group: New Opened: which New Chat row was tapped. Private is Create a New Group; Community is Create a New Community Chat. + +| Value sent | Name in code | Drift | +|---|---|---| +| Private | `PRIVATE` | | +| Community | `COMMUNITY` | | + ### GroupField The `Field` property on Group: Edited: what the edit changed. @@ -779,6 +901,39 @@ The `Method` property on Group: Gate Funding Tapped: the funding button tapped o | Buy Token | `BUY_TOKEN` | | | Add Cash | `ADD_CASH` | | +### SearchResultType + +The `Result Type` property on Search: Result Tapped: the kind of result row. Chat covers direct messages and groups, and the recent chats shown before anything is typed. + +| Value sent | Name in code | Drift | +|---|---|---| +| Chat | `CHAT` | | +| Currency | `CURRENCY` | | +| Message | `MESSAGE` | | +| Link | `LINK` | | + +### CurrencyInfoTile + +The `Tile` property on Token Info: Tile Tapped: the action tile tapped on Currency Info. Which tiles show depends on whether the currency is held. + +| Value sent | Name in code | Drift | +|---|---|---| +| Send | `SEND` | | +| Buy | `BUY` | | +| Buy In | `BUY_IN` | | +| Transactions | `TRANSACTIONS` | | +| More | `MORE` | | + +### CurrencyInfoMoreOption + +The `Option` property on Token Info: More Option Picked: the row picked in the More sheet. + +| Value sent | Name in code | Drift | +|---|---|---| +| Convert | `CONVERT` | | +| Withdraw | `WITHDRAW` | | +| Deposit | `DEPOSIT` | | + ### PurchaseMethod How a token is paid for, sent in the event name `Token Purchase With {method}`. diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 54581db899..5c32db30a7 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -115,11 +115,12 @@ description = "The `Provider` property on the Wallet events: the wallet app the PHANTOM = "Phantom" [lists.TokenInfoSource] -description = "Where Token Info was opened from. Discovery, Chat and Chat Gate are new in part 1; Android previously sent Wallet for all three. Give and Send are iOS's, from the Add More Cash prompt on the Give and Send amount screens; the event names iOS sends today are unchanged." +description = "Where Token Info was opened from. Discovery, Chat and Chat Gate are new in part 1; Android previously sent Wallet for all three. Give and Send are iOS's, from the Add More Cash prompt on the Give and Send amount screens; the event names iOS sends today are unchanged. Search is a result tapped on the Chats search screen." [lists.TokenInfoSource.values] DEEPLINK = "Deeplink" WALLET = "Wallet" DISCOVERY = "Discovery" +SEARCH = "Search" CHAT = "Chat" 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." } GIVE = { value = "Give", drift = "Android has no such flow: its Give screen sends Add Money: Opened with source Give Shortfall instead of opening Token Info." } @@ -133,6 +134,20 @@ EIGHT_HOURS = "8 Hours" ONE_WEEK = "1 Week" ALWAYS = "Always" +[lists.SilenceDuration] +description = "The `Duration` property on Group: Member Silenced: the silence option picked. Both apps offer these four. The last reads Forever here, where MuteDuration reads Always." +[lists.SilenceDuration.values] +ONE_HOUR = "1 Hour" +EIGHT_HOURS = "8 Hours" +ONE_WEEK = "1 Week" +FOREVER = "Forever" + +[lists.GroupType] +description = "The `Type` property on Group: New Opened: which New Chat row was tapped. Private is Create a New Group; Community is Create a New Community Chat." +[lists.GroupType.values] +PRIVATE = "Private" +COMMUNITY = "Community" + [lists.GroupField] description = "The `Field` property on Group: Edited: what the edit changed." [lists.GroupField.values] @@ -171,6 +186,30 @@ description = "The `Method` property on Group: Gate Funding Tapped: the funding BUY_TOKEN = "Buy Token" ADD_CASH = "Add Cash" +[lists.SearchResultType] +description = "The `Result Type` property on Search: Result Tapped: the kind of result row. Chat covers direct messages and groups, and the recent chats shown before anything is typed." +[lists.SearchResultType.values] +CHAT = "Chat" +CURRENCY = "Currency" +MESSAGE = "Message" +LINK = "Link" + +[lists.CurrencyInfoTile] +description = "The `Tile` property on Token Info: Tile Tapped: the action tile tapped on Currency Info. Which tiles show depends on whether the currency is held." +[lists.CurrencyInfoTile.values] +SEND = "Send" +BUY = "Buy" +BUY_IN = "Buy In" +TRANSACTIONS = "Transactions" +MORE = "More" + +[lists.CurrencyInfoMoreOption] +description = "The `Option` property on Token Info: More Option Picked: the row picked in the More sheet." +[lists.CurrencyInfoMoreOption.values] +CONVERT = "Convert" +WITHDRAW = "Withdraw" +DEPOSIT = "Deposit" + [lists.PurchaseMethod] description = "How a token is paid for, sent in the event name `Token Purchase With {method}`." [lists.PurchaseMethod.values] @@ -265,6 +304,7 @@ MESSAGES = "Messages Received" [domains] Wallet = "The Phantom deeplink flow." +Search = "The Chats search screen." 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." @@ -324,7 +364,8 @@ properties = [ [[event]] domain = "Group" name = "Group: New Opened" -description = "The New Group form opened." +properties = [{ name = "Type", type = "GroupType", optional = true }] +description = "The New Group form opened. Type says whether the user tapped Create a New Group or Create a New Community Chat." [[event]] domain = "Group" @@ -421,6 +462,74 @@ properties = [ ] description = "The group info (profile) screen opened." +[[event]] +domain = "Group" +name = "Group: Invite Link Created" +properties = [ + { name = "State", type = "State" }, + { name = "Error", type = "text", optional = true }, +] +description = "CreateInvite returned for a private group's invite link, from New Link in Manage Invites or when Share Invite Link makes one because none exists." + +[[event]] +domain = "Group" +name = "Group: Invite Link Revoked" +properties = [ + { name = "State", type = "State" }, + { name = "Error", type = "text", optional = true }, +] +description = "RevokeInvite returned after the host confirmed Revoke Link." + +[[event]] +domain = "Group" +name = "Group: Join Request Approved" +properties = [ + { name = "State", type = "State" }, + { name = "Error", type = "text", optional = true }, +] +description = "A host tapped Approve on a join request and the admit call returned." + +[[event]] +domain = "Group" +name = "Group: Join Request Denied" +properties = [ + { name = "State", type = "State" }, + { name = "Error", type = "text", optional = true }, +] +description = "A host tapped Deny on a join request and the deny call returned." + +[[event]] +domain = "Group" +name = "Group: Member Silenced" +properties = [ + { name = "Duration", type = "SilenceDuration" }, + { name = "State", type = "State" }, + { name = "Error", type = "text", optional = true }, +] +description = "A host picked a silence option for a member and the call returned. The member can still read the group but can't send messages." + +[[event]] +domain = "Group" +name = "Group: Member Removed" +properties = [ + { name = "State", type = "State" }, + { name = "Error", type = "text", optional = true }, +] +description = "A host confirmed Remove on a member and the ban call returned. Both apps remove a member by banning them." + +# Search + +[[event]] +domain = "Search" +name = "Search: Opened" +description = "The Chats search screen opened." + +[[event]] +domain = "Search" +name = "Search: Result Tapped" +properties = [{ name = "Result Type", type = "SearchResultType" }] +description = "A result row was tapped on the Chats search screen. Tapping a section's See all is not a result." + # Transfers [[event]] @@ -576,6 +685,30 @@ builder = "opened" placeholders = { source = "TokenInfoSource" } properties = [{ name = "Mint", type = "text" }] +[[event]] +domain = "Token Info" +name = "Token Info: Tile Tapped" +properties = [ + { name = "Tile", type = "CurrencyInfoTile" }, + { name = "Mint", type = "text" }, +] +description = "An action tile was tapped on Currency Info. It counts the tap, not whether the Send, Buy or Transactions screen completed." + +[[event]] +domain = "Token Info" +name = "Token Info: More Option Picked" +properties = [ + { name = "Option", type = "CurrencyInfoMoreOption" }, + { name = "Mint", type = "text" }, +] +description = "A row was picked in the More sheet on Currency Info." + +[[event]] +domain = "Token Info" +name = "Token Info: Community Chat Tapped" +properties = [{ name = "Mint", type = "text" }] +description = "A community chat row was tapped on Currency Info, in the Top Community Chats preview or its View All list." + # Swap [[event]] 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 index a4ddca6a47..15bf8acc18 100644 --- a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/GroupEventsTest.kt +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/GroupEventsTest.kt @@ -7,8 +7,79 @@ import kotlin.test.assertEquals class GroupEventsTest { @Test - fun newOpenedCarriesNoProperties() { - assertEquals(AnalyticsEvent("Group: New Opened", emptyMap()), GroupEvents.newOpened()) + fun newOpenedLeavesOutAnAbsentType() { + assertEquals(AnalyticsEvent("Group: New Opened", emptyMap()), GroupEvents.newOpened(null)) + } + + @Test + fun newOpenedCarriesEveryTypeByItsWireString() { + assertEquals( + listOf("Private", "Community"), + GroupType.entries.map { + val event = GroupEvents.newOpened(it) + assertEquals(AnalyticsEvent("Group: New Opened", mapOf("Type" to event.properties.getValue("Type"))), event) + (event.properties.getValue("Type") as PropertyValue.Text).value + }, + ) + } + + @Test + fun inviteLinkCreatedAndRevokedCarryTheOutcome() { + assertEquals( + AnalyticsEvent("Group: Invite Link Created", mapOf("State" to PropertyValue.Text("Success"))), + GroupEvents.inviteLinkCreated(State.SUCCESS, null), + ) + assertEquals( + AnalyticsEvent( + "Group: Invite Link Revoked", + mapOf("State" to PropertyValue.Text("Failure"), "Error" to PropertyValue.Text("Denied")), + ), + GroupEvents.inviteLinkRevoked(State.FAILURE, "Denied"), + ) + } + + @Test + fun joinRequestApprovedAndDeniedCarryTheOutcome() { + assertEquals( + AnalyticsEvent("Group: Join Request Approved", mapOf("State" to PropertyValue.Text("Success"))), + GroupEvents.joinRequestApproved(State.SUCCESS, null), + ) + assertEquals( + AnalyticsEvent( + "Group: Join Request Denied", + mapOf("State" to PropertyValue.Text("Failure"), "Error" to PropertyValue.Text("Network")), + ), + GroupEvents.joinRequestDenied(State.FAILURE, "Network"), + ) + } + + @Test + fun memberSilencedCarriesEveryDurationByItsWireString() { + assertEquals( + listOf("1 Hour", "8 Hours", "1 Week", "Forever"), + SilenceDuration.entries.map { + val event = GroupEvents.memberSilenced(it, State.SUCCESS, null) + assertEquals( + AnalyticsEvent( + "Group: Member Silenced", + mapOf("Duration" to event.properties.getValue("Duration"), "State" to PropertyValue.Text("Success")), + ), + event, + ) + (event.properties.getValue("Duration") as PropertyValue.Text).value + }, + ) + } + + @Test + fun memberRemovedCarriesTheOutcome() { + assertEquals( + AnalyticsEvent( + "Group: Member Removed", + mapOf("State" to PropertyValue.Text("Failure"), "Error" to PropertyValue.Text("Denied")), + ), + GroupEvents.memberRemoved(State.FAILURE, "Denied"), + ) } @Test diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/SearchEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/SearchEventsTest.kt new file mode 100644 index 0000000000..08a541795a --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/SearchEventsTest.kt @@ -0,0 +1,25 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.SearchEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class SearchEventsTest { + + @Test + fun openedCarriesNoProperties() { + assertEquals(AnalyticsEvent("Search: Opened", emptyMap()), SearchEvents.opened()) + } + + @Test + fun resultTappedCarriesEveryResultTypeByItsWireString() { + assertEquals( + listOf("Chat", "Currency", "Message", "Link"), + SearchResultType.entries.map { + val event = SearchEvents.resultTapped(it) + assertEquals(AnalyticsEvent("Search: Result Tapped", mapOf("Result Type" to event.properties.getValue("Result Type"))), event) + (event.properties.getValue("Result Type") as PropertyValue.Text).value + }, + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TokenInfoEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TokenInfoEventsTest.kt index c5edcc1baf..c6345ec484 100644 --- a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TokenInfoEventsTest.kt +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TokenInfoEventsTest.kt @@ -15,6 +15,7 @@ class TokenInfoEventsTest { AnalyticsEvent("Token Info: Opened From Deeplink", mint), AnalyticsEvent("Token Info: Opened From Wallet", mint), AnalyticsEvent("Token Info: Opened From Discovery", mint), + AnalyticsEvent("Token Info: Opened From Search", mint), AnalyticsEvent("Token Info: Opened From Chat", mint), AnalyticsEvent("Token Info: Opened From Chat Gate", mint), AnalyticsEvent("Token Info: Opened From Give", mint), @@ -24,6 +25,7 @@ class TokenInfoEventsTest { TokenInfoSource.DEEPLINK, TokenInfoSource.WALLET, TokenInfoSource.DISCOVERY, + TokenInfoSource.SEARCH, TokenInfoSource.CHAT, TokenInfoSource.CHAT_GATE, TokenInfoSource.GIVE, @@ -31,4 +33,39 @@ class TokenInfoEventsTest { ).map { TokenInfoEvents.opened(it, "mint") }, ) } + + @Test + fun tileTappedCarriesEveryTileByItsWireString() { + assertEquals( + listOf("Send", "Buy", "Buy In", "Transactions", "More"), + CurrencyInfoTile.entries.map { + val event = TokenInfoEvents.tileTapped(it, "mint") + assertEquals( + AnalyticsEvent("Token Info: Tile Tapped", mapOf("Tile" to event.properties.getValue("Tile")) + mint), + event, + ) + (event.properties.getValue("Tile") as PropertyValue.Text).value + }, + ) + } + + @Test + fun moreOptionPickedCarriesEveryOptionByItsWireString() { + assertEquals( + listOf("Convert", "Withdraw", "Deposit"), + CurrencyInfoMoreOption.entries.map { + val event = TokenInfoEvents.moreOptionPicked(it, "mint") + assertEquals( + AnalyticsEvent("Token Info: More Option Picked", mapOf("Option" to event.properties.getValue("Option")) + mint), + event, + ) + (event.properties.getValue("Option") as PropertyValue.Text).value + }, + ) + } + + @Test + fun communityChatTappedCarriesTheMint() { + assertEquals(AnalyticsEvent("Token Info: Community Chat Tapped", mint), TokenInfoEvents.communityChatTapped("mint")) + } } From 80bf9b92a43d25d8328603ba883e97573643cf9a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 21:03:33 -0400 Subject: [PATCH 2/4] test(analytics): list Search among the Token Info sources --- .../spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift index 8f50c49fae..8420d5c434 100644 --- a/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift @@ -33,7 +33,7 @@ import Testing /// A value added in Kotlin compiles on both sides and would be silently missing from any /// Swift switch that lists the cases; this is the check that it is not. @Test func tokenInfoSourcesAreTheSpecNames() { - #expect(TokenInfoSource.entries.map(\.value) == ["Deeplink", "Wallet", "Discovery", "Chat", "Chat Gate", "Give", "Send"]) + #expect(TokenInfoSource.entries.map(\.value) == ["Deeplink", "Wallet", "Discovery", "Search", "Chat", "Chat Gate", "Give", "Send"]) } @Test func buildsAGroupEventWithAnOptionalLeftOut() { From 0ead9021b6a4816442efc967ac6c53060a2b26a0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 22:13:13 -0400 Subject: [PATCH 3/4] feat(analytics): send the group and discovery events Wire the events catalogued in this PR through FlipcashAnalytics: invite link created/revoked, join request approved/denied, member silenced/removed, Search opened/result tapped, Token Info opened from Search, Currency Info tile/more option/community chat taps, and Group: New Opened with Type on both rows (Private row previously sent nothing). Moderation outcomes report State and Error like Group: Created; the new error types map in chatResult. Add TokenInfoEntry.Search so Search currency results report source Search instead of Discovery. --- .../app/core/tokens/TokenInfoEntryTest.kt | 2 + .../app/core/tokens/TokenInfoEntry.kt | 9 +- .../app/messenger/internal/ChatViewModel.kt | 8 +- .../internal/GroupModerationEvents.kt | 65 +++++++++++ .../internal/MemberModerationViewModel.kt | 6 +- .../internal/SilenceUserViewModel.kt | 6 +- .../internal/invites/InviteLinkViewModel.kt | 5 +- .../invites/ManageInvitesViewModel.kt | 7 +- .../internal/ChatGroupAnalyticsTest.kt | 80 ++++++++++++- .../internal/MemberModerationAnalyticsTest.kt | 89 +++++++++++++++ .../internal/SilenceUserViewModelTest.kt | 45 +++++++- .../invites/InviteLinkViewModelTest.kt | 39 ++++++- .../invites/ManageInvitesViewModelTest.kt | 106 +++++++++++++++++- .../flipcash/features/search/build.gradle.kts | 3 + .../com/flipcash/app/search/SearchScreen.kt | 13 ++- .../app/search/internal/SearchViewModel.kt | 10 ++ .../internal/SearchViewModelAnalyticsTest.kt | 73 ++++++++++++ .../com/flipcash/app/tipping/NewChatScreen.kt | 8 +- .../flipcash/app/tokens/TokenInfoScreen.kt | 1 + .../tokens/internal/CommunityChatsScreen.kt | 4 + .../components/info/CurrencyInfoContentV2.kt | 7 ++ .../tokens/TokenInfoScreenAnalyticsTest.kt | 6 + .../flipcash/app/analytics/ConversionsTest.kt | 18 +++ .../com/flipcash/app/analytics/Conversions.kt | 23 +++- .../app/tokens/ui/CurrencyInfoEventsTest.kt | 42 +++++++ .../ui/TokenInfoViewModelChartLoadTest.kt | 1 + .../TokenInfoViewModelCommunityLeaderTest.kt | 1 + .../ui/TokenInfoViewModelConvertTest.kt | 51 +++++++++ .../app/tokens/ui/CurrencyInfoEvents.kt | 39 +++++++ .../app/tokens/ui/TokenInfoViewModel.kt | 5 + 30 files changed, 753 insertions(+), 19 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupModerationEvents.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MemberModerationAnalyticsTest.kt create mode 100644 apps/flipcash/features/search/src/test/kotlin/com/flipcash/app/search/internal/SearchViewModelAnalyticsTest.kt create mode 100644 apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/CurrencyInfoEventsTest.kt create mode 100644 apps/flipcash/shared/tokens/src/androidMain/kotlin/com/flipcash/app/tokens/ui/CurrencyInfoEvents.kt diff --git a/apps/flipcash/core/src/androidHostTest/kotlin/com/flipcash/app/core/tokens/TokenInfoEntryTest.kt b/apps/flipcash/core/src/androidHostTest/kotlin/com/flipcash/app/core/tokens/TokenInfoEntryTest.kt index 7eb2c4ba57..ed4111710e 100644 --- a/apps/flipcash/core/src/androidHostTest/kotlin/com/flipcash/app/core/tokens/TokenInfoEntryTest.kt +++ b/apps/flipcash/core/src/androidHostTest/kotlin/com/flipcash/app/core/tokens/TokenInfoEntryTest.kt @@ -26,6 +26,7 @@ class TokenInfoEntryTest { TokenInfoEntry.Wallet to TokenInfoPresentation.CardExpand, TokenInfoEntry.Deeplink to TokenInfoPresentation.CardExpand, TokenInfoEntry.Discovery to TokenInfoPresentation.Push, + TokenInfoEntry.Search to TokenInfoPresentation.Push, TokenInfoEntry.Chat to TokenInfoPresentation.Push, TokenInfoEntry.ChatGate to TokenInfoPresentation.Push, ), @@ -40,6 +41,7 @@ class TokenInfoEntryTest { TokenInfoEntry.Wallet to TokenInfoSwapOutcome.Stay, TokenInfoEntry.Deeplink to TokenInfoSwapOutcome.Stay, TokenInfoEntry.Discovery to TokenInfoSwapOutcome.Stay, + TokenInfoEntry.Search to TokenInfoSwapOutcome.Stay, TokenInfoEntry.Chat to TokenInfoSwapOutcome.Stay, TokenInfoEntry.ChatGate to TokenInfoSwapOutcome.Pop, ), diff --git a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/tokens/TokenInfoEntry.kt b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/tokens/TokenInfoEntry.kt index 8cda25dec3..a899d924a3 100644 --- a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/tokens/TokenInfoEntry.kt +++ b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/tokens/TokenInfoEntry.kt @@ -37,6 +37,13 @@ sealed interface TokenInfoEntry : Parcelable { override val afterBuy get() = AfterBuy.Stay } + /** A currency result tapped on the Chats search screen. */ + @Serializable + data object Search : TokenInfoEntry { + override val presentation get() = TokenInfoPresentation.Push + override val afterBuy get() = AfterBuy.Stay + } + /** A currency mentioned in a chat transcript. */ @Serializable data object Chat : TokenInfoEntry { @@ -56,7 +63,7 @@ sealed interface TokenInfoEntry : Parcelable { companion object { /** Every origin. Covered by a test against the serializer's subclass list. */ - val all: List = listOf(Wallet, Deeplink, Discovery, Chat, ChatGate) + val all: List = listOf(Wallet, Deeplink, Discovery, Search, Chat, ChatGate) } } 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 c4a07f973f..cd96fced4d 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 @@ -3295,7 +3295,9 @@ internal class ChatViewModel @AssistedInject constructor( if (userId in stateFlow.value.joinRequestCalls) return@onEach dispatchEvent(Event.JoinRequestCallChanged(userId, JoinRequestCall.Approve)) viewModelScope.launch { - hostLobbies.approve(chatId, event.member).onFailure { + hostLobbies.approve(chatId, event.member).also { + analytics.trackJoinRequestApproved(it) + }.onFailure { trace("failed to admit lobby member - ${it.localizedMessage}") BottomBarManager.showError( title = resources.getString(R.string.error_title_failedToApproveJoinRequest), @@ -3314,7 +3316,9 @@ internal class ChatViewModel @AssistedInject constructor( if (userId in stateFlow.value.joinRequestCalls) return@onEach dispatchEvent(Event.JoinRequestCallChanged(userId, JoinRequestCall.Deny)) viewModelScope.launch { - hostLobbies.deny(chatId, userId).onFailure { + hostLobbies.deny(chatId, userId).also { + analytics.trackJoinRequestDenied(it) + }.onFailure { trace("failed to deny lobby member - ${it.localizedMessage}") BottomBarManager.showError( title = resources.getString(R.string.error_title_failedToDenyJoinRequest), diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupModerationEvents.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupModerationEvents.kt new file mode 100644 index 0000000000..79cf2f18c8 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupModerationEvents.kt @@ -0,0 +1,65 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.analytics.SilenceDuration +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.GroupEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.chatResult +import com.flipcash.shared.chat.InviteCreation + +private fun Result<*>.state() = if (isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE + +private fun Result<*>.error() = exceptionOrNull()?.chatResult + +/** + * `Group: Invite Link Created` for the answer CreateInvite gave. [InviteCreation] folds the + * server's TOO_MANY_INVITES and DENIED into its own cases, so they are named again here. + */ +internal fun FlipcashAnalytics.trackInviteLinkCreated(creation: InviteCreation) { + val (state, error) = when (creation) { + is InviteCreation.Created -> AnalyticsState.SUCCESS to null + InviteCreation.TooMany -> AnalyticsState.FAILURE to "TooManyInvites" + InviteCreation.NotAvailable -> AnalyticsState.FAILURE to "Denied" + is InviteCreation.Failed -> AnalyticsState.FAILURE to creation.cause.chatResult + } + track(GroupEvents.inviteLinkCreated(state = state, error = error)) +} + +/** `Group: Invite Link Revoked` for the answer RevokeInvite gave, after the host confirmed. */ +internal fun FlipcashAnalytics.trackInviteLinkRevoked(result: Result<*>) { + track(GroupEvents.inviteLinkRevoked(state = result.state(), error = result.error())) +} + +/** `Group: Join Request Approved` for the answer AdmitLobbyMember gave. */ +internal fun FlipcashAnalytics.trackJoinRequestApproved(result: Result<*>) { + track(GroupEvents.joinRequestApproved(state = result.state(), error = result.error())) +} + +/** `Group: Join Request Denied` for the answer DenyLobbyMember gave. */ +internal fun FlipcashAnalytics.trackJoinRequestDenied(result: Result<*>) { + track(GroupEvents.joinRequestDenied(state = result.state(), error = result.error())) +} + +/** `Group: Member Silenced` for the answer SilenceUser gave to the option picked. */ +internal fun FlipcashAnalytics.trackMemberSilenced(option: SilenceOption, result: Result<*>) { + track( + GroupEvents.memberSilenced( + duration = option.analytics, + state = result.state(), + error = result.error(), + ) + ) +} + +/** `Group: Member Removed` for the answer BanUser gave after the host confirmed Remove. */ +internal fun FlipcashAnalytics.trackMemberRemoved(result: Result<*>) { + track(GroupEvents.memberRemoved(state = result.state(), error = result.error())) +} + +private val SilenceOption.analytics: SilenceDuration + get() = when (this) { + SilenceOption.OneHour -> SilenceDuration.ONE_HOUR + SilenceOption.EightHours -> SilenceDuration.EIGHT_HOURS + SilenceOption.OneWeek -> SilenceDuration.ONE_WEEK + SilenceOption.Forever -> SilenceDuration.FOREVER + } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MemberModerationViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MemberModerationViewModel.kt index 50ad3070e7..1c4d28cb42 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MemberModerationViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MemberModerationViewModel.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.features.messenger.R import com.flipcash.services.controllers.ChatController import com.flipcash.shared.chat.GroupRosters @@ -27,6 +28,7 @@ internal class MemberModerationViewModel @Inject constructor( private val chatController: ChatController, private val resources: ResourceHelper, private val groupRosters: GroupRosters, + private val analytics: FlipcashAnalytics, ) : ViewModel() { /** @@ -52,7 +54,9 @@ internal class MemberModerationViewModel @Inject constructor( viewModelScope.launch { // The alert closes on the tap and this scope can go with the screen before the answer lands. withContext(NonCancellable) { - chatController.banUser(chatId, userId).onSuccess { + chatController.banUser(chatId, userId).also { + analytics.trackMemberRemoved(it) + }.onSuccess { groupRosters.remove(chatId, userId) }.onFailure { trace("failed to remove member - ${it.localizedMessage}") diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SilenceUserViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SilenceUserViewModel.kt index f6e2684264..a07b45ac9e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SilenceUserViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SilenceUserViewModel.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.toast.SystemToastController import com.flipcash.features.messenger.R import com.flipcash.services.controllers.ChatController @@ -28,13 +29,16 @@ internal class SilenceUserViewModel @Inject constructor( private val chatController: ChatController, private val resources: ResourceHelper, private val toastController: SystemToastController, + private val analytics: FlipcashAnalytics, ) : ViewModel() { fun silence(chatId: ChatId, userId: ID, firstName: String, option: SilenceOption) { viewModelScope.launch { // Scoped to the sheet's entry, which is gone by the time the answer lands. withContext(NonCancellable) { - chatController.silenceUser(chatId, userId, option.toSilenceState()).onSuccess { + chatController.silenceUser(chatId, userId, option.toSilenceState()).also { + analytics.trackMemberSilenced(option, it) + }.onSuccess { toastController.showToast(option.toastRes, firstName, replacePrevious = true) }.onFailure { trace("failed to silence user - ${it.localizedMessage}") diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt index 0232384f00..6e3014cbc6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt @@ -3,7 +3,9 @@ package com.flipcash.app.messenger.internal.invites import android.content.ClipboardManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.extensions.setSensitiveText +import com.flipcash.app.messenger.internal.trackInviteLinkCreated import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.HostInvite @@ -63,6 +65,7 @@ internal class InviteLinkViewModel @Inject constructor( private val hostInvites: HostInvites, private val resources: ResourceHelper, private val clipboardManager: ClipboardManager, + private val analytics: FlipcashAnalytics, ) : ViewModel() { enum class Action { Share, Copy, Send } @@ -160,7 +163,7 @@ internal class InviteLinkViewModel @Inject constructor( } private suspend fun create(chatId: ChatId) { - when (val result = hostInvites.currentOrCreate(chatId)) { + when (val result = hostInvites.currentOrCreate(chatId).also { analytics.trackInviteLinkCreated(it) }) { is InviteCreation.Created -> { val link = result.invite.toState() _state.update { it.copy(link = link) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt index 9580f6fe5e..e43cddcccc 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt @@ -2,6 +2,9 @@ package com.flipcash.app.messenger.internal.invites import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.messenger.internal.trackInviteLinkCreated +import com.flipcash.app.messenger.internal.trackInviteLinkRevoked import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.HostInvite @@ -28,6 +31,7 @@ import kotlin.time.Clock internal class ManageInvitesViewModel @Inject constructor( private val hostInvites: HostInvites, private val resources: ResourceHelper, + private val analytics: FlipcashAnalytics, ) : ViewModel() { data class State( @@ -88,6 +92,7 @@ internal class ManageInvitesViewModel @Inject constructor( val chatId = chatId ?: return viewModelScope.launch { hostInvites.revoke(chatId, invite.invite.id) + .also { analytics.trackInviteLinkRevoked(it) } .onSuccess { publish(chatId) } .onFailure { error -> if (error.isLostInviteAccess()) { @@ -108,7 +113,7 @@ internal class ManageInvitesViewModel @Inject constructor( if (_state.value.creating) return _state.update { it.copy(creating = true) } viewModelScope.launch { - when (val result = hostInvites.create(chatId, settings)) { + when (val result = hostInvites.create(chatId, settings).also { analytics.trackInviteLinkCreated(it) }) { is InviteCreation.Created -> { _state.update { it.copy(creating = false) } publish(chatId) 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 201137c91e..765ebfff99 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 @@ -6,6 +6,7 @@ import com.flipcash.analytics.GroupGateFunding import com.flipcash.analytics.GroupInviteSheetSource import com.flipcash.analytics.PropertyValue import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.GroupEvents import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.persistence.sources.UserProfileDataSource @@ -19,7 +20,10 @@ import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.TestDispatcherProvider import com.flipcash.services.chat.E2eePolicy import com.flipcash.services.models.JoinChatError +import com.flipcash.services.models.AdmitLobbyMemberError +import com.flipcash.services.models.DenyLobbyMemberError import com.flipcash.services.models.LeaveChatError +import com.flipcash.services.models.chat.LobbyMember import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatRuleRequirement @@ -30,6 +34,7 @@ import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.FeaturedGroupsStore import com.flipcash.shared.chat.GroupAccess +import com.flipcash.shared.chat.HostLobbies import com.flipcash.shared.payments.ContactPaymentDelegate import com.flipcash.shared.payments.TipPaymentDelegate import com.getcode.libs.emojis.reactions.EmojiCatalogLoader @@ -108,6 +113,7 @@ class ChatGroupAnalyticsTest { private val recentReactionsStore = mockk(relaxed = true) private val emojiCatalogLoader = mockk(relaxed = true) private val userProfileDataSource = mockk(relaxed = true) + private val hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) } private val chatId = ChatId(UUID.randomUUID().bytes) private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa") @@ -169,7 +175,7 @@ class ChatGroupAnalyticsTest { rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), lobbyRequests = mockk(relaxed = true), - hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + hostLobbies = hostLobbies, groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, @@ -315,4 +321,76 @@ class ChatGroupAnalyticsTest { val event = analytics.events.single { it.name == "Group: Invite Followed" } assertEquals(text("Chat Card"), event.properties["Source"]) } + + private fun lobbyMember(): LobbyMember = mockk(relaxed = true) { + every { userProfile.userId } returns listOf(9) + } + + @Test + fun `an approved request reports Join Request Approved as a success`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { hostLobbies.approve(any(), any()) } returns Result.success(Unit) + + val vm = createViewModel() + vm.openGroup(isMember = true) + vm.dispatchEvent(ChatViewModel.Event.ApproveJoinRequest(lobbyMember())) + advanceUntilIdle() + + assertEquals( + listOf(GroupEvents.joinRequestApproved(state = AnalyticsState.SUCCESS, error = null)), + analytics.events.filter { it.name.startsWith("Group: Join Request") }, + ) + } + + @Test + fun `a failed approve reports the result name`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { hostLobbies.approve(any(), any()) } returns Result.failure(AdmitLobbyMemberError.Denied()) + + val vm = createViewModel() + vm.openGroup(isMember = true) + vm.dispatchEvent(ChatViewModel.Event.ApproveJoinRequest(lobbyMember())) + advanceUntilIdle() + + assertEquals( + listOf(GroupEvents.joinRequestApproved(state = AnalyticsState.FAILURE, error = "Denied")), + analytics.events.filter { it.name.startsWith("Group: Join Request") }, + ) + } + + @Test + fun `a denied request reports Join Request Denied, and a failure carries its error`() = + runTest(mainCoroutineRule.dispatcher) { + coEvery { hostLobbies.deny(any(), any()) } returnsMany listOf( + Result.success(Unit), + Result.failure(DenyLobbyMemberError.Other(RuntimeException("UNAVAILABLE"))), + ) + + val vm = createViewModel() + vm.openGroup(isMember = true) + vm.dispatchEvent(ChatViewModel.Event.DenyJoinRequest(lobbyMember())) + advanceUntilIdle() + vm.dispatchEvent(ChatViewModel.Event.DenyJoinRequest(lobbyMember())) + advanceUntilIdle() + + assertEquals( + listOf( + GroupEvents.joinRequestDenied(state = AnalyticsState.SUCCESS, error = null), + GroupEvents.joinRequestDenied(state = AnalyticsState.FAILURE, error = "Network"), + ), + analytics.events.filter { it.name.startsWith("Group: Join Request") }, + ) + } + + @Test + fun `a request with no user id sends nothing`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.openGroup(isMember = true) + vm.dispatchEvent( + ChatViewModel.Event.ApproveJoinRequest( + mockk(relaxed = true) { every { userProfile.userId } returns null } + ) + ) + advanceUntilIdle() + + assertTrue(analytics.events.none { it.name.startsWith("Group: Join Request") }) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MemberModerationAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MemberModerationAnalyticsTest.kt new file mode 100644 index 0000000000..56759d542b --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MemberModerationAnalyticsTest.kt @@ -0,0 +1,89 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.GroupEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.BanUserError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.GroupRosters +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** `Group: Member Removed` follows the ban call, and only after the host confirms. */ +@OptIn(ExperimentalCoroutinesApi::class) +class MemberModerationAnalyticsTest { + + private val chatController = mockk(relaxed = true) + private val resources = mockk(relaxed = true) { + every { getString(any()) } returns "text" + every { getString(any(), any()) } returns "text" + } + private val groupRosters = mockk(relaxed = true) + private val analytics = RecordingAnalytics() + private val chatId = ChatId(ByteArray(32) { 3 }.toList()) + private val userId = listOf(1) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + BottomBarManager.clear() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + BottomBarManager.clear() + } + + private fun viewModel() = MemberModerationViewModel(chatController, resources, groupRosters, analytics) + + private fun MemberModerationViewModel.confirm() { + confirmRemove(chatId, userId, "Ada", "Group") + BottomBarManager.messages.value.single().actions.single().onClick() + } + + @Test + fun `a removed member sends Member Removed as a success`() { + coEvery { chatController.banUser(chatId, userId) } returns Result.success(Unit) + + viewModel().confirm() + + assertEquals( + listOf(GroupEvents.memberRemoved(state = AnalyticsState.SUCCESS, error = null)), + analytics.events, + ) + } + + @Test + fun `a failed ban sends Member Removed with the error`() { + coEvery { chatController.banUser(chatId, userId) } returns Result.failure(BanUserError.NotFound()) + + viewModel().confirm() + + assertEquals( + listOf(GroupEvents.memberRemoved(state = AnalyticsState.FAILURE, error = "NotFound")), + analytics.events, + ) + } + + @Test + fun `asking to remove sends nothing until the host confirms`() { + viewModel().confirmRemove(chatId, userId, "Ada", "Group") + + assertTrue(analytics.events.isEmpty()) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SilenceUserViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SilenceUserViewModelTest.kt index 5b9a58c721..266162b929 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SilenceUserViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SilenceUserViewModelTest.kt @@ -1,6 +1,11 @@ package com.flipcash.app.messenger.internal +import com.flipcash.analytics.SilenceDuration +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.GroupEvents +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.core.toast.SystemToastController +import com.flipcash.services.models.SilenceUserError import com.flipcash.features.messenger.R import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.chat.ChatId @@ -17,6 +22,7 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before import org.junit.Test +import kotlin.test.assertEquals /** The confirmation toast follows the server's answer: it names the member and the length picked, and a failure leaves it to the error bar. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -24,6 +30,7 @@ class SilenceUserViewModelTest { private val chatController = mockk(relaxed = true) private val resources = mockk(relaxed = true) + private val analytics = RecordingAnalytics() private val toasts = mockk(relaxed = true) private val chatId = ChatId(ByteArray(32) { 3 }.toList()) private val userId = listOf(1) @@ -40,7 +47,7 @@ class SilenceUserViewModelTest { BottomBarManager.clear() } - private fun viewModel() = SilenceUserViewModel(chatController, resources, toasts) + private fun viewModel() = SilenceUserViewModel(chatController, resources, toasts, analytics) @Test fun `each option toasts its own length after a successful silence`() { @@ -62,4 +69,40 @@ class SilenceUserViewModelTest { verify(exactly = 0) { toasts.showToast(any(), *anyVararg(), replacePrevious = any()) } } + + @Test + fun `each option sends Member Silenced with its own duration`() { + coEvery { chatController.silenceUser(chatId, userId, any()) } returns Result.success(Unit) + + SilenceOption.entries.forEach { viewModel().silence(chatId, userId, "Ada", it) } + + assertEquals( + listOf( + SilenceDuration.ONE_HOUR, + SilenceDuration.EIGHT_HOURS, + SilenceDuration.ONE_WEEK, + SilenceDuration.FOREVER, + ).map { GroupEvents.memberSilenced(duration = it, state = AnalyticsState.SUCCESS, error = null) }, + analytics.events, + ) + } + + @Test + fun `a failed silence sends Member Silenced with the error`() { + coEvery { chatController.silenceUser(chatId, userId, any()) } returns + Result.failure(SilenceUserError.CannotRestrictCreator()) + + viewModel().silence(chatId, userId, "Ada", SilenceOption.OneWeek) + + assertEquals( + listOf( + GroupEvents.memberSilenced( + duration = SilenceDuration.ONE_WEEK, + state = AnalyticsState.FAILURE, + error = "CannotRestrictCreator", + ) + ), + analytics.events, + ) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt index f4507c1606..17cb7f5623 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt @@ -1,5 +1,8 @@ package com.flipcash.app.messenger.internal.invites +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.GroupEvents +import com.flipcash.app.analytics.RecordingAnalytics import android.content.ClipboardManager import android.content.Context import androidx.test.core.app.ApplicationProvider @@ -71,7 +74,9 @@ class InviteLinkViewModelTest { Dispatchers.resetMain() } - private fun viewModel() = InviteLinkViewModel(hostInvites, resources, clipboard) + private val analytics = RecordingAnalytics() + + private fun viewModel() = InviteLinkViewModel(hostInvites, resources, clipboard, analytics) private var seed = 1 private fun hostInvite(url: String?, useCount: Long = 0, maxUses: Long = 0): HostInvite { @@ -275,4 +280,36 @@ class InviteLinkViewModelTest { assertFalse(InviteLinkViewModel.Effect.Share("https://x/chat/invite#s=secret").toString().contains("secret")) assertFalse(InviteLinkViewModel.Effect.Send("https://x/chat/invite#s=secret").toString().contains("secret")) } + + @Test + fun `making the link because none exists sends Invite Link Created`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } returns + InviteCreation.Created(hostInvite("https://x/chat/invite#s=new")) + val model = viewModel() + model.open(chatId, null) + + assertEquals( + listOf(GroupEvents.inviteLinkCreated(state = AnalyticsState.SUCCESS, error = null)), + analytics.events, + ) + } + + @Test + fun `a refused create sends Invite Link Created with its error`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.TooMany + viewModel().open(chatId, null) + + assertEquals( + listOf(GroupEvents.inviteLinkCreated(state = AnalyticsState.FAILURE, error = "TooManyInvites")), + analytics.events, + ) + } + + @Test + fun `opening on a link that exists makes none and sends nothing`() = runTest { + every { hostInvites.current(chatId) } returns hostInvite("https://x/chat/invite#s=a") + viewModel().open(chatId, null) + + assertTrue(analytics.events.isEmpty()) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt index 8d401127d6..dd6e1c7bd4 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt @@ -1,7 +1,11 @@ package com.flipcash.app.messenger.internal.invites +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.GroupEvents +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.CreateInviteError import com.flipcash.services.models.GetChatInvitesError import com.flipcash.services.models.RevokeInviteError import com.flipcash.services.models.chat.InviteId @@ -43,6 +47,7 @@ class ManageInvitesViewModelTest { private val resources = mockk(relaxed = true) { every { getString(any()) } returns "text" } + private val analytics = RecordingAnalytics() private val listed = MutableStateFlow>>(emptyMap()) @Before @@ -60,7 +65,7 @@ class ManageInvitesViewModelTest { Dispatchers.resetMain() } - private fun viewModel() = ManageInvitesViewModel(hostInvites, resources) + private fun viewModel() = ManageInvitesViewModel(hostInvites, resources, analytics) private var seed = 0 private fun hostInvite( @@ -286,4 +291,103 @@ class ManageInvitesViewModelTest { assertFalse(model.state.value.failed) assertTrue(model.state.value.isEmpty) } + + @Test + fun `a created link sends Invite Link Created as a success`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + coEvery { hostInvites.create(chatId, any()) } returns InviteCreation.Created(hostInvite()) + val model = viewModel() + model.open(chatId) + + model.create(InviteSettings.Default) + + assertEquals( + listOf(GroupEvents.inviteLinkCreated(state = AnalyticsState.SUCCESS, error = null)), + analytics.events, + ) + } + + @Test + fun `each way a create fails sends Invite Link Created with its error`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + val model = viewModel() + model.open(chatId) + + coEvery { hostInvites.create(chatId, any()) } returns InviteCreation.TooMany + model.create(InviteSettings.Default) + coEvery { hostInvites.create(chatId, any()) } returns InviteCreation.NotAvailable + model.create(InviteSettings.Default) + coEvery { hostInvites.create(chatId, any()) } returns InviteCreation.Failed(CreateInviteError.Unrecognized()) + model.create(InviteSettings.Default) + coEvery { hostInvites.create(chatId, any()) } returns InviteCreation.Failed(IllegalStateException("offline")) + model.create(InviteSettings.Default) + + assertEquals( + listOf("TooManyInvites", "Denied", "Unrecognized", "Network").map { + GroupEvents.inviteLinkCreated(state = AnalyticsState.FAILURE, error = it) + }, + analytics.events, + ) + } + + @Test + fun `a create still in flight sends nothing yet`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + coEvery { hostInvites.create(chatId, any()) } coAnswers { kotlinx.coroutines.awaitCancellation() } + val model = viewModel() + model.open(chatId) + + model.create(InviteSettings.Default) + model.create(InviteSettings.Default) + + assertTrue(analytics.events.isEmpty()) + } + + @Test + fun `a confirmed revoke sends Invite Link Revoked as a success`() = runTest { + val active = hostInvite() + listed.value = mapOf(chatId to listOf(active)) + coEvery { hostInvites.load(chatId) } returns Result.success(listOf(active)) + coEvery { hostInvites.revoke(chatId, active.invite.id) } returns Result.success(Unit) + val model = viewModel() + model.open(chatId) + + model.confirmRevoke(active) + BottomBarManager.messages.value.single().actions.single().onClick() + + assertEquals( + listOf(GroupEvents.inviteLinkRevoked(state = AnalyticsState.SUCCESS, error = null)), + analytics.events, + ) + } + + @Test + fun `a failed revoke sends Invite Link Revoked with the error`() = runTest { + val active = hostInvite() + listed.value = mapOf(chatId to listOf(active)) + coEvery { hostInvites.load(chatId) } returns Result.success(listOf(active)) + coEvery { hostInvites.revoke(chatId, active.invite.id) } returns Result.failure(RevokeInviteError.NotFound()) + val model = viewModel() + model.open(chatId) + + model.confirmRevoke(active) + BottomBarManager.messages.value.single().actions.single().onClick() + + assertEquals( + listOf(GroupEvents.inviteLinkRevoked(state = AnalyticsState.FAILURE, error = "NotFound")), + analytics.events, + ) + } + + @Test + fun `asking to revoke sends nothing until the host confirms`() = runTest { + val active = hostInvite() + coEvery { hostInvites.load(chatId) } returns Result.success(listOf(active)) + val model = viewModel() + model.open(chatId) + + model.confirmRevoke(active) + + assertTrue(analytics.events.isEmpty()) + } } diff --git a/apps/flipcash/features/search/build.gradle.kts b/apps/flipcash/features/search/build.gradle.kts index 4d137880ef..25b29eb361 100644 --- a/apps/flipcash/features/search/build.gradle.kts +++ b/apps/flipcash/features/search/build.gradle.kts @@ -10,9 +10,12 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) testImplementation(libs.robolectric) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) + testImplementation(testFixtures(project(":libs:coroutines"))) implementation(project(":services:flipcash")) implementation(project(":services:opencode")) + implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:chat-ui")) implementation(project(":apps:flipcash:shared:persistence:sources")) diff --git a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/SearchScreen.kt b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/SearchScreen.kt index 06c7cd7de4..c7a6e925c3 100644 --- a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/SearchScreen.kt +++ b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/SearchScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.runtime.remember import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.analytics.SearchResultType import com.flipcash.app.core.AppRoute import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.links.ExternalLinkUriHandler @@ -58,6 +59,8 @@ fun SearchScreen( results = results, onSelectChip = viewModel::selectChip, onOpenChat = { chatId, messageId -> + // A message hit carries its message id; chat rows and recent chats do not. + viewModel.resultTapped(if (messageId != null) SearchResultType.MESSAGE else SearchResultType.CHAT) viewModel.opened(chatId) navigator.push( AppRoute.Messaging.Chat( @@ -67,17 +70,21 @@ fun SearchScreen( ) }, onOpenCurrency = { token -> + viewModel.resultTapped(SearchResultType.CURRENCY) // Switch to the Money tab and push the currency there, so Search is left behind // (a tab switch replaces the stack) and back lands on the wallet. Discovery is the - // push-presented entry; Wallet would draw the card-expand ✕ with no card to shrink to. + // push-presented entry like Discovery; Wallet would draw the card-expand ✕ with no card to shrink to. navigator.navigateAll( listOf( AppRoute.Tabs.Wallet, - AppRoute.Token.Info(token.address, TokenInfoEntry.Discovery), + AppRoute.Token.Info(token.address, TokenInfoEntry.Search), ) ) }, - onOpenLink = { link -> externalLinks.openUri(link.url) }, + onOpenLink = { link -> + viewModel.resultTapped(SearchResultType.LINK) + externalLinks.openUri(link.url) + }, onClose = { navigator.pop() }, close = close, field = field, diff --git a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchViewModel.kt b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchViewModel.kt index 27171650cc..23e411a865 100644 --- a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchViewModel.kt +++ b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchViewModel.kt @@ -6,6 +6,9 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.SearchResultType +import com.flipcash.analytics.events.SearchEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.libs.coroutines.DispatcherProvider @@ -56,6 +59,7 @@ internal class SearchViewModel @Inject constructor( messageDataSource: ChatMessageDataSource, private val currencySearch: CurrencySearch, private val recentStore: RecentSearchStore, + private val analytics: FlipcashAnalytics, ) : ViewModel() { private val inviteLinkLabel = resources.getString(R.string.label_inviteLinkPreview) @@ -139,6 +143,7 @@ internal class SearchViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.Eagerly, null) init { + analytics.track(SearchEvents.opened()) // A cleared query starts the next search from All, whichever chip the last one ended on. viewModelScope.launch { snapshotFlow { query.text.toString() } @@ -161,6 +166,11 @@ internal class SearchViewModel @Inject constructor( fun selectChip(chip: SearchChip) = _chip.update { chip } + /** `Search: Result Tapped` for the row kind tapped, sent at the tap and before anything opens. */ + fun resultTapped(type: SearchResultType) { + analytics.track(SearchEvents.resultTapped(type)) + } + /** Records [chatId] as a recent before the screen opens it. */ fun opened(chatId: ChatId) { viewModelScope.launch { recentStore.record(chatId) } diff --git a/apps/flipcash/features/search/src/test/kotlin/com/flipcash/app/search/internal/SearchViewModelAnalyticsTest.kt b/apps/flipcash/features/search/src/test/kotlin/com/flipcash/app/search/internal/SearchViewModelAnalyticsTest.kt new file mode 100644 index 0000000000..a433098df9 --- /dev/null +++ b/apps/flipcash/features/search/src/test/kotlin/com/flipcash/app/search/internal/SearchViewModelAnalyticsTest.kt @@ -0,0 +1,73 @@ +package com.flipcash.app.search.internal + +import com.flipcash.analytics.SearchResultType +import com.flipcash.analytics.events.SearchEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.libs.coroutines.TestDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +/** `Search: Opened` is sent once when the screen's view model starts, and each tap sends its own result type. */ +@OptIn(ExperimentalCoroutinesApi::class) +class SearchViewModelAnalyticsTest { + + private val analytics = RecordingAnalytics() + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel(): SearchViewModel { + val recentStore = mockk(relaxed = true) { + every { recents } returns MutableStateFlow(emptyList()) + } + return SearchViewModel( + exchange = mockk(relaxed = true), + chatCoordinator = mockk(relaxed = true), + userManager = mockk(relaxed = true), + tokenCoordinator = mockk(relaxed = true), + resources = mockk(relaxed = true), + dispatchers = TestDispatcherProvider(dispatcher), + messageDataSource = mockk(relaxed = true), + currencySearch = mockk(relaxed = true), + recentStore = recentStore, + analytics = analytics, + ) + } + + @Test + fun `opening Search sends Search Opened once and nothing else`() { + viewModel() + + assertEquals(listOf(SearchEvents.opened()), analytics.events) + } + + @Test + fun `each result type sends Result Tapped after Opened`() { + val model = viewModel() + + SearchResultType.entries.forEach(model::resultTapped) + + assertEquals( + listOf(SearchEvents.opened()) + SearchResultType.entries.map { SearchEvents.resultTapped(it) }, + analytics.events, + ) + } +} 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 041f6d5fec..a2187953c0 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 @@ -13,6 +13,7 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.analytics.GroupType import com.flipcash.analytics.events.GroupEvents import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute @@ -63,7 +64,10 @@ fun NewChatScreen() { ChoiceRow( label = stringResource(R.string.action_createPrivateGroup), icon = R.drawable.ic_group_3, - onClick = { navigator.push(AppRoute.Messaging.NewGroup(isPrivate = true)) }, + onClick = { + analytics.track(GroupEvents.newOpened(GroupType.PRIVATE)) + navigator.push(AppRoute.Messaging.NewGroup(isPrivate = true)) + }, ) ChoiceRow( label = stringResource(R.string.action_createPublicGroup), @@ -74,7 +78,7 @@ fun NewChatScreen() { if (hasCommunityCurrency == false) { presentNoCommunityCurrencies(resources, navigator) } else { - analytics.track(GroupEvents.newOpened(null)) + analytics.track(GroupEvents.newOpened(GroupType.COMMUNITY)) navigator.push(AppRoute.Messaging.NewGroup()) } }, diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index 463133048a..bd9c1cb876 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -222,6 +222,7 @@ internal fun TokenInfoEntry.toTokenInfoSource(): TokenInfoSource = TokenInfoEntry.Deeplink -> TokenInfoSource.DEEPLINK TokenInfoEntry.Wallet -> TokenInfoSource.WALLET TokenInfoEntry.Discovery -> TokenInfoSource.DISCOVERY + TokenInfoEntry.Search -> TokenInfoSource.SEARCH TokenInfoEntry.Chat -> TokenInfoSource.CHAT TokenInfoEntry.ChatGate -> TokenInfoSource.CHAT_GATE } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt index dae89ed7be..164fd48963 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt @@ -14,7 +14,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.foundation.layout.Arrangement import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute +import com.flipcash.app.tokens.ui.trackCommunityChatTapped import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.tokens.internal.components.info.CommunityChatRow import com.flipcash.app.tokens.ui.CommunityChatsViewModel @@ -31,6 +33,7 @@ fun CommunityChatsScreen(mint: Mint) { val viewModel = hiltViewModel() val state by viewModel.state.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current + val analytics = rememberAnalytics() LaunchedEffect(viewModel, mint) { viewModel.load(mint) } @@ -60,6 +63,7 @@ fun CommunityChatsScreen(mint: Mint) { holdings = entry.holdings, currencyName = state.currencyName, onClick = { + analytics.trackCommunityChatTapped(mint) navigator.push( AppRoute.Messaging.Chat( identifier = ChatIdentifier.ByChatId(entry.chat.chatId), diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt index 609321ebe6..2056255116 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp +import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.data.Loadable import com.flipcash.app.core.money.formattedAppreciation @@ -69,6 +70,8 @@ import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.tokens.ui.CurrencyActionTile import com.flipcash.app.tokens.ui.TopCommunityChatsPreviewCount import com.flipcash.app.tokens.ui.currencyActionTiles +import com.flipcash.app.tokens.ui.trackCommunityChatTapped +import com.flipcash.app.tokens.ui.trackTileTapped import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.SocialLink @@ -109,6 +112,7 @@ internal fun CurrencyInfoContentV2( onHeroBounds: (Rect) -> Unit = {}, dispatch: (TokenInfoViewModel.Event) -> Unit, ) { + val analytics = rememberAnalytics() val inset = CodeTheme.dimens.inset val grid = CodeTheme.dimens.grid @@ -272,6 +276,7 @@ internal fun CurrencyInfoContentV2( holdings = entry.holdings, currencyName = token.name, onClick = { + analytics.trackCommunityChatTapped(token.address) dispatch( TokenInfoViewModel.Event.OpenScreen( AppRoute.Messaging.Chat( @@ -420,6 +425,7 @@ private fun CurrencyActionTiles( dispatch: (TokenInfoViewModel.Event) -> Unit, modifier: Modifier = Modifier, ) { + val analytics = rememberAnalytics() Row( modifier = modifier, // Node 11622:1609, drawn at 0.65: 5.2 gaps, i.e. 8dp. @@ -445,6 +451,7 @@ private fun CurrencyActionTiles( ) }, onClick = { + analytics.trackTileTapped(tile, tokenMint) when (tile) { CurrencyActionTile.Send -> dispatch( TokenInfoViewModel.Event.OpenScreen(AppRoute.Sheets.CurrencySend(tokenMint)) diff --git a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenInfoScreenAnalyticsTest.kt b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenInfoScreenAnalyticsTest.kt index 0b4cdc93b7..60286ed73c 100644 --- a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenInfoScreenAnalyticsTest.kt +++ b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenInfoScreenAnalyticsTest.kt @@ -38,6 +38,12 @@ class TokenInfoScreenAnalyticsTest { expectedName = "Token Info: Opened From Discovery", ) + @Test + fun searchTracksOpenedFromSearch() = assertTracksSource( + entry = TokenInfoEntry.Search, + expectedName = "Token Info: Opened From Search", + ) + @Test fun chatTracksOpenedFromChat() = assertTracksSource( entry = TokenInfoEntry.Chat, diff --git a/apps/flipcash/shared/analytics/src/androidHostTest/kotlin/com/flipcash/app/analytics/ConversionsTest.kt b/apps/flipcash/shared/analytics/src/androidHostTest/kotlin/com/flipcash/app/analytics/ConversionsTest.kt index 0d7fb7f906..42f398c33b 100644 --- a/apps/flipcash/shared/analytics/src/androidHostTest/kotlin/com/flipcash/app/analytics/ConversionsTest.kt +++ b/apps/flipcash/shared/analytics/src/androidHostTest/kotlin/com/flipcash/app/analytics/ConversionsTest.kt @@ -4,7 +4,13 @@ import com.flipcash.analytics.Amount import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.app.core.navigation.DeeplinkType +import com.flipcash.services.models.AdmitLobbyMemberError +import com.flipcash.services.models.BanUserError +import com.flipcash.services.models.CreateInviteError +import com.flipcash.services.models.DenyLobbyMemberError import com.flipcash.services.models.JoinChatError +import com.flipcash.services.models.RevokeInviteError +import com.flipcash.services.models.SilenceUserError import com.flipcash.services.models.MuteChatError import com.flipcash.services.models.StartChatError import com.flipcash.services.models.chat.ChatId @@ -109,6 +115,18 @@ class ConversionsTest { assertEquals("TitleModerated", StartChatError.TitleModerated(mockk(relaxed = true)).chatResult) } + @Test + fun `an invite, lobby or moderation error reports the proto result name`() { + assertEquals("TooManyInvites", CreateInviteError.TooManyInvites().chatResult) + assertEquals("Denied", CreateInviteError.Denied().chatResult) + assertEquals("NotFound", RevokeInviteError.NotFound().chatResult) + assertEquals("NotInLobby", AdmitLobbyMemberError.NotInLobby().chatResult) + assertEquals("Denied", DenyLobbyMemberError.Denied().chatResult) + assertEquals("CannotRestrictCreator", SilenceUserError.CannotRestrictCreator().chatResult) + assertEquals("CannotRestrictCreator", BanUserError.CannotRestrictCreator().chatResult) + assertEquals("Unrecognized", BanUserError.Unrecognized().chatResult) + } + @Test fun `a transport failure reports Network`() { val cause = RuntimeException("UNAVAILABLE: io exception") diff --git a/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt b/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt index ed9ab49521..3ecf0b9cf2 100644 --- a/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt +++ b/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt @@ -12,10 +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.AdmitLobbyMemberError +import com.flipcash.services.models.BanUserError +import com.flipcash.services.models.CreateInviteError +import com.flipcash.services.models.DenyLobbyMemberError 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.RevokeInviteError +import com.flipcash.services.models.SilenceUserError import com.flipcash.services.models.StartChatError import com.flipcash.services.models.UnmuteChatError import com.flipcash.services.models.chat.ChatRuleRequirement @@ -51,16 +57,27 @@ val Throwable.analytics: String 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 LeaveChatError.Denied, is MuteChatError.Denied, is UnmuteChatError.Denied, + is CreateInviteError.Denied, is RevokeInviteError.Denied, is AdmitLobbyMemberError.Denied, + is DenyLobbyMemberError.Denied, is SilenceUserError.Denied, is BanUserError.Denied -> "Denied" is EditChatError.NotFound, is JoinChatError.NotFound, is LeaveChatError.NotFound, - is MuteChatError.NotFound, is UnmuteChatError.NotFound -> "NotFound" + is MuteChatError.NotFound, is UnmuteChatError.NotFound, + is CreateInviteError.NotFound, is RevokeInviteError.NotFound, is AdmitLobbyMemberError.NotFound, + is DenyLobbyMemberError.NotFound, is SilenceUserError.NotFound, is BanUserError.NotFound -> "NotFound" + is CreateInviteError.TooManyInvites -> "TooManyInvites" + is CreateInviteError.InvalidExpiresAt -> "InvalidExpiresAt" + is CreateInviteError.InvalidSignature -> "InvalidSignature" + is AdmitLobbyMemberError.NotInLobby -> "NotInLobby" + is SilenceUserError.CannotRestrictCreator, is BanUserError.CannotRestrictCreator -> "CannotRestrictCreator" 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 JoinChatError.Banned -> "Banned" is StartChatError.Unrecognized, is EditChatError.Unrecognized, is JoinChatError.Unrecognized, - is LeaveChatError.Unrecognized, is MuteChatError.Unrecognized, is UnmuteChatError.Unrecognized -> "Unrecognized" + is LeaveChatError.Unrecognized, is MuteChatError.Unrecognized, is UnmuteChatError.Unrecognized, + is CreateInviteError.Unrecognized, is RevokeInviteError.Unrecognized, is AdmitLobbyMemberError.Unrecognized, + is DenyLobbyMemberError.Unrecognized, is SilenceUserError.Unrecognized, is BanUserError.Unrecognized -> "Unrecognized" is ValidationException -> "Validation" else -> "Network" } diff --git a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/CurrencyInfoEventsTest.kt b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/CurrencyInfoEventsTest.kt new file mode 100644 index 0000000000..a8b01ce12d --- /dev/null +++ b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/CurrencyInfoEventsTest.kt @@ -0,0 +1,42 @@ +package com.flipcash.app.tokens.ui + +import com.flipcash.analytics.CurrencyInfoTile +import com.flipcash.analytics.events.TokenInfoEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics +import com.getcode.solana.keys.Mint +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Every tile maps to its own value, and each event carries the mint it was tapped on. */ +class CurrencyInfoEventsTest { + + private val mint = Mint.usdc + + @Test + fun `each tile sends Tile Tapped with its own value and the mint`() { + val analytics = RecordingAnalytics() + + CurrencyActionTile.entries.forEach { analytics.trackTileTapped(it, mint) } + + assertEquals( + listOf( + CurrencyInfoTile.SEND, + CurrencyInfoTile.BUY, + CurrencyInfoTile.BUY_IN, + CurrencyInfoTile.TRANSACTIONS, + CurrencyInfoTile.MORE, + ).map { TokenInfoEvents.tileTapped(it, mint.analytics) }, + analytics.events, + ) + } + + @Test + fun `a community chat tap sends the currency's mint`() { + val analytics = RecordingAnalytics() + + analytics.trackCommunityChatTapped(mint) + + assertEquals(listOf(TokenInfoEvents.communityChatTapped(mint.analytics)), analytics.events) + } +} diff --git a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelChartLoadTest.kt b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelChartLoadTest.kt index 156b3a9b96..2ae7301bc7 100644 --- a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelChartLoadTest.kt +++ b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelChartLoadTest.kt @@ -93,6 +93,7 @@ class TokenInfoViewModelChartLoadTest { communityController = mockk { io.mockk.coEvery { getCommunity(any()) } returns Result.failure(Throwable("none")) }, + analytics = com.flipcash.app.analytics.RecordingAnalytics(), dispatchers = TestDispatchers(testScheduler), ) diff --git a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelCommunityLeaderTest.kt b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelCommunityLeaderTest.kt index 47fdbba84e..2ad2edc02a 100644 --- a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelCommunityLeaderTest.kt +++ b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelCommunityLeaderTest.kt @@ -117,6 +117,7 @@ class TokenInfoViewModelCommunityLeaderTest { purchaseMethodController = mockk(relaxed = true), feedCoordinator = mockk(relaxed = true), communityController = communityController, + analytics = com.flipcash.app.analytics.RecordingAnalytics(), dispatchers = TestDispatchers(testScheduler), ) diff --git a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt index ff7eb9e840..b3cd6a8900 100644 --- a/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt +++ b/apps/flipcash/shared/tokens/src/androidHostTest/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt @@ -1,5 +1,9 @@ package com.flipcash.app.tokens.ui +import com.flipcash.analytics.events.TokenInfoEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.analytics.CurrencyInfoMoreOption import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.funding.PurchaseMethodController @@ -55,6 +59,7 @@ class TokenInfoViewModelConvertTest { private val tokenCoordinator = mockk(relaxed = true) private val exchange = mockk(relaxed = true) private val accountController = mockk(relaxed = true) + private val analytics = RecordingAnalytics() private val balances = MutableStateFlow>(emptyList()) private val other = Mint(ByteArray(32) { 2 }.toList()) @@ -103,6 +108,7 @@ class TokenInfoViewModelConvertTest { communityController = mockk { io.mockk.coEvery { getCommunity(any()) } returns Result.failure(Throwable("none")) }, + analytics = analytics, dispatchers = TestDispatchers(testScheduler), ) vm.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint)) @@ -165,4 +171,49 @@ class TokenInfoViewModelConvertTest { val vm = openToken(other) assertTrue(vm.stateFlow.value.canConvert) } + + @Test + fun `Convert from the More sheet sends More Option Picked when it proceeds`() = + runTest(mainCoroutineRule.dispatcher) { + balances.value = listOf(held(Mint.usdf, 5.0), held(other, 2.0)) + val vm = openToken(Mint.usdf) + + vm.dispatchEvent(TokenInfoViewModel.Event.OnConvert) + advanceUntilIdle() + + assertEquals( + listOf(TokenInfoEvents.moreOptionPicked(CurrencyInfoMoreOption.CONVERT, Mint.usdf.analytics)), + analytics.events, + ) + } + + @Test + fun `a Convert that is a no-op sends nothing`() = runTest(mainCoroutineRule.dispatcher) { + balances.value = listOf(held(Mint.usdf, 5.0)) + val vm = openToken(Mint.usdf) + + vm.dispatchEvent(TokenInfoViewModel.Event.OnConvert) + advanceUntilIdle() + + assertTrue(analytics.events.isEmpty(), analytics.events.toString()) + } + + @Test + fun `Withdraw and Deposit each send More Option Picked from their row`() = + runTest(mainCoroutineRule.dispatcher) { + val vm = openToken(other) + vm.dispatchEvent(TokenInfoViewModel.Event.OnMore) + advanceUntilIdle() + assertTrue(analytics.events.isEmpty(), "opening More is not a pick") + + val actions = BottomBarManager.messages.value.last().actions + actions.single { it.testTag == "more_action_withdraw" }.onClick() + actions.single { it.testTag == "more_action_deposit" }.onClick() + + assertEquals( + listOf(CurrencyInfoMoreOption.WITHDRAW, CurrencyInfoMoreOption.DEPOSIT) + .map { TokenInfoEvents.moreOptionPicked(it, other.analytics) }, + analytics.events, + ) + } } diff --git a/apps/flipcash/shared/tokens/src/androidMain/kotlin/com/flipcash/app/tokens/ui/CurrencyInfoEvents.kt b/apps/flipcash/shared/tokens/src/androidMain/kotlin/com/flipcash/app/tokens/ui/CurrencyInfoEvents.kt new file mode 100644 index 0000000000..792ef5202e --- /dev/null +++ b/apps/flipcash/shared/tokens/src/androidMain/kotlin/com/flipcash/app/tokens/ui/CurrencyInfoEvents.kt @@ -0,0 +1,39 @@ +package com.flipcash.app.tokens.ui + +import com.flipcash.analytics.CurrencyInfoMoreOption +import com.flipcash.analytics.CurrencyInfoTile +import com.flipcash.analytics.events.TokenInfoEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics +import com.getcode.solana.keys.Mint + +/** `Token Info: Tile Tapped` for the tap, before anything opens. It does not wait for the screen it leads to. */ +fun FlipcashAnalytics.trackTileTapped(tile: CurrencyActionTile, mint: Mint) { + track(TokenInfoEvents.tileTapped(tile.analytics, mint.analytics)) +} + +/** `Token Info: More Option Picked` for the row picked in the More sheet. */ +fun FlipcashAnalytics.trackMoreOptionPicked(row: MoreRow, mint: Mint) { + track(TokenInfoEvents.moreOptionPicked(row.analytics, mint.analytics)) +} + +/** `Token Info: Community Chat Tapped` for a row in the Top Community Chats preview or its View All list. */ +fun FlipcashAnalytics.trackCommunityChatTapped(mint: Mint) { + track(TokenInfoEvents.communityChatTapped(mint.analytics)) +} + +private val CurrencyActionTile.analytics: CurrencyInfoTile + get() = when (this) { + CurrencyActionTile.Send -> CurrencyInfoTile.SEND + CurrencyActionTile.Buy -> CurrencyInfoTile.BUY + CurrencyActionTile.BuyIn -> CurrencyInfoTile.BUY_IN + CurrencyActionTile.Transactions -> CurrencyInfoTile.TRANSACTIONS + CurrencyActionTile.More -> CurrencyInfoTile.MORE + } + +private val MoreRow.analytics: CurrencyInfoMoreOption + get() = when (this) { + MoreRow.Convert -> CurrencyInfoMoreOption.CONVERT + MoreRow.Withdraw -> CurrencyInfoMoreOption.WITHDRAW + MoreRow.Deposit -> CurrencyInfoMoreOption.DEPOSIT + } diff --git a/apps/flipcash/shared/tokens/src/androidMain/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/androidMain/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index caede04827..6613dc6a7e 100644 --- a/apps/flipcash/shared/tokens/src/androidMain/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/androidMain/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -1,6 +1,7 @@ package com.flipcash.app.tokens.ui import androidx.lifecycle.viewModelScope +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.data.Loadable import com.flipcash.app.core.data.isLoaded @@ -58,6 +59,7 @@ class TokenInfoViewModel @Inject constructor( private val purchaseMethodController: PurchaseMethodController, private val feedCoordinator: ActivityFeedCoordinator, private val communityController: CommunityController, + private val analytics: FlipcashAnalytics, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -368,6 +370,7 @@ class TokenInfoViewModel @Inject constructor( // The tile is disabled when this is false; checked again here so nothing else can // open a Convert out of Dollars with no destination. if (!stateFlow.value.canConvert) return@onEach + analytics.trackMoreOptionPicked(MoreRow.Convert, mint) // Dollars is the default landing spot; when Dollars *is* the source the swap // view model substitutes the user's largest other holding. @@ -471,6 +474,7 @@ class TokenInfoViewModel @Inject constructor( iconRes = R.drawable.ic_menu_withdraw, testTag = "more_action_withdraw", onClick = { + analytics.trackMoreOptionPicked(MoreRow.Withdraw, mint) dispatchEvent( Event.OpenScreen(AppRoute.Transfers.Withdrawal(preselectedMint = mint)) ) @@ -484,6 +488,7 @@ class TokenInfoViewModel @Inject constructor( // Dollars opens the add-money options (debit card, Phantom, other wallet); // any other currency opens its own deposit address. onClick = { + analytics.trackMoreOptionPicked(MoreRow.Deposit, mint) if (mint == Mint.usdf) { dispatchEvent(Event.PresentDepositOptions) } else { From 484923409ad682c82c998b2e5021293ebfe2331e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 00:14:17 -0400 Subject: [PATCH 4/4] fix(analytics): adapt the group and search tests to the current code/cash Drop the explicit null in newOpened now that optional builder params default to null, point search at the test-fixtures modules, and disambiguate the ResourceHelper.getString mocks. --- .../app/messenger/internal/MemberModerationAnalyticsTest.kt | 4 ++-- apps/flipcash/features/search/build.gradle.kts | 4 ++-- .../kotlin/com/flipcash/analytics/GroupEventsTest.kt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MemberModerationAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MemberModerationAnalyticsTest.kt index 56759d542b..53cd2a302d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MemberModerationAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MemberModerationAnalyticsTest.kt @@ -29,8 +29,8 @@ class MemberModerationAnalyticsTest { private val chatController = mockk(relaxed = true) private val resources = mockk(relaxed = true) { - every { getString(any()) } returns "text" - every { getString(any(), any()) } returns "text" + every { getString(any()) } returns "text" + every { getString(any(), *anyVararg()) } returns "text" } private val groupRosters = mockk(relaxed = true) private val analytics = RecordingAnalytics() diff --git a/apps/flipcash/features/search/build.gradle.kts b/apps/flipcash/features/search/build.gradle.kts index 25b29eb361..639b848d54 100644 --- a/apps/flipcash/features/search/build.gradle.kts +++ b/apps/flipcash/features/search/build.gradle.kts @@ -10,8 +10,8 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) testImplementation(libs.robolectric) - testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) - testImplementation(testFixtures(project(":libs:coroutines"))) + testImplementation(project(":apps:flipcash:shared:analytics:test-fixtures")) + testImplementation(project(":libs:coroutines:test-fixtures")) implementation(project(":services:flipcash")) implementation(project(":services:opencode")) 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 index 15bf8acc18..defa0598ec 100644 --- a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/GroupEventsTest.kt +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/GroupEventsTest.kt @@ -8,7 +8,7 @@ class GroupEventsTest { @Test fun newOpenedLeavesOutAnAbsentType() { - assertEquals(AnalyticsEvent("Group: New Opened", emptyMap()), GroupEvents.newOpened(null)) + assertEquals(AnalyticsEvent("Group: New Opened", emptyMap()), GroupEvents.newOpened()) } @Test