From f418ffcb3d06a24802a4ed84725ee383b21327c9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 14 Sep 2026 15:56:00 -0400 Subject: [PATCH 1/2] chore(chat): scaffold roster summary, group picture, and participation rules flipcash2-protobuf-api 797052dd -> 35f99814 renames Blob.AccessContext.profile to user_profile, adds a chat_profile scope arm, and adds Metadata.picture, roster_summary, and rules to chat.v1. Carry the rename through BlobAccessContext and wire the new Metadata fields onto Conversation: rosterSummary (defaulting to memberCount/version 0), picture, and the new ConversationRules/ConversationListenerRule/ConversationSpeakerRule types in ConversationRules.swift, mirroring the MinimumBalanceRequirement currency-drop behavior already used for FiatAmount elsewhere in the model layer. BlobAccessContext gains .chatProfile for reading a group chat current picture, but nothing constructs it yet -- no call site needs a chat-scoped blob URL today. The SQLite cache (Database+Conversations.swift) also does not persist picture/rosterSummary/rules yet, so a relaunch drops them until the next fetch; both are follow-up work once there is a consumer, not scaffolding gaps to paper over here. --- .../Core/Controllers/ProfileAvatarStore.swift | 2 +- .../Flip API/Services/BlobService.swift | 14 +- .../Models/Conversation/Conversation.swift | 20 ++- .../Conversation/ConversationRules.swift | 150 ++++++++++++++++++ .../ConversationModelMappingTests.swift | 65 ++++++++ 5 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift diff --git a/Flipcash/Core/Controllers/ProfileAvatarStore.swift b/Flipcash/Core/Controllers/ProfileAvatarStore.swift index c77687bd9..93e201483 100644 --- a/Flipcash/Core/Controllers/ProfileAvatarStore.swift +++ b/Flipcash/Core/Controllers/ProfileAvatarStore.swift @@ -51,7 +51,7 @@ final class ProfileAvatarStore { try await flipClient.blobDownloadURL( blobID: blobID, owner: owner, - accessContext: .profile(userID) + accessContext: .userProfile(userID) ) } ) diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift index e531a9d22..f592f9985 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift @@ -177,12 +177,20 @@ extension ErrorBlob: ServerError { public enum BlobAccessContext: Sendable { /// Reading a rendition of `userID`'s current profile picture. - case profile(UserID) + case userProfile(UserID) + + /// Reading a rendition of `conversationID`'s current group chat profile + /// picture. Authorized only while the blob is a rendition of that chat's + /// CURRENT picture; a superseded picture's renditions stop resolving + /// through it. + case chatProfile(ConversationID) var proto: Flipcash_Blob_V1_AccessContext { switch self { - case .profile(let userID): - return .with { $0.profile = .with { $0.value = userID.data } } + case .userProfile(let userID): + return .with { $0.userProfile = .with { $0.value = userID.data } } + case .chatProfile(let conversationID): + return .with { $0.chatProfile = conversationID.proto } } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index 375df366a..4b7fd0929 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -33,7 +33,19 @@ public struct Conversation: Identifiable, Hashable, Sendable { /// "unknown", not "empty". public var latestEventSequence: UInt64 - public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm, isHidden: Bool = false, title: String? = nil, latestEventSequence: UInt64 = 0) { + /// The chat's picture. Only ever set for group chats. + public var picture: ProfilePicture? + + /// Summary of the chat's roster. Tells whether ``members`` — a subset for + /// a large group chat — is stale without needing to hold the full list. + /// See ``ConversationRosterSummary``. + public var rosterSummary: ConversationRosterSummary + + /// Requirements a user must satisfy to participate. Only ever set for + /// group chats; `nil` means the chat has no participation requirements. + public var rules: ConversationRules? + + public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm, isHidden: Bool = false, title: String? = nil, latestEventSequence: UInt64 = 0, picture: ProfilePicture? = nil, rosterSummary: ConversationRosterSummary = ConversationRosterSummary(memberCount: 0, version: 0), rules: ConversationRules? = nil) { self.id = id self.members = members self.lastMessage = lastMessage @@ -42,6 +54,9 @@ public struct Conversation: Identifiable, Hashable, Sendable { self.isHidden = isHidden self.title = title self.latestEventSequence = latestEventSequence + self.picture = picture + self.rosterSummary = rosterSummary + self.rules = rules } } @@ -88,6 +103,9 @@ extension Conversation { // DMs (which never carry a title) and untitled groups behave the same. self.title = proto.title.isEmpty ? nil : proto.title self.latestEventSequence = proto.latestEventSequence + self.picture = proto.hasPicture ? ProfilePicture(proto.picture) : nil + self.rosterSummary = ConversationRosterSummary(proto.rosterSummary) + self.rules = proto.hasRules ? ConversationRules(proto.rules) : nil } /// The member that isn't the signed-in user, used to title the conversation. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift new file mode 100644 index 000000000..7e3dbfea4 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift @@ -0,0 +1,150 @@ +// +// ConversationRules.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +/// Summary of a chat's roster — its member list — without containing it: what +/// a client needs to know whether its copy of ``Conversation/members`` is +/// stale, without holding the full list. See `chat.v1.RosterSummary`. +public struct ConversationRosterSummary: Hashable, Sendable { + + /// True number of currently joined members. ``Conversation/members`` is + /// only a subset for a large group chat; this is its real size. + public let memberCount: UInt64 + + /// Opaque version, advanced by exactly one on every change to the + /// membership records (a join, a leave, or a future per-member change + /// such as a role) and never on an idempotent no-op or a profile change. + /// + /// Compare against the last value seen: a different value means the + /// cached member list may be stale and should be refetched. On a stream, + /// apply a greater value and drop the rest — delivery order does not + /// matter. There is no delta to fetch against it, only a refetch of the + /// members. + public let version: UInt64 + + public init(memberCount: UInt64, version: UInt64) { + self.memberCount = memberCount + self.version = version + } +} + +extension ConversationRosterSummary { + public init(_ proto: Flipcash_Chat_V1_RosterSummary) { + self.init(memberCount: proto.memberCount, version: proto.version) + } +} + +/// Requirements a user must satisfy to participate in a group chat. Only +/// ever set for group chats; unset means the chat has no participation +/// requirements. See `chat.v1.Rules`. +public struct ConversationRules: Hashable, Sendable { + + /// Requirements to read and join the chat. Empty means anyone can. + public var listener: [ConversationListenerRule] + + /// Requirements to send messages in the chat, applied in addition to + /// ``listener`` — a user must be able to listen before they can speak. + /// Empty means any member can send. + public var speaker: [ConversationSpeakerRule] + + public init(listener: [ConversationListenerRule] = [], speaker: [ConversationSpeakerRule] = []) { + self.listener = listener + self.speaker = speaker + } +} + +extension ConversationRules { + public init(_ proto: Flipcash_Chat_V1_Rules) { + self.init( + listener: proto.listener.compactMap(ConversationListenerRule.init), + speaker: proto.speaker.compactMap(ConversationSpeakerRule.init) + ) + } +} + +/// A single requirement gating reading and joining a chat. See +/// `chat.v1.ListenerRules`. +public enum ConversationListenerRule: Hashable, Sendable { + case minimumBalance(MinimumBalanceRequirement) + case staff +} + +extension ConversationListenerRule { + /// Returns nil when `proto` carries neither arm of the `kind` oneof, or a + /// `minimumBalance` requirement in a currency this client doesn't + /// recognize. + init?(_ proto: Flipcash_Chat_V1_ListenerRules) { + switch proto.kind { + case .minimumBalance(let requirement): + guard let requirement = MinimumBalanceRequirement(requirement) else { return nil } + self = .minimumBalance(requirement) + case .staff: + self = .staff + case nil: + return nil + } + } +} + +/// A single requirement gating sending messages in a chat. See +/// `chat.v1.SpeakerRules`. +public enum ConversationSpeakerRule: Hashable, Sendable { + case minimumBalance(MinimumBalanceRequirement) + case staff +} + +extension ConversationSpeakerRule { + /// Returns nil when `proto` carries neither arm of the `kind` oneof, or a + /// `minimumBalance` requirement in a currency this client doesn't + /// recognize. + init?(_ proto: Flipcash_Chat_V1_SpeakerRules) { + switch proto.kind { + case .minimumBalance(let requirement): + guard let requirement = MinimumBalanceRequirement(requirement) else { return nil } + self = .minimumBalance(requirement) + case .staff: + self = .staff + case nil: + return nil + } + } +} + +/// Requires holding a minimum balance, denominated in fiat, in an acceptable +/// mint. See `chat.v1.MinimumBalanceRequirement`. +public struct MinimumBalanceRequirement: Hashable, Sendable { + + /// The minimum balance, denominated in fiat. + public let amount: FiatAmount + + /// The mints the balance may be held in. Empty means the requirement + /// applies across all mints; otherwise it applies only to the one listed + /// mint. Repeated so multiple mints can be specified in the future. + public let mints: [PublicKey] + + public init(amount: FiatAmount, mints: [PublicKey] = []) { + self.amount = amount + self.mints = mints + } +} + +extension MinimumBalanceRequirement { + /// Returns nil for a currency code this client doesn't recognize. + /// Malformed mint entries are dropped individually rather than failing + /// the whole requirement. + init?(_ proto: Flipcash_Chat_V1_MinimumBalanceRequirement) { + guard let currency = CurrencyCode(rawValue: proto.amount.currency.lowercased()) else { + return nil + } + self.init( + amount: FiatAmount(value: Decimal(proto.amount.nativeAmount), currency: currency), + mints: proto.mints.compactMap { try? PublicKey($0.value) } + ) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 9a27d4441..b164e9856 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -173,6 +173,71 @@ struct ConversationModelMappingTests { #expect(Conversation(proto).title == nil) } + @Test("Metadata maps roster summary, group picture, and rules") + func dmMetadataMapsRosterSummaryPictureAndRules() { + let proto = Flipcash_Chat_V1_Metadata.with { + $0.chatID = .with { $0.value = Data(repeating: 0xAB, count: 32) } + $0.type = .group + $0.rosterSummary = .with { + $0.memberCount = 12 + $0.version = 3 + } + $0.picture = .with { + $0.renditions = [.with { + $0.role = .original + $0.blobID = .with { $0.value = Data(repeating: 0x01, count: 16) } + }] + } + $0.rules = .with { + $0.listener = [.with { $0.staff = .init() }] + $0.speaker = [.with { + $0.minimumBalance = .with { + $0.amount = .with { + $0.currency = "usd" + $0.nativeAmount = 5.0 + } + } + }] + } + } + + let conversation = Conversation(proto) + #expect(conversation.rosterSummary == ConversationRosterSummary(memberCount: 12, version: 3)) + #expect(conversation.picture != nil) + #expect(conversation.rules?.listener == [.staff]) + #expect(conversation.rules?.speaker == [.minimumBalance(MinimumBalanceRequirement(amount: .usd(5.0)))]) + } + + @Test("Metadata without roster summary or rules maps to defaults") + func dmMetadataWithoutRosterSummaryOrRulesMapsToDefaults() { + let proto = Flipcash_Chat_V1_Metadata.with { + $0.chatID = .with { $0.value = Data(repeating: 0xAB, count: 32) } + $0.type = .contactDm + } + + let conversation = Conversation(proto) + #expect(conversation.rosterSummary == ConversationRosterSummary(memberCount: 0, version: 0)) + #expect(conversation.picture == nil) + #expect(conversation.rules == nil) + } + + @Test("A rules requirement in an unrecognized currency is dropped") + func dmMetadataRulesWithUnrecognizedCurrencyIsDropped() { + let proto = Flipcash_Chat_V1_Metadata.with { + $0.chatID = .with { $0.value = Data(repeating: 0xAB, count: 32) } + $0.type = .group + $0.rules = .with { + $0.listener = [.with { + $0.minimumBalance = .with { + $0.amount = .with { $0.currency = "zzz" } + } + }] + } + } + + #expect(Conversation(proto).rules?.listener == []) + } + @Test("ConversationType round-trips through its proto value") func conversationTypeRoundTripsThroughProto() { for type in [ConversationType.contactDm, .tipDm, .group] { From 2618086abad706299dcbaedb5b8b65fe8df26c74 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 14 Sep 2026 16:04:12 -0400 Subject: [PATCH 2/2] chore(deps): pin flipcash2-client-protocol 0.6.0 0.6.0 is unpublished until flipcash2-client-protocol's sync PR merges and publish.yml runs, so the exact: requirement has no tag to resolve until then. The scaffold commit ahead of this builds against the local checkout through FLIPCASH_PROTO_LOCAL, which CI never sees. --- FlipcashAPI/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 075cfa234..69c2ba671 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -35,7 +35,7 @@ let contractDependencies: [Package.Dependency] = protoLocalRoot.map { root in ] } ?? [ .package(url: "https://github.com/code-payments/ocp-client-protocol", exact: "0.3.0"), - .package(url: "https://github.com/code-payments/flipcash2-client-protocol", exact: "0.5.0"), + .package(url: "https://github.com/code-payments/flipcash2-client-protocol", exact: "0.6.0"), ] let package = Package(