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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ struct ChangeCoverPictureScreen: View {
slot: .cover
)
)
Analytics.profileFieldSaved(.cover)

buttonState = .success
try? await Task.delay(milliseconds: 500)
Expand Down
2 changes: 2 additions & 0 deletions Flipcash/Core/Screens/Main/Profile/EditBioModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ struct ProfilePhotoScreen: View {
slot: .avatar
)
)
Analytics.profileFieldSaved(.photo)

buttonState = .success
// Same beat onboarding holds its checkmark for, so the confirmation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ struct UsernameEntryScreen: View {
username,
owner: session.ownerKeyPair
)
Analytics.profileFieldSaved(.username)
try await session.updateProfile()

buttonState = .success
Expand Down
1 change: 1 addition & 0 deletions Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ struct ProfileCardScreen: View {
)
}
.onAppear {
Analytics.profileCardShown()
boostBrightness()
reveal()
}
Expand Down
2 changes: 2 additions & 0 deletions Flipcash/Core/Screens/Main/You/YouScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ struct YouScreen: View {
title: editButton.title,
isAccented: editButton.isAccented
) {
Analytics.profileEditProfileViewed()
router.push(.editProfile)
}
.accessibilityIdentifier("you-edit-profile")
Expand Down Expand Up @@ -185,6 +186,7 @@ struct YouScreen: View {

private var settingsGear: some View {
Button {
Analytics.profileSettingsViewed()
router.push(.settings)
} label: {
Image(systemName: "gearshape")
Expand Down
16 changes: 16 additions & 0 deletions Flipcash/Core/Screens/Profile/ProfileMenuItems.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// Flipcash
//

import FlipcashCore

/// An entry in another user's overflow menu.
nonisolated enum ProfileMenuItem: Equatable {
case mute
Expand Down Expand Up @@ -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
}
}
}
12 changes: 12 additions & 0 deletions Flipcash/Core/Screens/Profile/ProfilePinnedAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
2 changes: 2 additions & 0 deletions Flipcash/Core/Screens/Profile/UserProfileScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ private struct UserProfileContent: View {
}

private func perform(_ item: ProfileMenuItem) {
Analytics.profileMenuAction(for: item)
switch item {
case .mute:
isPickingMuteDuration = true
Expand Down Expand Up @@ -402,6 +403,7 @@ private struct UserProfileContent: View {
}

private func tapPinned() {
Analytics.profilePinnedButtonTapped(for: pinnedAction)
switch pinnedAction {
case .none:
break
Expand Down
1 change: 1 addition & 0 deletions Flipcash/Core/Screens/Settings/SetMinimumTipScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ struct SetMinimumTipScreen: View {
fee,
owner: sessionContainer.session.ownerKeyPair
)
Analytics.profileFieldSaved(.minimum)
try await sessionContainer.session.updateProfile()

actionState = .success
Expand Down
51 changes: 51 additions & 0 deletions Flipcash/Utilities/Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
146 changes: 146 additions & 0 deletions FlipcashTests/Analytics/ProfileEventsTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
9 changes: 9 additions & 0 deletions FlipcashTests/TestSupport/Analytics+TestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)] {
Expand Down
Loading