diff --git a/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift b/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift index c7467051b..bd12c30b4 100644 --- a/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift @@ -101,6 +101,7 @@ struct ChangeCoverPictureScreen: View { slot: .cover ) ) + Analytics.profileFieldSaved(.cover) buttonState = .success try? await Task.delay(milliseconds: 500) diff --git a/Flipcash/Core/Screens/Main/Profile/EditBioModel.swift b/Flipcash/Core/Screens/Main/Profile/EditBioModel.swift index 9168d96d5..1ae0091ec 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditBioModel.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditBioModel.swift @@ -103,10 +103,12 @@ final class EditBioModel { do { try await saving(submission) + Analytics.profileFieldSaved(.bio) try await refresh() state = .saved } catch ErrorProfile.moderated(let category) { + Analytics.profileBioModerationFailed() state = .normal logger.info("Bio moderation denied", metadata: ["category": "\(category)"]) fieldError = .moderated diff --git a/Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift b/Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift index 65363c06e..ccebc238c 100644 --- a/Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift @@ -142,6 +142,7 @@ struct ProfileNameScreen: View { owner: sessionContainer.session.ownerKeyPair ) Analytics.displayNameSubmitted(source: source, hadPreviousName: hadPreviousName) + if source == .myAccount { Analytics.profileFieldSaved(.name) } try await sessionContainer.session.updateProfile() buttonState = .success diff --git a/Flipcash/Core/Screens/Main/Profile/ProfilePhotoScreen.swift b/Flipcash/Core/Screens/Main/Profile/ProfilePhotoScreen.swift index 2b2df4ce1..75bcaf428 100644 --- a/Flipcash/Core/Screens/Main/Profile/ProfilePhotoScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/ProfilePhotoScreen.swift @@ -157,6 +157,7 @@ struct ProfilePhotoScreen: View { slot: .avatar ) ) + Analytics.profileFieldSaved(.photo) buttonState = .success // Same beat onboarding holds its checkmark for, so the confirmation diff --git a/Flipcash/Core/Screens/Main/Username/UsernameEntryScreen.swift b/Flipcash/Core/Screens/Main/Username/UsernameEntryScreen.swift index bde644bbc..cdc9ed722 100644 --- a/Flipcash/Core/Screens/Main/Username/UsernameEntryScreen.swift +++ b/Flipcash/Core/Screens/Main/Username/UsernameEntryScreen.swift @@ -144,6 +144,7 @@ struct UsernameEntryScreen: View { username, owner: session.ownerKeyPair ) + Analytics.profileFieldSaved(.username) try await session.updateProfile() buttonState = .success diff --git a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift index 93db9d67d..44cb2bb4a 100644 --- a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift +++ b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift @@ -93,6 +93,7 @@ struct ProfileCardScreen: View { ) } .onAppear { + Analytics.profileCardShown() boostBrightness() reveal() } diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index c61b5a3ea..232dd213c 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -148,6 +148,7 @@ struct YouScreen: View { title: editButton.title, isAccented: editButton.isAccented ) { + Analytics.profileEditProfileViewed() router.push(.editProfile) } .accessibilityIdentifier("you-edit-profile") @@ -185,6 +186,7 @@ struct YouScreen: View { private var settingsGear: some View { Button { + Analytics.profileSettingsViewed() router.push(.settings) } label: { Image(systemName: "gearshape") diff --git a/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift index e144d2116..50b53c285 100644 --- a/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift +++ b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift @@ -3,6 +3,8 @@ // Flipcash // +import FlipcashCore + /// An entry in another user's overflow menu. nonisolated enum ProfileMenuItem: Equatable { case mute @@ -69,3 +71,17 @@ nonisolated enum ProfileMenuItems { return items } } + +extension ProfileMenuItem { + /// The shared catalogue's action for this entry, or nil for the group-moderation entries it doesn't cover. + var analyticsAction: SharedCoreKit.ProfileMenuAction? { + switch self { + case .mute: .mute + case .report: .report + case .block: .block + case .unblock: .unblock + case .silence: nil + case .removeFromGroup: nil + } + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfilePinnedAction.swift b/Flipcash/Core/Screens/Profile/ProfilePinnedAction.swift index 452a34a66..d4491c5b4 100644 --- a/Flipcash/Core/Screens/Profile/ProfilePinnedAction.swift +++ b/Flipcash/Core/Screens/Profile/ProfilePinnedAction.swift @@ -36,3 +36,15 @@ nonisolated enum ProfilePinnedAction: Equatable { } } } + +extension ProfilePinnedAction { + /// The shared catalogue's action for this button, or nil when nothing is pinned. + var analyticsAction: SharedCoreKit.ProfilePinnedAction? { + switch self { + case .none: nil + case .unblock: .unblock + case .openChat: .openChat + case .startChatting, .startChattingUnpriced: .startChatting + } + } +} diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index dc577f865..ff79b56da 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -353,6 +353,7 @@ private struct UserProfileContent: View { } private func perform(_ item: ProfileMenuItem) { + Analytics.profileMenuAction(for: item) switch item { case .mute: isPickingMuteDuration = true @@ -402,6 +403,7 @@ private struct UserProfileContent: View { } private func tapPinned() { + Analytics.profilePinnedButtonTapped(for: pinnedAction) switch pinnedAction { case .none: break diff --git a/Flipcash/Core/Screens/Settings/SetMinimumTipScreen.swift b/Flipcash/Core/Screens/Settings/SetMinimumTipScreen.swift index dd9cf9e48..966d8f2eb 100644 --- a/Flipcash/Core/Screens/Settings/SetMinimumTipScreen.swift +++ b/Flipcash/Core/Screens/Settings/SetMinimumTipScreen.swift @@ -132,6 +132,7 @@ struct SetMinimumTipScreen: View { fee, owner: sessionContainer.session.ownerKeyPair ) + Analytics.profileFieldSaved(.minimum) try await sessionContainer.session.updateProfile() actionState = .success diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index 47e2b4706..a34f5c641 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -412,6 +412,57 @@ extension Analytics.DisplayNameSource { } } +// MARK: - Profile - + +extension Analytics { + /// The Settings screen was opened from the You tab. + static func profileSettingsViewed() { + track(ProfileEvents.shared.settingsViewed()) + } + + /// The Edit Profile screen was opened from the You tab. + static func profileEditProfileViewed() { + track(ProfileEvents.shared.editProfileViewed()) + } + + /// A profile field was saved on the server. + static func profileFieldSaved(_ field: SharedCoreKit.ProfileField) { + track(ProfileEvents.shared.fieldSaved(field: field)) + } + + /// The server refused a bio for moderation. + static func profileBioModerationFailed() { + track(ProfileEvents.shared.bioModerationFailed()) + } + + /// An entry was picked in another user's profile menu. + static func profileMenuAction(_ action: SharedCoreKit.ProfileMenuAction) { + track(ProfileEvents.shared.menuAction(action: action)) + } + + /// An entry was picked in another user's profile menu; entries outside the catalogue send nothing. + static func profileMenuAction(for item: ProfileMenuItem) { + guard let action = item.analyticsAction else { return } + profileMenuAction(action) + } + + /// The pinned button under another user's profile was tapped. + static func profilePinnedButtonTapped(_ action: SharedCoreKit.ProfilePinnedAction) { + track(ProfileEvents.shared.pinnedButtonTapped(action: action)) + } + + /// The pinned button was tapped; an empty pin sends nothing. + static func profilePinnedButtonTapped(for action: ProfilePinnedAction) { + guard let shared = action.analyticsAction else { return } + profilePinnedButtonTapped(shared) + } + + /// The profile card appeared. + static func profileCardShown() { + track(ProfileEvents.shared.cardShown()) + } +} + // MARK: - Conversation - extension Analytics { diff --git a/FlipcashTests/Analytics/ProfileEventsTests.swift b/FlipcashTests/Analytics/ProfileEventsTests.swift new file mode 100644 index 000000000..675fc1c4f --- /dev/null +++ b/FlipcashTests/Analytics/ProfileEventsTests.swift @@ -0,0 +1,146 @@ +// +// ProfileEventsTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashAPI +import FlipcashCore +@testable import Flipcash + +/// What the Profile events send, written as literals. +@MainActor +@Suite("Profile events", .serialized) +struct ProfileEventsTests { + + init() { + Analytics.tokenSymbolResolver = nil + } + + private struct Boom: Error {} + + // MARK: - Wrappers - + + @Test("Profile: Settings Viewed has no properties") + func settingsViewed() throws { + let sent = Analytics.recordingSends { Analytics.profileSettingsViewed() } + try expectEvent(sent, "Profile: Settings Viewed", [:]) + } + + @Test("Profile: Edit Profile Viewed has no properties") + func editProfileViewed() throws { + let sent = Analytics.recordingSends { Analytics.profileEditProfileViewed() } + try expectEvent(sent, "Profile: Edit Profile Viewed", [:]) + } + + @Test("Profile: Card Shown has no properties") + func cardShown() throws { + let sent = Analytics.recordingSends { Analytics.profileCardShown() } + try expectEvent(sent, "Profile: Card Shown", [:]) + } + + @Test("Profile: Bio Moderation Failed has no properties") + func bioModerationFailed() throws { + let sent = Analytics.recordingSends { Analytics.profileBioModerationFailed() } + try expectEvent(sent, "Profile: Bio Moderation Failed", [:]) + } + + @Test("Profile: Field Saved carries the field") + func fieldSaved() throws { + let cases: [(SharedCoreKit.ProfileField, String)] = [ + (.name, "Name"), (.username, "Username"), (.photo, "Photo"), + (.cover, "Cover"), (.bio, "Bio"), (.minimum, "Minimum"), + ] + for (field, value) in cases { + let sent = Analytics.recordingSends { Analytics.profileFieldSaved(field) } + try expectEvent(sent, "Profile: Field Saved", ["Field": value]) + } + } + + @Test("Profile: Menu Action carries the action", arguments: [ + (ProfileMenuItem.mute, "Mute"), + (.report, "Report"), + (.block, "Block"), + (.unblock, "Unblock"), + ]) + func menuAction(item: ProfileMenuItem, value: String) throws { + let sent = Analytics.recordingSends { Analytics.profileMenuAction(for: item) } + try expectEvent(sent, "Profile: Menu Action", ["Action": value]) + } + + @Test("Group moderation entries send no Menu Action", arguments: [ProfileMenuItem.silence, .removeFromGroup]) + func moderationEntriesSendNothing(item: ProfileMenuItem) { + #expect(item.analyticsAction == nil) + let sent = Analytics.recordingSends { Analytics.profileMenuAction(for: item) } + #expect(sent.isEmpty) + } + + @Test("Profile: Pinned Button Tapped carries the action") + func pinnedButtonTapped() throws { + let fee = try #require(FiatAmount.usd(1)) + let dmID = ConversationID(data: Data(repeating: 1, count: 32)) + let cases: [(Flipcash.ProfilePinnedAction, String)] = [ + (.startChatting(fee), "Start Chatting"), + (.startChattingUnpriced, "Start Chatting"), + (.openChat(dmID), "Open Chat"), + (.unblock, "Unblock"), + ] + for (action, value) in cases { + let sent = Analytics.recordingSends { Analytics.profilePinnedButtonTapped(for: action) } + try expectEvent(sent, "Profile: Pinned Button Tapped", ["Action": value]) + } + } + + @Test("An empty pin sends no Pinned Button Tapped") + func emptyPinSendsNothing() { + #expect(Flipcash.ProfilePinnedAction.none.analyticsAction == nil) + let sent = Analytics.recordingSends { Analytics.profilePinnedButtonTapped(for: .none) } + #expect(sent.isEmpty) + } + + // MARK: - EditBioModel - + + private func makeBioModel(saving: @escaping (String) async throws -> Void) -> EditBioModel { + let model = EditBioModel(bio: "Hello", saving: saving, refresh: {}) + model.text = "Hello there" + return model + } + + @Test("A saved bio sends Field Saved for the bio") + func bioSaved() async throws { + let model = makeBioModel { _ in } + let sent = await Analytics.recordingSendsAsync { await model.save() } + try expectEvent(sent, "Profile: Field Saved", ["Field": "Bio"]) + } + + @Test("A moderated bio sends Bio Moderation Failed and no Field Saved") + func bioModerated() async throws { + let model = makeBioModel { _ in throw ErrorProfile.moderated(.init()) } + let sent = await Analytics.recordingSendsAsync { await model.save() } + try expectEvent(sent, "Profile: Bio Moderation Failed", [:]) + } + + @Test("An invalid or failed bio sends nothing", arguments: [true, false]) + func bioOtherFailures(invalid: Bool) async { + let model = makeBioModel { _ in throw invalid ? ErrorProfile.invalidBio : Boom() } + let sent = await Analytics.recordingSendsAsync { await model.save() } + #expect(sent.isEmpty) + } + + // MARK: - Helpers - + + private func expectEvent( + _ sent: [SentEvent], + _ name: String, + _ properties: [String: String], + sourceLocation: SourceLocation = #_sourceLocation + ) throws { + let event = try sent.single + #expect(event.name == name, sourceLocation: sourceLocation) + #expect(event.properties.keys.sorted() == properties.keys.sorted(), sourceLocation: sourceLocation) + for (key, value) in properties { + #expect(event.properties[key] as? String == value, "\(key)", sourceLocation: sourceLocation) + } + } +} diff --git a/FlipcashTests/TestSupport/Analytics+TestSupport.swift b/FlipcashTests/TestSupport/Analytics+TestSupport.swift index 4b139c870..95fbb8656 100644 --- a/FlipcashTests/TestSupport/Analytics+TestSupport.swift +++ b/FlipcashTests/TestSupport/Analytics+TestSupport.swift @@ -25,6 +25,15 @@ extension Analytics { return sent } + /// `recordingSends` for a body that awaits, such as a model's async save. + static func recordingSendsAsync(_ body: () async -> Void) async -> [SentEvent] { + var sent: [SentEvent] = [] + sendOverride = { name, properties in sent.append(SentEvent(name: name, properties: properties)) } + defer { sendOverride = nil } + await body() + return sent + } + /// Runs `body` with the Mixpanel people increment replaced by a recorder and returns /// every increment it made, as property name and amount. static func recordingIncrements(_ body: () -> Void) -> [(property: String, amount: Double)] {