From dbb59957df5b0bb546b399f18ca4e7769d05950f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 10:16:09 -0400 Subject: [PATCH 1/3] fix(protos): follow the DM and DmPayment renames in the flipcash2 contract --- .../Conversation/ChatPaymentMetadata.swift | 16 ++++++++-------- .../Models/Conversation/Conversation.swift | 4 ++-- .../ChatPaymentMetadataTests.swift | 8 ++++---- .../FlipcashCoreTests/IntentTransferTests.swift | 4 ++-- .../Push/NotificationPayloadTests.swift | 4 ++-- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift index f9ef1f5fb..ae6c86d78 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift @@ -53,7 +53,7 @@ public enum ChatPaymentMetadata: Sendable { $0.destination = .with { $0.value = destinationPhoneE164 } } case .tipDm(_, let origin, let action): - $0.tipDmPayment = .with { + $0.dmPayment = .with { $0.location = origin.proto $0.action = action.proto } @@ -74,9 +74,9 @@ public enum TipOrigin: Sendable { /// Sent from the Send Cash action inside an existing tip DM thread. case chat - var proto: Flipcash_Intent_V1_ChatMetadata.TipDmPayment.Location { + var proto: Flipcash_Intent_V1_ChatMetadata.DmPayment.Location { switch self { - case .tipcard: return .tipcard + case .tipcard: return .flipcard case .chat: return .chat } } @@ -87,11 +87,11 @@ public enum TipOrigin: Sendable { /// The proto's `Action.default` means "infer from location," which only /// exists so the server can stay compatible with clients built before this /// field shipped. This client has no such clients to be compatible with, so -/// `.default` has no local representation here: every `TipDmPayment` this +/// `.default` has no local representation here: every `DmPayment` this /// client builds sets `action` explicitly. That matters because proto3 gives -/// `Location` a zero value too (`TIPCARD`), so an unset `action` alongside a -/// `TIPCARD` location would be indistinguishable on the wire from a client -/// that deliberately declared a tip — `DEFAULT` and `TIPCARD` are both 0. +/// `Location` a zero value too (`FLIPCARD`), so an unset `action` alongside a +/// `FLIPCARD` location would be indistinguishable on the wire from a client +/// that deliberately declared a tip — `DEFAULT` and `FLIPCARD` are both 0. public enum TipDmAction: Sendable, CaseIterable { /// A Send Cash payment inside an already-initialized tip DM. @@ -100,7 +100,7 @@ public enum TipDmAction: Sendable, CaseIterable { /// A payment from a tip card, or the payment that opens the tip DM. case tip - var proto: Flipcash_Intent_V1_ChatMetadata.TipDmPayment.Action { + var proto: Flipcash_Intent_V1_ChatMetadata.DmPayment.Action { switch self { case .send: return .send case .tip: return .tip diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index 169eebd75..35813f5d5 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -88,7 +88,7 @@ extension ConversationType { var proto: Flipcash_Chat_V1_ChatType { switch self { case .contactDm: .contactDm - case .tipDm: .tipDm + case .tipDm: .dm case .group: .group } } @@ -97,7 +97,7 @@ extension ConversationType { /// legacy-client contract for the DM feed. init(_ proto: Flipcash_Chat_V1_ChatType) { switch proto { - case .tipDm: + case .dm: self = .tipDm case .group: self = .group diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatPaymentMetadataTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatPaymentMetadataTests.swift index 478f4e9c2..0f043ea1d 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ChatPaymentMetadataTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatPaymentMetadataTests.swift @@ -34,11 +34,11 @@ struct ChatPaymentMetadataTests { let decoded = try Flipcash_Intent_V1_AppMetadata(serializedBytes: metadata.serializedAppMetadata()) #expect(decoded.chat.chatID.value == chatID.data) - guard case .tipDmPayment(let payment) = decoded.chat.type else { + guard case .dmPayment(let payment) = decoded.chat.type else { Issue.record("Expected tipDmPayment, got \(String(describing: decoded.chat.type))") return } - #expect(payment.location == .tipcard) + #expect(payment.location == .flipcard) #expect(payment.action == .tip) } @@ -49,7 +49,7 @@ struct ChatPaymentMetadataTests { let decoded = try Flipcash_Intent_V1_AppMetadata(serializedBytes: metadata.serializedAppMetadata()) #expect(decoded.chat.chatID.value == chatID.data) - guard case .tipDmPayment(let payment) = decoded.chat.type else { + guard case .dmPayment(let payment) = decoded.chat.type else { Issue.record("Expected tipDmPayment, got \(String(describing: decoded.chat.type))") return } @@ -76,7 +76,7 @@ struct ChatPaymentMetadataTests { let decoded = try Flipcash_Intent_V1_AppMetadata(serializedBytes: metadata.serializedAppMetadata()) - guard case .tipDmPayment(let payment) = decoded.chat.type else { + guard case .dmPayment(let payment) = decoded.chat.type else { Issue.record("Expected tipDmPayment, got \(String(describing: decoded.chat.type))") return } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/IntentTransferTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/IntentTransferTests.swift index 4550d1d31..9f914671e 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/IntentTransferTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/IntentTransferTests.swift @@ -136,8 +136,8 @@ extension IntentTransferTests { return } #expect(chatMetadata.chatID.value == chatID.data) - #expect(chatMetadata.tipDmPayment.location == .chat) - #expect(chatMetadata.tipDmPayment.action == .send) + #expect(chatMetadata.dmPayment.location == .chat) + #expect(chatMetadata.dmPayment.action == .send) } private func makeIntent( diff --git a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift index 424052693..b2fe28a0d 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift @@ -143,7 +143,7 @@ struct NotificationPayloadTests { func chatTypeFromMetadata() throws { let payload = Flipcash_Push_V1_Payload.with { $0.category = .chat - $0.chatMetadata = .with { $0.type = .tipDm } + $0.chatMetadata = .with { $0.type = .dm } } let userInfo = [NotificationPayload.userInfoKey: try Self.base64(for: payload)] #expect(NotificationPayload.chatType(userInfo) == .tipDm) @@ -160,7 +160,7 @@ struct NotificationPayloadTests { func chatTypeNilForNonChatCategory() throws { let payload = Flipcash_Push_V1_Payload.with { $0.category = .default - $0.chatMetadata = .with { $0.type = .tipDm } + $0.chatMetadata = .with { $0.type = .dm } } let userInfo = [NotificationPayload.userInfoKey: try Self.base64(for: payload)] #expect(NotificationPayload.chatType(userInfo) == nil) From cbeb799179413bf130a7f2156f0828b476164694 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 10:16:09 -0400 Subject: [PATCH 2/3] feat(chat): map never speaker rule, widget content, and auto-assigned username --- .../Conversation/ConversationGate.swift | 6 +++ .../Conversation/ConversationGatePanel.swift | 7 ++- .../Conversation/ConversationScreen.swift | 2 +- .../Screens/Conversation/GroupLinkCard.swift | 2 +- .../Clients/Flip API/FlipClient+Profile.swift | 7 ++- .../Flip API/Services/ProfileService.swift | 4 +- .../Models/Conversation/ChatSeal.swift | 2 +- .../Conversation/ConversationMessage.swift | 6 ++- .../Conversation/ConversationRules.swift | 6 +++ .../Conversation/ShareProfileWidget.swift | 43 +++++++++++++++++++ .../ConversationModelMappingTests.swift | 28 +++++++++++- FlipcashTests/ConversationGateTests.swift | 8 ++++ 12 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift diff --git a/Flipcash/Core/Screens/Conversation/ConversationGate.swift b/Flipcash/Core/Screens/Conversation/ConversationGate.swift index b07f15809..511fd3958 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGate.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGate.swift @@ -28,6 +28,8 @@ enum ConversationGateRequirement: Equatable { case minimumBalance(amount: FiatAmount, mint: PublicKey?) /// Flipcash staff only. There is no action a user can take, so no CTA. case staff + /// Nobody may send here. There is no action a user can take, so no CTA. + case never } /// Whether one class of rules is met, and if not, which of them aren't. @@ -130,6 +132,8 @@ func conversationGate( switch rule { case .staff: return session.isStaff ? nil : .staff + case .never: + return .never case .minimumBalance(let requirement): return unmetBalance(requirement, session: session, rates: rates) } @@ -326,6 +330,8 @@ func groupRequirementLine(_ headline: ConversationGateRequirement?, mintName: St return "Balance Requirement:\n\(holding)" case .staff: return "This chat is for Flipcash staff" + case .never: + return nil case nil: return nil } diff --git a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift index 7159deffb..dc0e3d5a3 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift @@ -86,6 +86,8 @@ struct ConversationGatePanel: View { case .readOnly: return "Only Flipcash staff can send messages here" case .open, .undetermined, .join, .blocked: return "This chat is for Flipcash staff" } + case .never: + return "Messages can't be sent in this chat" } } @@ -121,8 +123,9 @@ struct ConversationGatePanel: View { case .minimumBalance(_, let mint): Button(addFundsTitle(mint: mint), action: onAddFunds) .buttonStyle(.filled) - case .staff: - // Nothing the user can do about being staff, so a button here would be a lie. + case .staff, .never: + // Nothing the user can do about being staff or about a chat nobody may post in, + // so a button here would be a lie. EmptyView() } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 82dac0e4f..deb234980 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -448,7 +448,7 @@ struct ConversationScreen: View { private static func mint(of requirement: ConversationGateRequirement) -> PublicKey? { switch requirement { case .minimumBalance(_, let mint): mint - case .staff: nil + case .staff, .never: nil } } diff --git a/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift b/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift index 89601d27a..4d8345685 100644 --- a/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift +++ b/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift @@ -91,7 +91,7 @@ nonisolated extension ConversationGateRequirement { var mint: PublicKey? { switch self { case .minimumBalance(_, let mint): mint - case .staff: nil + case .staff, .never: nil } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Profile.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Profile.swift index ffaeea772..56febfac0 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Profile.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Profile.swift @@ -29,8 +29,11 @@ extension FlipClient { } /// Sets the caller's display name, which the server moderates before it - /// persists. - public func setDisplayName(_ displayName: String, owner: KeyPair) async throws { + /// persists. Returns the username the server auto-assigned from the name, + /// or nil when it assigned none; callers should prefer it over assuming + /// the caller's username is unchanged. + @discardableResult + public func setDisplayName(_ displayName: String, owner: KeyPair) async throws -> Username? { try await profileService.setDisplayName(displayName, owner: owner) } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift index 67358cdce..3708598ed 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift @@ -64,7 +64,8 @@ final class ProfileService: Sendable { // Async-native, unlike `fetchProfile` above: a continuation over a detached // Task never propagates cancellation into the RPC. - func setDisplayName(_ displayName: String, owner: KeyPair) async throws { + @discardableResult + func setDisplayName(_ displayName: String, owner: KeyPair) async throws -> Username? { var request = Flipcash_Profile_V1_SetDisplayNameRequest() request.displayName = displayName request.auth = owner.authFor(message: request) @@ -75,6 +76,7 @@ final class ProfileService: Sendable { switch response.result { case .ok: logger.info("Display name set") + return response.hasUsername ? Username(response.username) : nil case .invalidDisplayName: throw ErrorProfile.invalidDisplayName case .denied: diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift index 108c2568b..9a975cd71 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift @@ -120,7 +120,7 @@ extension Flipcash_Messaging_V1_Content { case .reply(let reply): guard reply.content.count == 1, case .text(let text) = reply.content[0].type else { return nil } return (text.text, MessageID(reply.repliedMessageID)) - case .cash, .media, .system, .deleted, .encrypted, nil: + case .cash, .media, .system, .widget, .deleted, .encrypted, nil: return nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index bb5a8bddf..9bc90f1fd 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -299,8 +299,10 @@ extension ConversationMessage { ) self.cashAction = nil repliedTo = nil - // `.media`/`.system` are dropped by design: the message is not stored and not shown. - case .media, .system, .none: + // `.media`/`.system`/`.widget` are dropped by design: the message is not stored and not + // shown. A widget carries no fallback text, so a client that can't draw one renders + // nothing rather than a broken bubble. + case .media, .system, .widget, .none: return nil } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift index 1dfc455c9..da709fcff 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift @@ -227,6 +227,8 @@ extension ConversationListenerRule { public enum ConversationSpeakerRule: Hashable, Codable, Sendable { case minimumBalance(MinimumBalanceRequirement) case staff + /// Nobody can send messages; the chat is read-only for everyone. + case never } extension ConversationSpeakerRule { @@ -240,6 +242,8 @@ extension ConversationSpeakerRule { self = .minimumBalance(requirement) case .staff: self = .staff + case .never: + self = .never case nil: return nil } @@ -254,6 +258,8 @@ extension ConversationSpeakerRule { $0.minimumBalance = requirement.proto case .staff: $0.staff = .init() + case .never: + $0.never = .init() } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift new file mode 100644 index 000000000..becaa537b --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift @@ -0,0 +1,43 @@ +// +// ShareProfileWidget.swift +// FlipcashCore +// + +import Foundation +import FlipcashAPI + +/// A widget message that shares a user's profile. See `messaging.v1.ShareProfileWidget`. +public struct ShareProfileWidget: Hashable, Sendable { + + /// The handle of the profile being shared. + public let username: Username + + public init(username: Username) { + self.username = username + } +} + +extension ShareProfileWidget { + + /// Returns nil when `proto` carries no well-formed username. + init?(_ proto: Flipcash_Messaging_V1_ShareProfileWidget) { + guard let username = Username(proto.username) else { return nil } + self.username = username + } + + var proto: Flipcash_Messaging_V1_ShareProfileWidget { + .with { $0.username = username.proto } + } +} + +extension Flipcash_Messaging_V1_WidgetContent { + + /// The profile share this widget carries, or nil for any variant this + /// client doesn't recognize — callers treat that as unsupported content. + var shareProfile: ShareProfileWidget? { + switch type { + case .shareProfile(let widget): ShareProfileWidget(widget) + case nil: nil + } + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 3542720fa..74e359ecb 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -134,7 +134,7 @@ struct ConversationModelMappingTests { func dmMetadataMapsTipDmType() { let proto = Flipcash_Chat_V1_Metadata.with { $0.chatID = .with { $0.value = Data(repeating: 0xAB, count: 32) } - $0.type = .tipDm + $0.type = .dm } #expect(Conversation(proto).type == .tipDm) @@ -706,4 +706,30 @@ struct ConversationMessageContentEncodingTests { let deleted = ConversationMessage.Content.deleted(.init(deletedBy: nil, deletedAt: .now)) #expect(throws: ConversationMessageContentEncodingError.self) { try deleted.asProto() } } + + @Test("The never speaker rule round-trips through the proto") + func neverSpeakerRuleRoundTrips() { + let proto = ConversationSpeakerRule.never.proto + #expect(ConversationSpeakerRule(proto) == .never) + } + + @Test("A share-profile widget maps to its domain model") + func shareProfileWidgetMaps() throws { + let proto = Flipcash_Messaging_V1_WidgetContent.with { + $0.shareProfile = .with { $0.username = .with { $0.value = "alice" } } + } + #expect(proto.shareProfile == ShareProfileWidget(username: try #require(Username("alice")))) + #expect(Flipcash_Messaging_V1_WidgetContent().shareProfile == nil) + } + + @Test("A widget message is dropped like other content this client can't draw") + func widgetMessageIsDropped() { + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 9 } + $0.content = [.with { + $0.widget = .with { $0.shareProfile = .with { $0.username = .with { $0.value = "alice" } } } + }] + } + #expect(ConversationMessage(proto) == nil) + } } diff --git a/FlipcashTests/ConversationGateTests.swift b/FlipcashTests/ConversationGateTests.swift index a9e1d3c5e..ff512449e 100644 --- a/FlipcashTests/ConversationGateTests.swift +++ b/FlipcashTests/ConversationGateTests.swift @@ -72,6 +72,14 @@ struct ConversationGateTests { // MARK: - Staff + @Test("A never speaker rule leaves everyone, staff included, unable to send") + func neverSpeakerRule_unsatisfied() { + let rules = ConversationRules(speaker: [.never]) + let gate = conversationGate(session: StubHoldings(isStaff: true), rules: rules, rates: noRates) + #expect(gate.listener == .satisfied) + #expect(gate.speaker == .unsatisfied(unmet: [.never], primary: .never)) + } + @Test("A staff member satisfies a staff-only chat") func staffRule_staffUser_satisfied() { let rules = ConversationRules(listener: [.staff]) From 81a92beee1cb9a6f6153603c6b0545b0269f0926 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 10:17:09 -0400 Subject: [PATCH 3/3] chore(deps): bump flipcash2-client-protocol to 0.13.0 --- FlipcashAPI/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 10c2da2ab..2f5d7a29f 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -56,7 +56,7 @@ enum ContractPackage: String, CaseIterable { var version: Version { switch self { case .ocp: return "0.6.0" - case .flipcash2: return "0.12.0" + case .flipcash2: return "0.13.0" } }