diff --git a/.claude/plans/2026-10-06-featured-groups.md b/.claude/plans/2026-10-06-featured-groups.md new file mode 100644 index 000000000..18ac8b14f --- /dev/null +++ b/.claude/plans/2026-10-06-featured-groups.md @@ -0,0 +1,26 @@ +# Favorite public groups on profiles (flipcash2 0.18.0) + +Contract + wrappers already on main (257a089d, 721fb442): `FlipClient.getFeaturedGroups(owner:username:)`, +`setFeaturedGroups(owner:conversationIDs:)`. This plan is the app layer and UI only. + +Branch `feat/profile-featured-groups`, cut from `feat/profile-refresh-copy` (#990) with `origin/main` merged in +(the stack predates the 0.18.0 wrappers). + +## Proto facts that shape the design +- Ordered list, max 10 (PGV `max_items`, so exceeding it is a transport invalid-argument — cap client-side). +- Written whole; empty clears. Only public groups (`.denied` otherwise, names no group). +- Featuring is independent of membership: leaving a group does not unfeature it. +- `GetFeaturedGroups` returns list-view `Metadata` (no members, viewer state, last message, cover). + +## Shape +- `FeaturedGroups` (`@MainActor @Observable`, `let` on `SessionContainer`): the signed-in user's list, + shared by the You tab section, the Edit Profile row ("N selected") and the picker. Closures injected + (fetch/save) like `EditBioModel`. Never seated into `ConversationStore`. +- `UserProfileViewModel.featuredGroups`: fetched after the profile (needs `username`). +- `FeaturedGroupsSection` view: "Favorite Public Groups" header + rows (group avatar, title, + description ?? people count); tap → `router.push(.tipConversation(id))`. Hidden when empty. +- Edit Profile: `FieldCard` "Favorite Public Groups" → new `.editFeaturedGroups` destination. +- Picker `EditFeaturedGroupsScreen` + `EditFeaturedGroupsModel`: candidates = current featured (server + order) ∪ joined public groups from `loadGroupFeed()`'s result (`isPrivate` isn't persisted, so the + store's copy can't be trusted offline; fallback = store joined groups with `!isPrivate && !useE2Ee`). + Search by title, ordered selection, cap 10, bottom filled Save like `EditBioScreen`. diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 13a85d080..fc67e67c0 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -64,6 +64,8 @@ extension AppRouter { case editProfile /// The bio on its own, pushed from Edit Profile. case editBio + /// The public groups shown on the signed-in user's profile, chosen from Edit Profile. + case editFeaturedGroups /// The cover banner on its own, pushed from Edit Profile. case changeCoverPicture /// The display name on its own, edited from Settings. The full @@ -141,7 +143,7 @@ extension AppRouter { .buyCurrency, .convertCurrency, .withdrawCurrency, .usdcDepositEducation, .usdcDepositAddress: return .balance - case .settings, .accountInfo, .editProfile, .editBio, .changeCoverPicture, + case .settings, .accountInfo, .editProfile, .editBio, .editFeaturedGroups, .changeCoverPicture, .changeDisplayName, .changeProfilePicture, .username, .setMinimumTip, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, @@ -180,6 +182,7 @@ extension AppRouter { case .accountInfo: "accountInfo" case .editProfile: "editProfile" case .editBio: "editBio" + case .editFeaturedGroups: "editFeaturedGroups" case .changeCoverPicture: "changeCoverPicture" case .changeDisplayName: "changeDisplayName" case .changeProfilePicture: "changeProfilePicture" @@ -242,7 +245,7 @@ extension AppRouter { case .activity, .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, .usdcDepositEducation, .usdcDepositAddress, - .settings, .accountInfo, .editProfile, .editBio, .changeCoverPicture, + .settings, .accountInfo, .editProfile, .editBio, .editFeaturedGroups, .changeCoverPicture, .changeDisplayName, .changeProfilePicture, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, .settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw, diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index f35f2359c..b7328c9b7 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -100,6 +100,23 @@ struct DestinationView: View { refresh: { try await session.updateProfile() } )) + case .editFeaturedGroups: + let session = sessionContainer.session + let flipClient = container.flipClient + let featuredGroups = sessionContainer.featuredGroups + let conversationController = sessionContainer.conversationController + EditFeaturedGroupsScreen(model: EditFeaturedGroupsModel( + featured: featuredGroups.groups, + loadingFeatured: { + // The list is read by handle; with none there is nothing to read back. + guard let username = session.profile?.username else { return [] } + return await featuredGroups.load(username: username) ? featuredGroups.groups : nil + }, + joinedGroups: { await conversationController.loadGroupFeed() }, + saving: { try await flipClient.setFeaturedGroups(owner: session.ownerKeyPair, conversationIDs: $0) }, + saved: { featuredGroups.replace(with: $0) } + )) + case .changeCoverPicture: ChangeCoverPictureScreen() diff --git a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsModel.swift b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsModel.swift new file mode 100644 index 000000000..bddbd464f --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsModel.swift @@ -0,0 +1,167 @@ +// +// EditFeaturedGroupsModel.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.edit-featured-groups") + +/// The favorite-groups picker's choices, ordered selection and its one save. +/// +/// The calls are injected: `FlipClient` is a concrete class with a live gRPC channel, so a test +/// has nothing to fake. +@MainActor +@Observable +final class EditFeaturedGroupsModel { + + /// Where the save is. `.saved` is held by the screen for its checkmark before it pops. + enum SaveState: Equatable { + case normal + case saving + case saved + } + + /// Where the candidate list is. + enum LoadState: Equatable { + case loading + case loaded + /// The featured list could not be read, so a save could drop groups the screen never saw. + case failed + } + + /// Why a save failed, for the dialog. + enum Failure: Equatable { + /// The server refused a group as private. It does not say which. + case privateGroup + case other + } + + /// The groups on offer: the ones featured now, in their order, then the public groups the user + /// has joined. + private(set) var candidates: [Conversation] + + /// The chosen groups, in the order they will show on the profile. + private(set) var selection: [ConversationID] + + private(set) var state: SaveState = .normal + private(set) var loadState: LoadState = .loading + + /// Filters ``visibleCandidates`` by title. + var query = "" + + /// A failed save, shown as a dialog. Set to nil once shown. + var failure: Failure? + + @ObservationIgnored private var initialSelection: [ConversationID] + @ObservationIgnored private let loadingFeatured: () async -> [Conversation]? + @ObservationIgnored private let joinedGroups: () async -> [Conversation] + @ObservationIgnored private let saving: ([ConversationID]) async throws -> [Conversation] + @ObservationIgnored private let saved: ([Conversation]) -> Void + + /// - Parameters: + /// - featured: the groups the profile featured when last read, shown while they are re-read. + /// - loadingFeatured: re-reads the featured groups, in order; nil when that fails. + /// - joinedGroups: the groups the user is in, as the server says now. + /// - saving: submits the ordered selection and returns what is now featured. + /// - saved: hands that list to the rest of the app. + init( + featured: [Conversation], + loadingFeatured: @escaping () async -> [Conversation]?, + joinedGroups: @escaping () async -> [Conversation], + saving: @escaping ([ConversationID]) async throws -> [Conversation], + saved: @escaping ([Conversation]) -> Void + ) { + self.candidates = featured + self.selection = featured.map(\.id) + self.initialSelection = featured.map(\.id) + self.loadingFeatured = loadingFeatured + self.joinedGroups = joinedGroups + self.saving = saving + self.saved = saved + } + + /// The candidates whose title matches ``query``. + var visibleCandidates: [Conversation] { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return candidates } + return candidates.filter { $0.groupLinkTitle.localizedCaseInsensitiveContains(query) } + } + + /// Whether Save is enabled: only once the featured list has been read, so a save never drops + /// groups the screen did not know about. Clearing every group is a change like any other. + var canSave: Bool { + state == .normal && loadState == .loaded && selection != initialSelection + } + + /// Whether the group is chosen. + func isSelected(_ id: ConversationID) -> Bool { + selection.contains(id) + } + + /// Whether tapping the group would change anything: always for a chosen one, and for the rest + /// only while there is room under ``FeaturedGroups/limit``. + func canToggle(_ id: ConversationID) -> Bool { + state == .normal && (isSelected(id) || selection.count < FeaturedGroups.limit) + } + + /// Chooses the group at the end of the order, or drops it. + func toggle(_ id: ConversationID) { + guard canToggle(id) else { return } + if let index = selection.firstIndex(of: id) { + selection.remove(at: index) + } else { + selection.append(id) + } + } + + /// Re-reads the featured groups, then offers the user's joined public groups after them. + /// + /// Featured groups stay on offer even when the user has left them: leaving does not unfeature + /// a group, so dropping it from the list would leave no way to remove it. + func loadCandidates() async { + guard let featured = await loadingFeatured() else { + loadState = .failed + return + } + let featuredIDs = featured.map(\.id) + // A choice made while the list was loading stands; an untouched one follows the server. + if selection == initialSelection { + selection = featuredIDs + } + initialSelection = featuredIDs + + let joined = await joinedGroups() + let known = Set(featuredIDs) + candidates = featured + joined.filter { $0.type == .group && !$0.isPrivate && !known.contains($0.id) } + loadState = .loaded + } + + /// Submits the selection. Failures land in ``failure``. + func save() async { + guard canSave else { return } + + state = .saving + + do { + let result = try await saving(selection) + saved(result) + state = .saved + + } catch let error as ErrorSetFeaturedGroups where error == .denied { + state = .normal + logger.info("Featured groups denied") + ErrorReporting.captureError(error, reason: "Featured groups denied") + failure = .privateGroup + + } catch { + state = .normal + guard !Task.isCancelled else { return } + logger.error("Failed to set featured groups", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to set featured groups") + failure = .other + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift new file mode 100644 index 000000000..58ab3b392 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift @@ -0,0 +1,157 @@ +// +// EditFeaturedGroupsScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Choosing the public groups the profile features, pushed from Edit Profile. +struct EditFeaturedGroupsScreen: View { + + @Environment(AppRouter.self) private var router + + @State private var model: EditFeaturedGroupsModel + @State private var dialog: DialogItem? + @State private var saveTask: Task? + + /// Takes the model rather than building it, so the list opens on the groups featured now. + init(model: EditFeaturedGroupsModel) { + _model = State(initialValue: model) + } + + private var buttonState: ButtonState { + switch model.state { + case .normal: .normal + case .saving: .loading + case .saved: .success + } + } + + var body: some View { + @Bindable var model = model + + Background(color: .backgroundMain) { + list + .scrollEdgeBar(.bottom) { + Button(action: save) { + ButtonStateLabel("Save", state: buttonState) + } + .buttonStyle(.filled) + // The disabled fill is translucent; without this the rows show through it. + .background(Color.backgroundMain, in: RoundedRectangle(cornerRadius: Metrics.buttonRadius)) + .disabled(!model.canSave) + .accessibilityIdentifier("edit-featured-groups-save") + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 20) + } + } + .navigationTitle("Favorite Groups") + .navigationBarTitleDisplayMode(.inline) + .searchable( + text: $model.query, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search your public groups" + ) + .dialog(item: $dialog) + .task { await model.loadCandidates() } + .onChange(of: model.failure) { _, failure in + guard let failure else { return } + model.failure = nil + switch failure { + case .privateGroup: + dialog = .error(title: "Only Public Groups Can Be Featured", subtitle: "One of these groups is private. Remove it and try again") + case .other: + dialog = .error(title: "Couldn't Save Your Groups", subtitle: "Try again") + } + } + // Leaving the screen abandons the submission: its only continuation is a pop. + .onDisappear { saveTask?.cancel() } + } + + private var list: some View { + List { + Section { + // The list appears whole once the feed is read, rather than growing from the + // featured groups. The List itself stays so the search field keeps its place. + if model.loadState != .loading { + ForEach(model.visibleCandidates) { group in + row(group) + } + } + } header: { + ListHeader("\(model.selection.count) of \(FeaturedGroups.limit) selected") + } footer: { + if model.loadState != .loading, !model.candidates.isEmpty { + Text("These public groups will appear on your public profile") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + } + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20)) + } + .listStyle(.grouped) + .scrollContentBackground(.hidden) + .overlay { + if model.loadState == .loading { + ProgressView() + .tint(Color.textSecondary) + } else { + emptyState + } + } + } + + private func row(_ group: Conversation) -> some View { + Button { + model.toggle(group.id) + } label: { + FeaturedGroupRow(group: group) { + CheckView(active: model.isSelected(group.id)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(model.canToggle(group.id) ? 1 : 0.4) + .disabled(!model.canToggle(group.id)) + .listRowBackground(Color.backgroundMain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(group.featuredRowAccessibilityLabel)) + .accessibilityAddTraits(model.isSelected(group.id) ? [.isButton, .isSelected] : .isButton) + .accessibilityIdentifier("edit-featured-groups-row") + } + + @ViewBuilder + private var emptyState: some View { + if !model.query.isEmpty, model.visibleCandidates.isEmpty { + SearchResultsUnavailableView(searchText: model.query) + } else if model.loadState == .failed { + Text("Couldn't load your groups") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + } else if model.loadState == .loaded, model.candidates.isEmpty { + Text("Join a public group to feature it on your profile") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + } + + private func save() { + guard saveTask == nil else { return } + saveTask = Task { + await model.save() + if model.state == .saved { + // Same beat the other editors hold their checkmark for. + try? await Task.delay(milliseconds: 500) + guard !Task.isCancelled else { return } + router.popTopmost() + } + saveTask = nil + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift index 2dddb0917..71949a7fc 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -136,6 +136,11 @@ struct EditProfileScreen: View { router.push(.setMinimumTip(isSetupStep: false)) } .accessibilityIdentifier("edit-profile-minimum") + + FieldCard(title: "Favorite Public Groups", value: featuredGroupsSummary, placeholder: "Add groups") { + router.push(.editFeaturedGroups) + } + .accessibilityIdentifier("edit-profile-featured-groups") } } @@ -154,6 +159,13 @@ struct EditProfileScreen: View { } } + /// How many groups the profile features, or nil for none. + private var featuredGroupsSummary: String? { + let count = sessionContainer.featuredGroups.groups.count + guard count > 0 else { return nil } + return count == 1 ? "1 group" : "\(count) groups" + } + /// What others pay to start a chat, matching the You tab's stats card. private var minimumToChat: String? { StartChattingFee.amount(for: profile, session: session, ratesController: ratesController)?.formatted() diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index ce60bda46..a61610813 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -71,6 +71,11 @@ struct YouScreen: View { joinedAt: profile?.joinedAt ) .padding(.top, 19) + + FeaturedGroupsSection(groups: sessionContainer.featuredGroups.groups) { + router.push(.tipConversation($0)) + } + .padding(.top, 20) } } .padding(.bottom, bottomContentInset) @@ -101,6 +106,10 @@ struct YouScreen: View { } await sessionContainer.profileAvatars.load(userID: sessionContainer.session.userID, picture: profilePicture) } + .task(id: username) { + guard let username else { return } + await sessionContainer.featuredGroups.load(username: username) + } // A cash link raises the bill without touching the router, and it would draw under the card. .onChange(of: sessionContainer.session.isShowingBill) { _, isShowing in if isShowing { router.isShowingProfileCard = false } diff --git a/Flipcash/Core/Screens/Profile/FeaturedGroups.swift b/Flipcash/Core/Screens/Profile/FeaturedGroups.swift new file mode 100644 index 000000000..503ab84d7 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/FeaturedGroups.swift @@ -0,0 +1,50 @@ +// +// FeaturedGroups.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore + +private let logger = Logger(label: "flipcash.featured-groups") + +/// The public groups the signed-in user features on their profile, in the order they chose. +/// +/// One list read by the You tab, Edit Profile and the picker, so a save shows everywhere at once. +/// Kept apart from `ConversationStore`: the server answers list-view metadata with no members or +/// viewer state, and featuring a group does not require being in it. +@MainActor +@Observable +final class FeaturedGroups { + + /// The most featured groups a profile can carry; the server rejects more. + static let limit = 10 + + private(set) var groups: [Conversation] = [] + + @ObservationIgnored private let fetching: (Username) async throws -> [Conversation] + + /// - Parameter fetching: reads the groups a username features. + init(fetching: @escaping (Username) async throws -> [Conversation]) { + self.fetching = fetching + } + + /// Re-reads the list for `username`, returning whether it did. A failure keeps the list already shown. + @discardableResult + func load(username: Username) async -> Bool { + do { + groups = try await fetching(username) + return true + } catch { + guard !Task.isCancelled else { return false } + logger.error("Failed to load featured groups", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to load featured groups") + return false + } + } + + /// Takes the list a save returned, which is the server's word on what is now featured. + func replace(with groups: [Conversation]) { + self.groups = groups + } +} diff --git a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift new file mode 100644 index 000000000..7217e6e79 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift @@ -0,0 +1,103 @@ +// +// FeaturedGroupsSection.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The "Favorite Public Groups" section on a profile, one row per group in the owner's order. Draws +/// nothing when the list is empty. +struct FeaturedGroupsSection: View { + + let groups: [Conversation] + let onTap: (ConversationID) -> Void + + var body: some View { + if !groups.isEmpty { + VStack(alignment: .leading, spacing: 0) { + Color.rowSeparator + .frame(height: 1) + + Text("Favorite Public Groups") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .padding(.top, 24) + .padding(.bottom, 24) + .accessibilityAddTraits(.isHeader) + + VStack(spacing: 16) { + ForEach(groups) { group in + Button { onTap(group.id) } label: { + FeaturedGroupRow(group: group) { EmptyView() } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(group.featuredRowAccessibilityLabel)) + .accessibilityAddTraits(.isButton) + .accessibilityIdentifier("profile-featured-group") + } + } + } + .padding(.horizontal, ProfileHeaderMetrics.inset) + .accessibilityIdentifier("profile-featured-groups") + } + } +} + +/// A public group as profiles and the picker show it: its picture, title, and its description or +/// head count. Never names members, which a viewer outside the group must not see. +struct FeaturedGroupRow: View { + + let group: Conversation + @ViewBuilder let trailing: Trailing + + @Environment(SessionContainer.self) private var sessionContainer + + private var subtitle: String { group.featuredRowSubtitle } + + var body: some View { + HStack(spacing: 14) { + ContactAvatarView( + id: group.id.description, + displayName: group.groupLinkTitle, + imageData: sessionContainer.profileAvatars.data(for: .chat(group.id)), + blurhash: group.picture?.thumbnailBlurhash, + size: 48, + cornerRadius: 15 + ) + VStack(alignment: .leading, spacing: 3) { + Text(group.groupLinkTitle) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(1) + Text(subtitle) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .lineLimit(2) + } + Spacer(minLength: 12) + trailing + } + .task(id: group.picture?.thumbnailBlobID) { + await sessionContainer.profileAvatars.load(.chat(group.id), picture: group.picture) + } + } +} + +extension Conversation { + /// The secondary line of a featured-group row: the group's description, or its member count when it has none. + var featuredRowSubtitle: String { + if let description = description?.trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty { + return description + } + return rosterSummary.peopleCount + } + + /// The single VoiceOver label for a featured-group row: title, then subtitle. + var featuredRowAccessibilityLabel: String { + "\(groupLinkTitle), \(featuredRowSubtitle)" + } +} diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 38c465ce9..4cd312fa0 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -146,6 +146,11 @@ private struct UserProfileContent: View { ProfileStatsCard(minimumToChat: fee, joinedAt: model.joinedAt) .padding(.top, 20) + + FeaturedGroupsSection(groups: model.featuredGroups) { + router.push(.tipConversation($0)) + } + .padding(.top, 20) } .padding(.bottom, 24) } @@ -207,7 +212,10 @@ private struct UserProfileContent: View { .onAppear { if position == nil { position = router.positionOfTopmost() } } - .task { await model.loadProfile() } + .task { + await model.loadProfile() + await model.loadFeaturedGroups() + } } // MARK: - Status badges - @@ -426,6 +434,8 @@ final class UserProfileViewModel { private(set) var customization: TipCardCustomization? private(set) var joinedAt: Date? private(set) var minDmChatInitFee: FiatAmount? + /// The public groups this person features, in their order. + private(set) var featuredGroups: [Conversation] = [] /// 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 @@ -490,6 +500,18 @@ final class UserProfileViewModel { await profileAvatars.load(userID: userID, picture: profile.profilePicture) } + /// Reads the groups this person features, once their handle is known. A failure leaves the + /// section hidden; the rest of the profile stands. + func loadFeaturedGroups() async { + guard let username else { return } + do { + featuredGroups = try await flipClient.getFeaturedGroups(owner: owner, username: username) + } catch { + guard !Task.isCancelled else { return } + ErrorReporting.captureError(error, reason: "Failed to load featured groups") + } + } + private func apply(_ profile: Profile) { if let name = profile.displayName, !name.isEmpty { self.name = name } if let username = profile.username { self.username = username } diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 1d39d6a57..9a80b3a61 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -578,6 +578,9 @@ final class SessionContainer { /// The emoji the signed-in user reacts with most, for the reaction strip and picker. let recentReactions: RecentReactionsStore + /// The public groups the signed-in user features on their profile. + let featuredGroups: FeaturedGroups + init( session: Session, database: Database, @@ -714,6 +717,10 @@ final class SessionContainer { } let recentReactions = RecentReactionsStore(owner: owner.publicKey) self.recentReactions = recentReactions + self.featuredGroups = FeaturedGroups(fetching: { [weak flipClient] username in + guard let flipClient else { return [] } + return try await flipClient.getFeaturedGroups(owner: owner, username: username) + }) conversationController.reactions.recents = recentReactions conversationController.start() self.conversationController = conversationController diff --git a/FlipcashTests/EditFeaturedGroupsModelTests.swift b/FlipcashTests/EditFeaturedGroupsModelTests.swift new file mode 100644 index 000000000..e3d12532d --- /dev/null +++ b/FlipcashTests/EditFeaturedGroupsModelTests.swift @@ -0,0 +1,218 @@ +// +// EditFeaturedGroupsModelTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("EditFeaturedGroupsModel") +struct EditFeaturedGroupsModelTests { + + private struct Boom: Error {} + + @MainActor + private final class Calls { + var submitted: [[ConversationID]] = [] + var handedBack: [[Conversation]] = [] + } + + private func group(_ byte: UInt8, title: String? = nil, isPrivate: Bool = false, type: ConversationType = .group) -> Conversation { + Conversation( + id: ConversationID(data: Data(repeating: byte, count: 32)), + members: [], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + type: type, + title: title, + isPrivate: isPrivate + ) + } + + private func makeModel( + cached: [Conversation] = [], + featured: [Conversation]? = [], + joined: [Conversation] = [], + calls: Calls = Calls(), + saving: (([ConversationID]) async throws -> [Conversation])? = nil + ) -> EditFeaturedGroupsModel { + EditFeaturedGroupsModel( + featured: cached, + loadingFeatured: { featured }, + joinedGroups: { joined }, + saving: { ids in + calls.submitted.append(ids) + return try await saving?(ids) ?? [] + }, + saved: { calls.handedBack.append($0) } + ) + } + + @Test("Candidates are the featured groups in their order, then joined public groups") + func candidatesOrder() async { + let a = group(1), b = group(2), c = group(3), secret = group(4, isPrivate: true), dm = group(5, type: .tipDm) + let model = makeModel(featured: [b, a], joined: [a, c, secret, dm]) + + await model.loadCandidates() + + #expect(model.candidates.map(\.id) == [b.id, a.id, c.id]) + #expect(model.selection == [b.id, a.id]) + #expect(model.loadState == .loaded) + } + + @Test("A featured group the user has left stays on offer so it can be removed") + func leftGroupStays() async { + let left = group(1) + let model = makeModel(featured: [left], joined: []) + + await model.loadCandidates() + + #expect(model.candidates.map(\.id) == [left.id]) + #expect(model.isSelected(left.id)) + } + + @Test("Toggling appends at the end of the order and removes in place") + func toggleOrder() async { + let a = group(1), b = group(2), c = group(3) + let model = makeModel(featured: [a], joined: [b, c]) + await model.loadCandidates() + + model.toggle(c.id) + model.toggle(b.id) + #expect(model.selection == [a.id, c.id, b.id]) + + model.toggle(a.id) + #expect(model.selection == [c.id, b.id]) + } + + @Test("No more than ten can be chosen, and a chosen one can still be dropped") + func capsAtLimit() async { + let groups = (1...11).map { group(UInt8($0)) } + let model = makeModel(featured: [], joined: groups) + await model.loadCandidates() + + groups.prefix(FeaturedGroups.limit).forEach { model.toggle($0.id) } + #expect(model.selection.count == FeaturedGroups.limit) + #expect(!model.canToggle(groups[10].id)) + + model.toggle(groups[10].id) + #expect(model.selection.count == FeaturedGroups.limit) + + #expect(model.canToggle(groups[0].id)) + model.toggle(groups[0].id) + #expect(model.selection.count == FeaturedGroups.limit - 1) + } + + @Test("Save stays shut until the featured list is read, and while unchanged") + func canSave() async { + let a = group(1), b = group(2) + let model = makeModel(cached: [a], featured: [a], joined: [b]) + + model.toggle(a.id) + #expect(!model.canSave) + + await model.loadCandidates() + // A choice made while loading stands. + #expect(model.selection.isEmpty) + #expect(model.canSave) + + model.toggle(a.id) + #expect(!model.canSave) + } + + @Test("A failed read of the featured list keeps Save shut") + func failedLoad() async { + let a = group(1) + let model = makeModel(cached: [], featured: nil, joined: [a]) + + await model.loadCandidates() + model.toggle(a.id) + + #expect(model.loadState == .failed) + #expect(!model.canSave) + } + + @Test("An untouched selection follows the re-read list") + func untouchedSelectionFollowsServer() async { + let stale = group(1), fresh = group(2) + let model = makeModel(cached: [stale], featured: [fresh]) + + await model.loadCandidates() + + #expect(model.selection == [fresh.id]) + #expect(!model.canSave) + } + + @Test("Saving submits the ordered selection and hands back the server's list") + func saveSubmits() async { + let a = group(1), b = group(2) + let calls = Calls() + let model = makeModel(featured: [a], joined: [b], calls: calls) { _ in [b, a] } + await model.loadCandidates() + model.toggle(a.id) + model.toggle(b.id) + model.toggle(a.id) + + await model.save() + + #expect(calls.submitted == [[b.id, a.id]]) + #expect(calls.handedBack.map { $0.map(\.id) } == [[b.id, a.id]]) + #expect(model.state == .saved) + } + + @Test("Clearing every group saves an empty list") + func saveEmpty() async { + let a = group(1) + let calls = Calls() + let model = makeModel(featured: [a], calls: calls) + await model.loadCandidates() + model.toggle(a.id) + + await model.save() + + #expect(calls.submitted == [[]]) + } + + @Test("A denied save names the private-group failure and leaves the selection") + func deniedSave() async { + let a = group(1) + let model = makeModel(featured: [], joined: [a]) { _ in throw ErrorSetFeaturedGroups.denied } + await model.loadCandidates() + model.toggle(a.id) + + await model.save() + + #expect(model.failure == .privateGroup) + #expect(model.state == .normal) + #expect(model.selection == [a.id]) + } + + @Test("Any other failure is generic") + func otherFailure() async { + let a = group(1) + let model = makeModel(featured: [], joined: [a]) { _ in throw Boom() } + await model.loadCandidates() + model.toggle(a.id) + + await model.save() + + #expect(model.failure == .other) + #expect(model.state == .normal) + } + + @Test("Search filters by title, ignoring case") + func search() async { + let cats = group(1, title: "Cat People"), dogs = group(2, title: "Dog people") + let model = makeModel(featured: [], joined: [cats, dogs]) + await model.loadCandidates() + + model.query = "cat" + #expect(model.visibleCandidates.map(\.id) == [cats.id]) + + model.query = " " + #expect(model.visibleCandidates.count == 2) + } +} diff --git a/FlipcashTests/Navigation/SettingsRoutingTests.swift b/FlipcashTests/Navigation/SettingsRoutingTests.swift index e16994558..7aaaab98e 100644 --- a/FlipcashTests/Navigation/SettingsRoutingTests.swift +++ b/FlipcashTests/Navigation/SettingsRoutingTests.swift @@ -15,6 +15,7 @@ struct SettingsRoutingTests { AppRouter.Destination.accountInfo, AppRouter.Destination.editProfile, AppRouter.Destination.editBio, + AppRouter.Destination.editFeaturedGroups, AppRouter.Destination.changeCoverPicture, ]) func owningStack(destination: AppRouter.Destination) { diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift index d91675814..4ab272c6d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift @@ -21,8 +21,11 @@ public struct ContactAvatarView: View { /// The monogram's color. Full-strength by default; a surface whose design mutes the initials /// passes a dimmer one. public let initialsColor: Color + /// Rounds the avatar into a square with this corner radius; `nil` keeps it a circle. + public let cornerRadius: CGFloat? - public init(id: String, displayName: String, imageData: Data? = nil, blurhash: String? = nil, size: CGFloat = 44, initialsColor: Color = .textMain) { + public init(id: String, displayName: String, imageData: Data? = nil, blurhash: String? = nil, size: CGFloat = 44, initialsColor: Color = .textMain, cornerRadius: CGFloat? = nil) { + self.cornerRadius = cornerRadius self.id = id self.displayName = displayName self.imageData = imageData @@ -31,6 +34,14 @@ public struct ContactAvatarView: View { self.initialsColor = initialsColor } + private var clipShape: AnyShape { + if let cornerRadius { + AnyShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } else { + AnyShape(Circle()) + } + } + public var body: some View { Group { if let imageData, @@ -61,7 +72,7 @@ public struct ContactAvatarView: View { } } .frame(width: size, height: size) - .clipShape(Circle()) + .clipShape(clipShape) .accessibilityElement(children: .ignore) .accessibilityLabel(Text(displayName.isEmpty ? "Contact" : displayName)) .accessibilityAddTraits(.isImage)