diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index b05f748cd..ef5e9e434 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -816,11 +816,16 @@ final class ConversationController { } /// Seed values for the profile screen while the live profile loads: the - /// counterpart's current name and avatar blurhash from the open conversation. + /// counterpart's current name, handle, and avatar blurhash from the open + /// conversation. func counterpartSeed(forUserID userID: UserID) -> CounterpartSeed { let member = conversations.flatMap(\.members).first { $0.userID == userID } - let name = member.flatMap { $0.displayName.isEmpty ? nil : $0.displayName } ?? Self.fallbackCounterpartName - return CounterpartSeed(displayName: name, imageData: nil, blurhash: member?.profilePicture?.thumbnailBlurhash) + return CounterpartSeed( + name: member.flatMap { $0.displayName.isEmpty ? nil : $0.displayName }, + username: member?.username, + imageData: nil, + blurhash: member?.profilePicture?.thumbnailBlurhash + ) } private func contactName(for conversationID: ConversationID) -> String? { @@ -1158,7 +1163,12 @@ final class ConversationController { /// Seed data for the profile screen before the live profile fetch returns. struct CounterpartSeed: Sendable { - let displayName: String + + /// The counterpart's own name, or `nil` for an account that hasn't set + /// one — the profile screen titles those by handle instead. + let name: String? + + let username: Username? let imageData: Data? let blurhash: String? } diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index ad43a39a4..33194bb9b 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -55,14 +55,25 @@ private struct UserProfileContent: View { ) .padding(.top, 40) - Text(model.displayName) - .font(.appDisplaySmall) - .foregroundStyle(.textMain) + // The handle and join date read as a block under the name, so + // they group tighter than the screen's other spacing (node + // 9443:8928). + VStack(spacing: 5) { + Text(model.displayName) + .font(.appDisplaySmall) + .foregroundStyle(.textMain) - if let joined = model.joinedText { - Text(joined) - .font(.appTextSmall) - .foregroundStyle(.textSecondary) + if let handle = model.handle { + Text(handle) + .font(.appTextSmall) + .foregroundStyle(.textSecondary) + } + + if let joined = model.joinedText { + Text(joined) + .font(.appTextSmall) + .foregroundStyle(.textSecondary) + } } Row(insets: .init(top: 25, leading: 0, bottom: 25, trailing: 0)) { @@ -108,11 +119,29 @@ private struct UserProfileContent: View { @Observable final class UserProfileViewModel { let userID: UserID - private(set) var displayName: String + + /// The counterpart's own name, or `nil` for an account that hasn't set one. + private(set) var name: String? + + private(set) var username: Username? private(set) var imageData: Data? private(set) var blurhash: String? private(set) var joinedText: String? + /// What to call this person: their name when they have one, their handle + /// when they don't. A handle is public and stable, so it beats the generic + /// fallback, which is left for an account carrying neither. + var displayName: String { + name ?? username?.handle ?? ConversationController.fallbackCounterpartName + } + + /// The handle line under the title. Left out when the title is already the + /// handle, so a name-less account doesn't read it twice. + var handle: String? { + guard name != nil else { return nil } + return username?.handle + } + @ObservationIgnored private let flipClient: FlipClient @ObservationIgnored private let owner: KeyPair @ObservationIgnored private let blocklistController: BlocklistController @@ -126,7 +155,8 @@ final class UserProfileViewModel { self.blocklistController = blocklistController self.router = router self.session = session - self.displayName = seed.displayName + self.name = seed.name + self.username = seed.username self.imageData = avatarData ?? seed.imageData self.blurhash = seed.blurhash } @@ -144,7 +174,8 @@ final class UserProfileViewModel { } private func apply(_ profile: Profile) { - if let name = profile.displayName, !name.isEmpty { displayName = name } + if let name = profile.displayName, !name.isEmpty { self.name = name } + if let username = profile.username { self.username = username } if blurhash == nil { blurhash = profile.profilePicture?.thumbnailBlurhash } if let joined = profile.joinedAt { joinedText = "Joined \(joined.formatted(.dateTime.month(.wide).year()))"