Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Flipcash/Core/Controllers/ProfileAvatarStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ final class ProfileAvatarStore {
try await flipClient.blobDownloadURL(
blobID: blobID,
owner: owner,
accessContext: .profile(userID)
accessContext: .userProfile(userID)
)
}
)
Expand Down
2 changes: 1 addition & 1 deletion FlipcashAPI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ enum ContractPackage: String, CaseIterable {
var version: Version {
switch self {
case .ocp: return "0.3.0"
case .flipcash2: return "0.5.0"
case .flipcash2: return "0.6.0"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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) }
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
Loading