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
6 changes: 6 additions & 0 deletions Flipcash/Core/Screens/Conversation/ConversationGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

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

Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Screens/Conversation/GroupLinkCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ nonisolated extension ConversationGateRequirement {
var mint: PublicKey? {
switch self {
case .minimumBalance(_, let mint): mint
case .staff: nil
case .staff, .never: nil
}
}
}
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.6.0"
case .flipcash2: return "0.12.0"
case .flipcash2: return "0.13.0"
}
}

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -240,6 +242,8 @@ extension ConversationSpeakerRule {
self = .minimumBalance(requirement)
case .staff:
self = .staff
case .never:
self = .never
case nil:
return nil
}
Expand All @@ -254,6 +258,8 @@ extension ConversationSpeakerRule {
$0.minimumBalance = requirement.proto
case .staff:
$0.staff = .init()
case .never:
$0.never = .init()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions FlipcashTests/ConversationGateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading