Skip to content
Merged
26 changes: 26 additions & 0 deletions .claude/plans/2026-10-06-featured-groups.md
Original file line number Diff line number Diff line change
@@ -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`.
7 changes: 5 additions & 2 deletions Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
167 changes: 167 additions & 0 deletions Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsModel.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Loading