diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 760261da0..1f2701f99 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e25f0139915b45eeac7692830aee0bb9b7878d88877c2ed56e231f4530b4a416", + "originHash" : "d61d3d0a8bd1078b5f85476541742804facb4c56b3b33fa2f04e283ed039e8be", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "4c1315854e44e3f2c6e15f100e6fa819e01eeb9e", - "version" : "0.18.0" + "revision" : "3f3e29b416a50047854984a5155c136c6489d455", + "version" : "0.19.0" } }, { @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/ocp-client-protocol", "state" : { - "revision" : "65a7cf673e77286c24e497f5f097c950bbae00d2", - "version" : "0.6.0" + "revision" : "756e3724c217f556d9cb8799037f5ff8a216ac06", + "version" : "0.7.0" } }, { diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 6739d163b..9435e29ee 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -55,8 +55,8 @@ enum ContractPackage: String, CaseIterable { /// The pinned version consumed when this package isn't building against a local checkout. var version: Version { switch self { - case .ocp: return "0.6.0" - case .flipcash2: return "0.18.0" + case .ocp: return "0.7.0" + case .flipcash2: return "0.19.0" } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Poll.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Poll.swift new file mode 100644 index 000000000..ef5606bb8 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Poll.swift @@ -0,0 +1,36 @@ +// +// FlipClient+Poll.swift +// FlipcashCore +// + +import Foundation + +extension FlipClient { + + /// Opens a poll in a group chat. `idempotencyKey` must be minted where the user's intent to + /// create the poll begins; a retry of the same intent reuses it. + public func createPoll(owner: KeyPair, conversationID: ConversationID, definition: PollDefinition, idempotencyKey: UUID) async throws -> Poll { + try await withCheckedThrowingContinuation { c in + pollService.createPoll(owner: owner, conversationID: conversationID, definition: definition, idempotencyKey: idempotencyKey) { c.resume(with: $0) } + } + } + + public func getPoll(owner: KeyPair, pollID: PollID) async throws -> Poll { + try await withCheckedThrowingContinuation { c in + pollService.getPoll(owner: owner, pollID: pollID) { c.resume(with: $0) } + } + } + + public func getPolls(owner: KeyPair, pollIDs: [PollID]) async throws -> [Poll] { + try await withCheckedThrowingContinuation { c in + pollService.getPolls(owner: owner, pollIDs: pollIDs) { c.resume(with: $0) } + } + } + + /// Records the viewer's selection, replacing any earlier one, and returns the updated state. + public func vote(owner: KeyPair, pollID: PollID, optionIndices: [Int]) async throws -> PollState { + try await withCheckedThrowingContinuation { c in + pollService.vote(owner: owner, pollID: pollID, optionIndices: optionIndices) { c.resume(with: $0) } + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift index 7f0cb23aa..b0cd2ef36 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift @@ -39,6 +39,7 @@ public class FlipClient: ObservableObject { internal let chatMessagingService: ChatMessagingService internal let blocklistService: BlocklistService internal let reportingService: ReportingService + internal let pollService: PollService /// The single per-user event stream. Started on login, stopped on logout. public let eventStreamer: EventStreamer @@ -87,6 +88,7 @@ public class FlipClient: ObservableObject { self.chatMessagingService = ChatMessagingService(client: client) self.blocklistService = BlocklistService(client: client) self.reportingService = ReportingService(client: client) + self.pollService = PollService(client: client) self.eventStreamer = EventStreamer(service: EventStreamingService(client: client)) } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PollService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PollService.swift new file mode 100644 index 000000000..08949f506 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PollService.swift @@ -0,0 +1,241 @@ +// +// PollService.swift +// FlipcashCore +// + +import Foundation +import FlipcashAPI +import GRPCCore + +private let logger = Logger(label: "flipcash.poll-service") + +final class PollService: Sendable { + + private let service: Flipcash_Poll_V1_Poll.Client + + init(client: GRPCClient) { + self.service = Flipcash_Poll_V1_Poll.Client(wrapping: client) + } + + /// Opens a poll in a group chat. `idempotencyKey` must be minted where the user's intent to + /// create the poll begins, so a retry of the same intent cannot create a second poll. + func createPoll(owner: KeyPair, conversationID: ConversationID, definition: PollDefinition, idempotencyKey: UUID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Poll_V1_CreatePollRequest.with { + $0.chatID = conversationID.proto + $0.definition = definition.proto + $0.idempotencyKey = .with { $0.value = idempotencyKey.data } + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.createPoll(request, options: .unaryDefault) + guard response.result == .ok, response.hasPoll else { + logger.error("Failed to create poll") + let error = ErrorCreatePoll(response.result, flaggedCategory: response.flaggedCategory) + await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + return + } + await MainActor.run { completion(.success(Poll(response.poll))) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + func getPoll(owner: KeyPair, pollID: PollID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Poll_V1_GetPollRequest.with { + $0.pollID = pollID.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.getPoll(request, options: .unaryDefault) + let error = ErrorGetPoll(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok, response.hasPoll else { + logger.error("Failed to get poll") + await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + return + } + await MainActor.run { completion(.success(Poll(response.poll))) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// Fetches up to 100 polls in one call. Polls the server doesn't return (unknown or not + /// visible to the caller) are simply absent from the result. + func getPolls(owner: KeyPair, pollIDs: [PollID], completion: @Sendable @escaping (Result<[Poll], ErrorGetPolls>) -> Void) { + let request = Flipcash_Poll_V1_GetPollsRequest.with { + $0.pollIds = pollIDs.map(\.proto) + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.getPolls(request, options: .unaryDefault) + let error = ErrorGetPolls(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to get polls") + await MainActor.run { completion(.failure(error)) } + return + } + await MainActor.run { completion(.success(response.polls.map(Poll.init))) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// Records the viewer's selection, replacing any earlier one, and returns the updated state. + func vote(owner: KeyPair, pollID: PollID, optionIndices: [Int], completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Poll_V1_VoteRequest.with { + $0.pollID = pollID.proto + $0.optionIndices = optionIndices.map { UInt32($0) } + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.vote(request, options: .unaryDefault) + let error = ErrorVote(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok, response.hasState else { + logger.error("Failed to vote") + await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + return + } + await MainActor.run { completion(.success(PollState(response.state))) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } +} + +// MARK: - Errors - + +/// Associated-value error, modelled on `ErrorStartChat`, so `.textModerated` can carry the +/// `flaggedCategory` the server reports for it. `.ok` exists only so `init(_:flaggedCategory:)` is +/// total over the proto enum; a success response resolves to `Poll` and never reaches this type. +public enum ErrorCreatePoll: Error, Sendable, Equatable { + case ok + case denied + case chatNotSupported + case encryptionNotAllowed + case encryptionRequired + case optionCountMismatch + case invalidEndsAt + case invalidBalanceParameters + case textModerated(Flipcash_Moderation_V1_FlaggedCategory) + case unknown + case transportFailure + case cancelled + case rejected +} + +public enum ErrorGetPoll: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorGetPolls: Int, Error { + case ok + case denied + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorVote: Int, Error { + case ok + case denied + case notFound + case pollClosed + case invalidSelection + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +extension ErrorCreatePoll: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .chatNotSupported, .encryptionNotAllowed, .encryptionRequired, .textModerated: .info + // Validation the client is expected to satisfy before sending; a hit is a client defect. + case .optionCountMismatch, .invalidEndsAt, .invalidBalanceParameters: .error + case .unknown, .rejected: .error + } + } +} + +extension ErrorCreatePoll { + /// Maps `CreatePollResponse.Result` to its domain error, carrying `flaggedCategory` through on + /// `.textModerated`. Pure so the mapping is unit-testable without a live RPC. + init(_ result: Flipcash_Poll_V1_CreatePollResponse.Result, flaggedCategory: Flipcash_Moderation_V1_FlaggedCategory) { + switch result { + case .ok: self = .ok + case .denied: self = .denied + case .chatNotSupported: self = .chatNotSupported + case .encryptionNotAllowed: self = .encryptionNotAllowed + case .encryptionRequired: self = .encryptionRequired + case .optionCountMismatch: self = .optionCountMismatch + case .invalidEndsAt: self = .invalidEndsAt + case .invalidBalanceParameters: self = .invalidBalanceParameters + case .textModerated: self = .textModerated(flaggedCategory) + case .UNRECOGNIZED: self = .unknown + } + } +} + +extension ErrorGetPoll: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorGetPolls: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorVote: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound, .pollClosed: .info + case .invalidSelection: .error + case .unknown, .rejected: .error + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift index ae6c86d78..9dc817579 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift @@ -17,14 +17,19 @@ public enum ChatPaymentMetadata: Sendable { case contactDm(chatID: ConversationID, sourcePhoneE164: String, destinationPhoneE164: String) case tipDm(chatID: ConversationID, origin: TipOrigin, action: TipDmAction) + /// A Send Cash payment to a member of a group chat. The destination owner account names the + /// recipient; the server denies a tip, so the action is always `SEND`. + case groupPayment(chatID: ConversationID) - /// The DM chat this payment posts into. + /// The chat this payment posts into. public var chatID: ConversationID { switch self { case .contactDm(let chatID, _, _): return chatID case .tipDm(let chatID, _, _): return chatID + case .groupPayment(let chatID): + return chatID } } @@ -33,7 +38,7 @@ public enum ChatPaymentMetadata: Sendable { /// carries instead of re-deriving it. public var isTip: Bool { switch self { - case .contactDm: + case .contactDm, .groupPayment: return false case .tipDm(_, _, let action): return action == .tip @@ -57,6 +62,8 @@ public enum ChatPaymentMetadata: Sendable { $0.location = origin.proto $0.action = action.proto } + case .groupPayment: + $0.groupPayment = .with { $0.action = .send } } } }.serializedData() @@ -100,7 +107,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.DmPayment.Action { + var proto: Flipcash_Intent_V1_PaymentAction { switch self { case .send: return .send case .tip: return .tip diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 3081b392b..690a1f13f 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -100,6 +100,10 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { public let content: Content /// How a `.cash` message was delivered (sent vs. tipped); `nil` for non-cash content. public let cashAction: CashAction? + /// The member a group-chat `.cash` payment was sent to; `nil` for DM cash (the counterpart is + /// implicit) and for non-cash content. Set only on messages built from the wire proto; it is + /// not persisted, so a message read back from the local store has `nil`. + public let cashReceiverID: UserID? public let date: Date public let unreadSeq: UInt64 /// The event-log version at which this message reached its current state, @@ -143,6 +147,7 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { senderID: UserID?, content: Content, cashAction: CashAction? = nil, + cashReceiverID: UserID? = nil, date: Date, unreadSeq: UInt64, eventSequence: UInt64 = 0, @@ -159,6 +164,7 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { self.senderID = senderID self.content = content self.cashAction = cashAction + self.cashReceiverID = cashReceiverID self.date = date self.unreadSeq = unreadSeq self.eventSequence = eventSequence @@ -200,6 +206,7 @@ extension ConversationMessage { senderID: senderID, content: content, cashAction: cashAction, + cashReceiverID: cashReceiverID, date: date, unreadSeq: unreadSeq, eventSequence: eventSequence, @@ -260,6 +267,7 @@ extension ConversationMessage { senderID: senderID, content: content, cashAction: cashAction, + cashReceiverID: cashReceiverID, date: date, unreadSeq: unreadSeq, eventSequence: eventSequence, @@ -308,6 +316,7 @@ extension ConversationMessage { /// and can't leave the pre-delete content on screen. public init?(_ proto: Flipcash_Messaging_V1_Message) { let repliedTo: MessageID? + var cashReceiverID: UserID? switch proto.content.first?.type { case .text(let textContent): self.content = .text(textContent.text) @@ -320,6 +329,7 @@ extension ConversationMessage { self.content = .cash(amount) // Unrecognized verbs fall back to `.sent`, per the proto contract. self.cashAction = cashContent.verb == .tipped ? .tipped : .sent + cashReceiverID = cashContent.hasReceiver ? try? UUID(data: cashContent.receiver.value) : nil repliedTo = nil case .deleted(let deletedContent): self.content = .deleted( @@ -363,7 +373,8 @@ extension ConversationMessage { repliedTo = nil case .widget(let widgetContent): // Kept even when the variant is unknown: a widget has no fallback text, so the contract - // is that a client which can't draw one shows the message as unsupported. + // is that a client which can't draw one shows the message as unsupported. A poll widget + // is such a variant until the app can draw one. self.content = .widget(widgetContent.shareProfile.map(Widget.shareProfile) ?? .unrecognized) self.cashAction = nil repliedTo = nil @@ -376,6 +387,7 @@ extension ConversationMessage { } self.id = MessageID(proto.messageID) + self.cashReceiverID = cashReceiverID self.senderID = try? UUID(data: proto.senderID.value) self.date = proto.hasTs ? proto.ts.date : .now self.unreadSeq = proto.unreadSeq @@ -440,7 +452,8 @@ extension ConversationMessage.Content { } /// The `.system` content `proto` describes. Blank text becomes `""` and is kept, not dropped, so the - /// message still anchors the read pointer and the unread divider. + /// message still anchors the read pointer and the unread divider. Only `fallback_text` is read, so + /// every `type` variant (`UserJoined` today) and an unset one render as their fallback text. init(_ proto: Flipcash_Messaging_V1_SystemContent) { let text = proto.fallbackText self = .system(fallbackText: text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "" : text) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift index becaa537b..e6f86c0ab 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift @@ -37,7 +37,8 @@ extension Flipcash_Messaging_V1_WidgetContent { var shareProfile: ShareProfileWidget? { switch type { case .shareProfile(let widget): ShareProfileWidget(widget) - case nil: nil + // A poll widget has no local representation yet; it degrades to `.unrecognized` upstream. + case .poll, nil: nil } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Poll/Poll.swift b/FlipcashCore/Sources/FlipcashCore/Models/Poll/Poll.swift new file mode 100644 index 000000000..bf1a19088 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Poll/Poll.swift @@ -0,0 +1,226 @@ +// +// Poll.swift +// FlipcashCore +// + +import Foundation +import FlipcashAPI + +/// The server-assigned identifier of a poll. +public struct PollID: Hashable, Sendable { + + public let data: Data + + public init(data: Data) { + self.data = data + } + + public init(_ proto: Flipcash_Poll_V1_PollId) { + self.data = proto.value + } + + var proto: Flipcash_Poll_V1_PollId { + .with { $0.value = data } + } +} + +/// A poll as the server reports it: what was asked, who asked, and the current tally. +public struct Poll: Hashable, Sendable { + + public let id: PollID + public let conversationID: ConversationID + /// `nil` when the wire user id isn't a valid UUID. + public let creator: UserID? + public let definition: PollDefinition + public let createdAt: Date + public let state: PollState + + init(_ proto: Flipcash_Poll_V1_PollMetadata) { + self.id = PollID(proto.pollID) + self.conversationID = ConversationID(proto.chatID) + self.creator = try? UUID(data: proto.creator.value) + self.definition = PollDefinition(proto.definition) + self.createdAt = proto.createdAt.date + self.state = PollState(proto.state) + } +} + +/// How votes are weighted. +public enum PollMode: Hashable, Sendable { + /// One vote per member. + case count + /// Votes weigh by the voter's balance of a mint. + case balance + /// A mode this client doesn't recognize. + case unknown + + init(_ proto: Flipcash_Poll_V1_Mode) { + switch proto { + case .count: self = .count + case .balance: self = .balance + case .unknown, .UNRECOGNIZED: self = .unknown + } + } + + var proto: Flipcash_Poll_V1_Mode { + switch self { + case .count: .count + case .balance: .balance + case .unknown: .unknown + } + } +} + +/// The question and options of a poll, either in the clear or sealed under the chat key. +public enum PollText: Hashable, Sendable { + case plaintext(question: String, options: [String]) + /// Sealed text of a private group. The scheme is the wire `EncryptedPollText.Scheme` raw value. + case encrypted(scheme: Int, nonce: Data, ciphertext: Data) +} + +/// The mint and currency a `.balance` poll weighs votes in. +public struct PollBalanceParameters: Hashable, Sendable { + public let mint: PublicKey + public let currency: String + + public init(mint: PublicKey, currency: String) { + self.mint = mint + self.currency = currency + } +} + +/// Everything a creator chooses when opening a poll. +public struct PollDefinition: Hashable, Sendable { + + public let mode: PollMode + public let allowsMultipleChoices: Bool + public let endsAt: Date + /// Set when `mode` is `.balance`. + public let balanceParameters: PollBalanceParameters? + /// `nil` when the wire definition carries neither plaintext nor encrypted text. + public let text: PollText? + /// Carried separately because the server cannot count the options of sealed text. + public let optionCount: Int + + public init( + mode: PollMode, + allowsMultipleChoices: Bool, + endsAt: Date, + balanceParameters: PollBalanceParameters? = nil, + optionCount: Int, + text: PollText + ) { + self.mode = mode + self.allowsMultipleChoices = allowsMultipleChoices + self.endsAt = endsAt + self.balanceParameters = balanceParameters + self.optionCount = optionCount + self.text = text + } + + init(_ proto: Flipcash_Poll_V1_PollDefinition) { + self.mode = PollMode(proto.mode) + self.allowsMultipleChoices = proto.allowsMultipleChoices + self.endsAt = proto.endsAt.date + self.optionCount = Int(proto.optionCount) + self.balanceParameters = proto.hasBalanceParameters + ? (try? PublicKey(proto.balanceParameters.mint.value)).map { + PollBalanceParameters(mint: $0, currency: proto.balanceParameters.currency) + } + : nil + switch proto.text { + case .plaintext(let plaintext): + self.text = .plaintext(question: plaintext.question, options: plaintext.options.map(\.text)) + case .encrypted(let encrypted): + self.text = .encrypted(scheme: encrypted.scheme.rawValue, nonce: encrypted.nonce, ciphertext: encrypted.ciphertext) + case .none: + self.text = nil + } + } + + /// The wire definition for `CreatePoll`. + var proto: Flipcash_Poll_V1_PollDefinition { + .with { + $0.mode = mode.proto + $0.allowsMultipleChoices = allowsMultipleChoices + $0.endsAt = .init(date: endsAt) + if let balanceParameters { + $0.balanceParameters = .with { + $0.mint = .with { $0.value = balanceParameters.mint.data } + $0.currency = balanceParameters.currency + } + } + $0.optionCount = UInt32(optionCount) + switch text { + case .plaintext(let question, let options): + $0.plaintext = .with { + $0.question = question + $0.options = options.map { option in .with { $0.text = option } } + } + case .encrypted(let scheme, let nonce, let ciphertext): + $0.encrypted = .with { + $0.scheme = Flipcash_Poll_V1_EncryptedPollText.Scheme(rawValue: scheme) ?? .unknown + $0.nonce = nonce + $0.ciphertext = ciphertext + } + case .none: + break + } + } + } +} + +/// Where a poll stands: open, tallying, or closed, with the viewer's own vote and the results. +public struct PollState: Hashable, Sendable { + + public enum Status: Hashable, Sendable { + case open + case tallying + case closed + case unknown + } + + public enum Results: Hashable, Sendable { + case count(totalVoters: UInt64, optionVoters: [UInt64]) + /// `total` and `optionBalances` hold `nil` where an amount fails to decode. + case balance(totalVoters: UInt64, total: ExchangedFiat?, optionBalances: [ExchangedFiat?]) + case ranked(totalVoters: UInt64, optionRanks: [UInt32]) + /// The server holds the tally back (for example until the poll closes). + case withheld + /// Results of a kind this client doesn't recognize, or none at all. + case unknown + } + + public let status: Status + /// Option indices the viewer voted for. + public let selfVote: [Int] + public let closedAt: Date? + public let results: Results + + init(_ proto: Flipcash_Poll_V1_PollState) { + switch proto.status { + case .open: self.status = .open + case .tallying: self.status = .tallying + case .closed: self.status = .closed + case .unknown, .UNRECOGNIZED: self.status = .unknown + } + self.selfVote = proto.selfVote.map(Int.init) + self.closedAt = proto.hasClosedAt ? proto.closedAt.date : nil + switch proto.results.type { + case .count(let count): + self.results = .count(totalVoters: count.totalVoters, optionVoters: count.optionVoters) + case .balance(let balance): + self.results = .balance( + totalVoters: balance.totalVoters, + total: balance.hasTotal ? try? ExchangedFiat(balance.total) : nil, + optionBalances: balance.optionBalances.map { try? ExchangedFiat($0) } + ) + case .ranked(let ranked): + self.results = .ranked(totalVoters: ranked.totalVoters, optionRanks: ranked.optionRanks) + case .withheld: + self.results = .withheld + case .none: + self.results = .unknown + } + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatPaymentMetadataTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatPaymentMetadataTests.swift index 0f043ea1d..b53f9c673 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ChatPaymentMetadataTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatPaymentMetadataTests.swift @@ -8,6 +8,21 @@ struct ChatPaymentMetadataTests { private let chatID = ConversationID(data: Data(repeating: 0xAB, count: 32)) + @Test("Group payments carry the chat id and always send, never tip") + func groupPaymentSerialization() throws { + let metadata = ChatPaymentMetadata.groupPayment(chatID: chatID) + + let decoded = try Flipcash_Intent_V1_AppMetadata(serializedBytes: metadata.serializedAppMetadata()) + + guard case .groupPayment(let payment) = decoded.chat.type else { + Issue.record("Expected groupPayment, got \(String(describing: decoded.chat.type))") + return + } + #expect(decoded.chat.chatID.value == chatID.data) + #expect(payment.action == .send) + #expect(!metadata.isTip) + } + @Test("Contact DM payments carry the chat id and both phones") func contactDmSerialization() throws { let metadata = ChatPaymentMetadata.contactDm( diff --git a/FlipcashCore/Tests/FlipcashCoreTests/PollServiceTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/PollServiceTests.swift new file mode 100644 index 000000000..187469ba5 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/PollServiceTests.swift @@ -0,0 +1,69 @@ +// +// PollServiceTests.swift +// FlipcashCoreTests +// + +import Foundation +import Testing +import FlipcashAPI +@testable import FlipcashCore + +@Suite("Poll service error mapping and model decoding") +struct PollServiceTests { + + @Test("ErrorCreatePoll maps every result explicitly and carries the flagged category") + func createPollResults() { + #expect(ErrorCreatePoll(.denied, flaggedCategory: .nsfw) == .denied) + #expect(ErrorCreatePoll(.chatNotSupported, flaggedCategory: .nsfw) == .chatNotSupported) + #expect(ErrorCreatePoll(.encryptionNotAllowed, flaggedCategory: .nsfw) == .encryptionNotAllowed) + #expect(ErrorCreatePoll(.encryptionRequired, flaggedCategory: .nsfw) == .encryptionRequired) + #expect(ErrorCreatePoll(.optionCountMismatch, flaggedCategory: .nsfw) == .optionCountMismatch) + #expect(ErrorCreatePoll(.invalidEndsAt, flaggedCategory: .nsfw) == .invalidEndsAt) + #expect(ErrorCreatePoll(.invalidBalanceParameters, flaggedCategory: .nsfw) == .invalidBalanceParameters) + #expect(ErrorCreatePoll(.textModerated, flaggedCategory: .nsfw) == .textModerated(.nsfw)) + #expect(ErrorCreatePoll(.textModerated, flaggedCategory: .nsfw).reportingLevel == .info) + #expect(ErrorCreatePoll(.UNRECOGNIZED(99), flaggedCategory: .nsfw) == .unknown) + } + + @Test("Positional rawValue mapping matches the proto result enums") + func positionalResults() { + #expect(ErrorGetPoll(rawValue: Flipcash_Poll_V1_GetPollResponse.Result.notFound.rawValue) == .notFound) + #expect(ErrorGetPolls(rawValue: Flipcash_Poll_V1_GetPollsResponse.Result.denied.rawValue) == .denied) + #expect(ErrorVote(rawValue: Flipcash_Poll_V1_VoteResponse.Result.notFound.rawValue) == .notFound) + #expect(ErrorVote(rawValue: Flipcash_Poll_V1_VoteResponse.Result.pollClosed.rawValue) == .pollClosed) + #expect(ErrorVote(rawValue: Flipcash_Poll_V1_VoteResponse.Result.invalidSelection.rawValue) == .invalidSelection) + } + + @Test("A plaintext count poll round-trips through its domain model") + func pollMapping() { + let proto = Flipcash_Poll_V1_PollMetadata.with { + $0.pollID = .with { $0.value = Data(repeating: 1, count: 16) } + $0.chatID = .with { $0.value = Data(repeating: 2, count: 32) } + $0.creator = .with { $0.value = UUID().data } + $0.createdAt = .init(date: Date(timeIntervalSince1970: 1_000)) + $0.definition = .with { + $0.optionCount = 2 + $0.mode = .count + $0.endsAt = .init(date: Date(timeIntervalSince1970: 2_000)) + $0.plaintext = .with { + $0.question = "Lunch?" + $0.options = [.with { $0.text = "Pizza" }, .with { $0.text = "Tacos" }] + } + } + $0.state = .with { + $0.status = .open + $0.selfVote = [1] + $0.results = .with { $0.count = .with { $0.totalVoters = 3; $0.optionVoters = [1, 2] } } + } + } + + let poll = Poll(proto) + #expect(poll.definition.mode == .count) + #expect(poll.definition.text == .plaintext(question: "Lunch?", options: ["Pizza", "Tacos"])) + #expect(poll.state.status == .open) + #expect(poll.state.selfVote == [1]) + #expect(poll.state.results == .count(totalVoters: 3, optionVoters: [1, 2])) + #expect(poll.definition.proto.optionCount == 2) + #expect(poll.definition.proto.plaintext.question == "Lunch?") + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index 5f642c8a8..66ad132b8 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -92,6 +92,10 @@ struct TransportClassificationTests { @Test func errorSetKeyEnvelope() { assertClassifies(ErrorSetKeyEnvelope.self) } @Test func errorGetKeyEnvelope() { assertClassifies(ErrorGetKeyEnvelope.self) } @Test func errorStartChat() { assertClassifies(ErrorStartChat.self) } + @Test func errorCreatePoll() { assertClassifies(ErrorCreatePoll.self) } + @Test func errorGetPoll() { assertClassifies(ErrorGetPoll.self) } + @Test func errorGetPolls() { assertClassifies(ErrorGetPolls.self) } + @Test func errorVote() { assertClassifies(ErrorVote.self) } @Test func errorJoinChat() { assertClassifies(ErrorJoinChat.self) } @Test func errorLeaveChat() { assertClassifies(ErrorLeaveChat.self) } @Test func errorMuteChat() { assertClassifies(ErrorMuteChat.self) }