diff --git a/Flipcash/Core/Controllers/ProfileAvatarStore.swift b/Flipcash/Core/Controllers/ProfileAvatarStore.swift index 9b76165aa..decbd132d 100644 --- a/Flipcash/Core/Controllers/ProfileAvatarStore.swift +++ b/Flipcash/Core/Controllers/ProfileAvatarStore.swift @@ -25,6 +25,8 @@ final class ProfileAvatarStore { case chat(ConversationID) /// A user's cover banner. Authorizes like the user's avatar but holds the full-size blob. case cover(UserID) + /// A group chat's cover banner. Authorizes like the chat's picture but holds the full-size blob. + case groupCover(ConversationID) /// The context this subject's blobs authorize through. var accessContext: BlobAccessContext { @@ -32,14 +34,15 @@ final class ProfileAvatarStore { case .user(let userID): .userProfile(userID) case .chat(let conversationID): .chatProfile(conversationID) case .cover(let userID): .userProfile(userID) + case .groupCover(let conversationID): .chatProfile(conversationID) } } /// The blob of `picture` this subject renders: avatars use the thumbnail, a cover the original. func blobID(for picture: ProfilePicture) -> BlobID { switch self { - case .user, .chat: picture.thumbnailBlobID - case .cover: picture.blobID + case .user, .chat: picture.thumbnailBlobID + case .cover, .groupCover: picture.blobID } } } @@ -145,7 +148,10 @@ final class ProfileAvatarStore { /// Covers hold the full-size blob, so they get their own budget rather than crowding out thumbnails. private func cache(for subject: AvatarSubject) -> BlobCache { - if case .cover = subject { coverCache } else { cache } + switch subject { + case .user, .chat: cache + case .cover, .groupCover: coverCache + } } /// Download URLs expire, so one is minted per fetch and never stored. diff --git a/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift b/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift index 17894f688..c7467051b 100644 --- a/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift @@ -35,8 +35,7 @@ struct ChangeCoverPictureScreen: View { Button("Choose File", systemImage: "folder") { isShowingFilePicker = true } } label: { ProfileCoverBanner( - userID: session.userID, - coverPicture: profile?.coverPicture, + cover: .user(session.userID, picture: profile?.coverPicture), preview: state.selectedImage ) .clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius)) diff --git a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift index e6706b043..2dddb0917 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -41,7 +41,7 @@ struct EditProfileScreen: View { fieldCards .padding(.top, 20) } - .padding(.horizontal, ProfileHeaderView.inset) + .padding(.horizontal, ProfileHeaderMetrics.inset) .padding(.vertical, 24) } } @@ -59,7 +59,7 @@ struct EditProfileScreen: View { Button { router.push(.changeCoverPicture) } label: { - ProfileCoverBanner(userID: session.userID, coverPicture: profile?.coverPicture, bannerHeight: Self.coverHeight) + ProfileCoverBanner(cover: .user(session.userID, picture: profile?.coverPicture), bannerHeight: Self.coverHeight) .clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) } .buttonStyle(.plain) diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index bd7bff652..ce60bda46 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -60,7 +60,7 @@ struct YouScreen: View { if displayName == nil { setupPrompt .padding(.top, 24) - .padding(.horizontal, ProfileHeaderView.inset) + .padding(.horizontal, ProfileHeaderMetrics.inset) } else { ProfileStatsCard( minimumToChat: StartChattingFee.amount( @@ -115,13 +115,18 @@ struct YouScreen: View { private var header: some View { ProfileHeaderView( - userID: sessionContainer.session.userID, - displayName: displayName, - handle: username.map(\.handle), - bio: profile?.bio, - avatarData: sessionContainer.profileAvatars.data(for: sessionContainer.session.userID), - avatarBlurhash: profilePicture?.thumbnailBlurhash, - coverPicture: profile?.coverPicture, + cover: .user(sessionContainer.session.userID, picture: profile?.coverPicture), + title: displayName, + subtitle: username.map(\.handle), + bodyText: profile?.bio, + avatar: { + ProfileHeaderAvatar( + id: sessionContainer.session.userID.uuidString, + displayName: displayName ?? "", + imageData: sessionContainer.profileAvatars.data(for: sessionContainer.session.userID), + blurhash: profilePicture?.thumbnailBlurhash + ) + }, bannerControls: { EmptyView() }, rowActions: { ProfileEditCapsule { @@ -133,7 +138,7 @@ struct YouScreen: View { shareButton } }, - underHandle: { + underSubtitle: { if shouldPromptForUsername { Button(action: claimUsername) { Text("Claim your username ›") diff --git a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift index 2f235ee98..b6d02c1f3 100644 --- a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift +++ b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift @@ -7,6 +7,28 @@ import SwiftUI import FlipcashCore import FlipcashUI +/// What a cover banner draws: the picture, and the subject its bytes load and authorize under. +struct ProfileCover { + + let subject: ProfileAvatarStore.AvatarSubject + let picture: ProfilePicture? + + private init(subject: ProfileAvatarStore.AvatarSubject, picture: ProfilePicture?) { + self.subject = subject + self.picture = picture + } + + /// A user's cover. + static func user(_ userID: UserID, picture: ProfilePicture?) -> ProfileCover { + ProfileCover(subject: .cover(userID), picture: picture) + } + + /// A group chat's cover. + static func group(_ conversationID: ConversationID, picture: ProfilePicture?) -> ProfileCover { + ProfileCover(subject: .groupCover(conversationID), picture: picture) + } +} + /// A profile's full-bleed cover: the picture once it has loaded, the blurhash while it does, and a /// flat gray when there is no picture. /// @@ -15,8 +37,7 @@ struct ProfileCoverBanner: View { @Environment(SessionContainer.self) private var sessionContainer - let userID: UserID - let coverPicture: ProfilePicture? + let cover: ProfileCover /// A picked image not uploaded yet, drawn in place of the stored cover. var preview: UIImage? = nil /// The banner's height; a profile's own cover is ``height``. @@ -29,13 +50,13 @@ struct ProfileCoverBanner: View { Color.clear .frame(height: bannerHeight) .frame(maxWidth: .infinity) - .overlay { cover } + .overlay { image } .clipped() .overlay(alignment: .top) { controls() } - .task(id: coverPicture?.blobID) { - await sessionContainer.profileAvatars.load(.cover(userID), picture: coverPicture) + .task(id: cover.picture?.blobID) { + await sessionContainer.profileAvatars.load(cover.subject, picture: cover.picture) } // `.contain` keeps the banner's controls as their own elements; a bare identifier // here would overwrite theirs. @@ -44,11 +65,11 @@ struct ProfileCoverBanner: View { } @ViewBuilder - private var cover: some View { + private var image: some View { if let preview { fill(preview) - } else if let coverPicture { - let data = sessionContainer.profileAvatars.data(for: .cover(userID)) + } else if let coverPicture = cover.picture { + let data = sessionContainer.profileAvatars.data(for: cover.subject) if let data, let image = ContactAvatarCache.shared.image(forKey: "cover-\(coverPicture.blobID)", data: data) { fill(image) } else if let preview = BlurHashCache.shared.image(for: coverPicture.thumbnailBlurhash) { @@ -71,7 +92,7 @@ struct ProfileCoverBanner: View { extension ProfileCoverBanner where Controls == EmptyView { /// A cover with nothing laid over it. - init(userID: UserID, coverPicture: ProfilePicture?, preview: UIImage? = nil, bannerHeight: CGFloat = Self.height) { - self.init(userID: userID, coverPicture: coverPicture, preview: preview, bannerHeight: bannerHeight, controls: { EmptyView() }) + init(cover: ProfileCover, preview: UIImage? = nil, bannerHeight: CGFloat = Self.height) { + self.init(cover: cover, preview: preview, bannerHeight: bannerHeight, controls: { EmptyView() }) } } diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift index 690618fc7..cbee37178 100644 --- a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -7,48 +7,45 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// A profile's top block: a full-bleed cover, the avatar overlapping it, an action row, then name, -/// handle and bio, all left-aligned on a 24pt inset. -/// -/// The caller lets the view run under the status bar. A nil `displayName` leaves the name block out, -/// for a profile that has not named itself yet. -struct ProfileHeaderView: View { - - let userID: UserID - let displayName: String? - let handle: String? - let bio: String? - let avatarData: Data? - let avatarBlurhash: String? - let coverPicture: ProfilePicture? - @ViewBuilder let bannerControls: () -> BannerControls - @ViewBuilder let rowActions: () -> RowActions - @ViewBuilder let underHandle: () -> UnderHandle +/// The profile header's layout constants, shared with the sections laid out beneath it. +enum ProfileHeaderMetrics { + /// The horizontal inset everything below the banner sits on. static var inset: CGFloat { 24 } static var avatarSize: CGFloat { 84 } /// How far the avatar rises over the banner. static var avatarOverlap: CGFloat { 42 } +} + +/// A profile's top block: a full-bleed cover, the caller's avatar overlapping it, an action row, +/// then title, subtitle and body text, all left-aligned on a 24pt inset. +/// +/// The caller lets the view run under the status bar. A nil `title` leaves the text block out, +/// for a profile that has not named itself yet. +struct ProfileHeaderView: View { + + let cover: ProfileCover + let title: String? + let subtitle: String? + let bodyText: String? + /// Drawn at ``ProfileHeaderMetrics/avatarSize``; the header adds the ring and the overlap. + @ViewBuilder let avatar: () -> Avatar + @ViewBuilder let bannerControls: () -> BannerControls + @ViewBuilder let rowActions: () -> RowActions + @ViewBuilder let underSubtitle: () -> UnderSubtitle var body: some View { VStack(alignment: .leading, spacing: 0) { ProfileCoverBanner( - userID: userID, - coverPicture: coverPicture, + cover: cover, controls: bannerControls ) HStack(alignment: .top, spacing: 0) { - ContactAvatarView( - id: userID.uuidString, - displayName: displayName ?? "", - imageData: avatarData, - blurhash: avatarBlurhash, - size: Self.avatarSize - ) - .overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 5) } - .padding(.top, -Self.avatarOverlap) + avatar() + .overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 5) } + .padding(.top, -ProfileHeaderMetrics.avatarOverlap) Spacer(minLength: 8) @@ -57,26 +54,26 @@ struct ProfileHeaderView.inset) + .padding(.horizontal, ProfileHeaderMetrics.inset) } private func stat(title: String, value: String, valueFont: Font, identifier: String) -> some View { diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 6d06b8a82..38c465ce9 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -124,19 +124,24 @@ private struct UserProfileContent: View { ScrollView { VStack(spacing: 0) { ProfileHeaderView( - userID: model.userID, - displayName: model.displayName, - handle: model.handle, - bio: model.bio, - avatarData: model.imageData, - avatarBlurhash: model.blurhash, - coverPicture: model.coverPicture, + cover: .user(model.userID, picture: model.coverPicture), + title: model.displayName, + subtitle: model.handle, + bodyText: model.bio, + avatar: { + ProfileHeaderAvatar( + id: model.userID.uuidString, + displayName: model.displayName, + imageData: model.imageData, + blurhash: model.blurhash + ) + }, bannerControls: { EmptyView() }, rowActions: { statusBadges shareButton }, - underHandle: { EmptyView() } + underSubtitle: { EmptyView() } ) ProfileStatsCard(minimumToChat: fee, joinedAt: model.joinedAt) @@ -291,7 +296,7 @@ private struct UserProfileContent: View { CodeButton(style: .filled, title: title, action: tapPinned) .accessibilityIdentifier("profile-pinned-action") } - .padding(.horizontal, ProfileHeaderView.inset) + .padding(.horizontal, ProfileHeaderMetrics.inset) .padding(.top, 12) .padding(.bottom, 8) } else if showsE2eeFooter { diff --git a/FlipcashTests/ProfileAvatarStoreTests.swift b/FlipcashTests/ProfileAvatarStoreTests.swift index e865f0b0e..39f3fca9f 100644 --- a/FlipcashTests/ProfileAvatarStoreTests.swift +++ b/FlipcashTests/ProfileAvatarStoreTests.swift @@ -189,6 +189,37 @@ struct ProfileAvatarStoreTests { #expect(!coverCache.holds(picture.thumbnailBlobID)) } + /// A group's cover is the chat's blob, so it must authorize through the chat, not a member. + @Test("A group cover loads the original blob under the chat's context, into the cover pool") + func groupCoverLoadsOriginalUnderChatContext() async { + let conversationID = ConversationID(data: Data(repeating: 7, count: 32)) + let picture = ProfilePicture(blobID: BlobID(uuid: UUID()), thumbnailBlobID: BlobID(uuid: UUID())) + let avatarCache = Self.cache() + let coverCache = Self.cache() + var mintedBlobs: [BlobID] = [] + var mintedContexts: [BlobAccessContext] = [] + + let store = ProfileAvatarStore( + cache: avatarCache, + coverCache: coverCache, + mintURL: { blobID, subject in + mintedBlobs.append(blobID) + mintedContexts.append(subject.accessContext) + return URL(string: "https://example.test/\(blobID)")! + }, + fetch: { _ in Self.bytes } + ) + + await store.load(.groupCover(conversationID), picture: picture) + + #expect(mintedBlobs == [picture.blobID]) + #expect(mintedContexts == [.chatProfile(conversationID)]) + #expect(store.data(for: AvatarSubject.groupCover(conversationID)) == Self.bytes) + #expect(store.data(for: AvatarSubject.chat(conversationID)) == nil) + #expect(coverCache.holds(picture.blobID)) + #expect(!avatarCache.holds(picture.blobID)) + } + /// Bytes already on disk are the common case at launch, and paying for a round trip there would /// put a blurhash on screen for the length of one. @Test("Bytes already on disk need no fetch")