diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 40de7c9f7..54b6f3957 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e5f1ba52e5331c4be2f0112091373db97282453fcb23e452e257d42eaa1ec130", + "originHash" : "0949e333ed9deff2d24a867708577c820b601cd4655fa62ed4d469a4d88f6147", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "ad5e650ce239a94f555877a8d3ef747db9e159fc", - "version" : "0.22.0" + "revision" : "297e75a42b77b77da359ac553251e161abbf1f81", + "version" : "0.23.0" } }, { diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 6594a6a5e..899034cfe 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -960,6 +960,14 @@ final class ConversationController { case .lobbyChanged: // Only delivered to a private chat's admitting members, who already hold the chat. return + case .rulesChanged: + // Only delivered to a chat's members, same reasoning as `.titleChanged`: the feed brings + // the current rules with it. Nothing to fetch. + return + case .paymentRequestsChanged: + // Best-effort and only meaningful to a chat the viewer holds; the request is fetched + // with the chat. Nothing to fetch. + return } guard !store.conversations.contains(where: { $0.id == conversationID }), !hydratingConversationIDs.contains(conversationID) else { @@ -1510,6 +1518,13 @@ final class ConversationController { // Cached like the roster summary/mute above, so a cold restore shows the edited // title/picture rather than the stale one until the next full metadata fetch. persistConversation(conversationID) + case .rulesChanged(let conversationID, _): + // Rules are cached with the conversation, so a cold restore gates join/speak on the + // edited rules rather than the stale ones. + persistConversation(conversationID) + case .paymentRequestsChanged: + // TODO: apply to payment request state once a consumer holds it; nothing to persist yet. + break case .descriptionChanged: // Description isn't persisted, so there is nothing to write. Fold this into the // title/picture case above if it gains a column. diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index 336dab800..a228ef887 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -253,7 +253,7 @@ extension EncryptedChatClient: ConversationEventStreaming { case .metadataRefresh(let conversation): return .metadataRefresh(await opened(conversation)) case .lastActivityChanged, .readPointersChanged, .typingChanged, .rosterChanged, - .viewerStateChanged, .titleChanged, .pictureChanged, .coverPictureChanged, .descriptionChanged, .reactionsChanged, .lobbyChanged: + .viewerStateChanged, .titleChanged, .pictureChanged, .coverPictureChanged, .descriptionChanged, .reactionsChanged, .lobbyChanged, .rulesChanged, .paymentRequestsChanged: return event } } diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index e0a838bb8..47e2b4706 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -561,6 +561,8 @@ extension Analytics { case .pictureBlobNotAccepted: "PictureBlobNotAccepted" case .coverPictureBlobNotAccepted: "CoverPictureBlobNotAccepted" case .descriptionModerated: "DescriptionModerated" + case .invalidRules: "InvalidRules" + case .rulesNotSatisfied: "RulesNotSatisfied" case .unknown, .transportFailure, .cancelled, .rejected: "Network" } case let error as ErrorJoinChat: diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 87bab4f22..9d383a308 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.9.0" - case .flipcash2: return "0.22.0" + case .flipcash2: return "0.23.0" } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index 4db515ae8..28b0da594 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -242,9 +242,12 @@ extension FlipClient { /// `MetadataUpdate.titleChanged`/`.descriptionChanged`/`.pictureChanged`/`.coverPictureChanged` /// for the chat's other members (and the caller's other devices); this call's return value is /// only this device's confirmation. - public func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit = .unchanged, pictureBlobID: BlobID?, coverPictureBlobID: BlobID? = nil) async throws -> Conversation { + /// + /// `rules`, when set, fully replaces the chat's rules (public groups with a listener minimum balance + /// only); fails with `.invalidRules` or `.rulesNotSatisfied`. `nil` leaves the rules as they are. + public func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit = .unchanged, pictureBlobID: BlobID?, coverPictureBlobID: BlobID? = nil, rules: ConversationRules? = nil) async throws -> Conversation { try await withCheckedThrowingContinuation { c in - chatService.editChat(owner: owner, conversationID: conversationID, title: title, description: description, pictureBlobID: pictureBlobID, coverPictureBlobID: coverPictureBlobID) { c.resume(with: $0) } + chatService.editChat(owner: owner, conversationID: conversationID, title: title, description: description, pictureBlobID: pictureBlobID, coverPictureBlobID: coverPictureBlobID, rules: rules) { c.resume(with: $0) } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Push.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Push.swift index 98386c58f..f83ce7a99 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Push.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Push.swift @@ -20,4 +20,11 @@ extension FlipClient { pushService.deleteTokens(installationID: installationID, owner: owner) { c.resume(with: $0) } } } + + /// Asks the server to send a fixed test notification to every device the owner has registered. + public func sendTestPush(owner: KeyPair) async throws { + try await withCheckedThrowingContinuation { c in + pushService.sendTestPush(owner: owner) { c.resume(with: $0) } + } + } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index be981f58a..83cd8702c 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -565,14 +565,15 @@ final class ChatService: Sendable { /// /// `description` is ``ConversationDescriptionEdit/unchanged`` by default; use `.clear` to remove /// an existing description. - func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit = .unchanged, pictureBlobID: BlobID?, coverPictureBlobID: BlobID? = nil, completion: @Sendable @escaping (Result) -> Void) { + func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit = .unchanged, pictureBlobID: BlobID?, coverPictureBlobID: BlobID? = nil, rules: ConversationRules? = nil, completion: @Sendable @escaping (Result) -> Void) { let request = Self.editChatRequest( owner: owner, conversationID: conversationID, title: title, description: description, pictureBlobID: pictureBlobID, - coverPictureBlobID: coverPictureBlobID + coverPictureBlobID: coverPictureBlobID, + rules: rules ) Task { @@ -598,8 +599,9 @@ final class ChatService: Sendable { } /// The signed `EditChat` request; a nil title or blob and an `.unchanged` description leave that - /// field unset, so the server keeps its current value. - static func editChatRequest(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit, pictureBlobID: BlobID?, coverPictureBlobID: BlobID?) -> Flipcash_Chat_V1_EditChatRequest { + /// field unset, so the server keeps its current value. `rules`, when set, is the full replacement + /// rule set (public groups with a listener minimum balance only). + static func editChatRequest(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit, pictureBlobID: BlobID?, coverPictureBlobID: BlobID?, rules: ConversationRules? = nil) -> Flipcash_Chat_V1_EditChatRequest { Flipcash_Chat_V1_EditChatRequest.with { $0.chatID = conversationID.proto if let title { @@ -614,6 +616,9 @@ final class ChatService: Sendable { if let coverPictureBlobID { $0.coverPicture = .with { $0.blobID = .with { $0.value = coverPictureBlobID.data } } } + if let rules { + $0.rules = rules.proto + } $0.auth = owner.authFor(message: $0) } } @@ -1220,6 +1225,8 @@ public enum ErrorEditChat: Error, Sendable, Equatable { case pictureBlobNotAccepted case coverPictureBlobNotAccepted case descriptionModerated(Flipcash_Moderation_V1_FlaggedCategory) + case invalidRules + case rulesNotSatisfied case unknown case transportFailure case cancelled @@ -1678,7 +1685,7 @@ extension ErrorEditChat: ServerError, TransportClassifiableError { switch self { case .transportFailure: .suppressed case .cancelled: .info - case .denied, .notFound, .titleModerated, .pictureBlobNotAccepted, .coverPictureBlobNotAccepted, .descriptionModerated: .info + case .denied, .notFound, .titleModerated, .pictureBlobNotAccepted, .coverPictureBlobNotAccepted, .descriptionModerated, .invalidRules, .rulesNotSatisfied: .info case .unknown, .rejected: .error } } @@ -1705,6 +1712,10 @@ extension ErrorEditChat { self = .coverPictureBlobNotAccepted case .descriptionModerated: self = .descriptionModerated(flaggedCategory) + case .invalidRules: + self = .invalidRules + case .rulesNotSatisfied: + self = .rulesNotSatisfied case .UNRECOGNIZED: self = .unknown } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PushService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PushService.swift index ff32ce142..61b87b731 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PushService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PushService.swift @@ -76,6 +76,31 @@ final class PushService: Sendable { } } } + + func sendTestPush(owner: KeyPair, completion: @Sendable @escaping (Result<(), ErrorSendTestPush>) -> Void) { + logger.info("Sending test push") + + let request = Flipcash_Push_V1_SendTestPushRequest.with { + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.sendTestPush(request, options: .unaryDefault) + let error = ErrorSendTestPush(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to send test push", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error)) } + return + } + await MainActor.run { completion(.success(())) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } } // MARK: - Errors - @@ -97,6 +122,24 @@ public enum ErrorDeleteToken: Int, Error { case rejected = -4 } +public enum ErrorSendTestPush: Int, Error { + case ok + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +extension ErrorSendTestPush: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .unknown, .rejected: .error + } + } +} + extension ErrorAddToken: ServerError, TransportClassifiableError { public var reportingLevel: ErrorReportingLevel { switch self { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Activity+Metadata.swift b/FlipcashCore/Sources/FlipcashCore/Models/Activity+Metadata.swift index 81724a8be..d36058cab 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Activity+Metadata.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Activity+Metadata.swift @@ -169,11 +169,53 @@ extension Activity.BoostedReaction { /// payments that boosted nothing. init?(_ proto: Flipcash_Activity_V1_Notification.OneOf_AdditionalMetadata?) { switch proto { - case .directlySentCrypto(let metadata) where metadata.hasBoostedReaction: - self.init(metadata.boostedReaction) - case .receivedCrypto(let metadata) where metadata.hasBoostedReaction: - self.init(metadata.boostedReaction) - case .directlySentCrypto, .receivedCrypto, .withdrewCrypto, .indirectlySentCrypto, + case .directlySentCrypto(let metadata): + guard case .boostedReaction(let reaction)? = metadata.context else { return nil } + self.init(reaction) + case .receivedCrypto(let metadata): + guard case .boostedReaction(let reaction)? = metadata.context else { return nil } + self.init(reaction) + case .withdrewCrypto, .indirectlySentCrypto, + .depositedCrypto, .boughtCrypto, .soldCrypto, .swappedCrypto, .none: + return nil + } + } +} + +extension Activity { + /// The payment request a payment paid a share of: for a send, the payer paid their share to the + /// request's creator; for a receive, a debtor paid their share of the viewer's request. The + /// request may since have closed and the viewer may no longer be a member of the chat. + public struct RequestedPayment: Sendable, Equatable, Hashable { + public let conversationID: ConversationID + public let paymentRequestID: PaymentRequestID + + public init(conversationID: ConversationID, paymentRequestID: PaymentRequestID) { + self.conversationID = conversationID + self.paymentRequestID = paymentRequestID + } + } +} + +extension Activity.RequestedPayment { + init(_ proto: Flipcash_Activity_V1_RequestedPayment) { + self.init( + conversationID: ConversationID(proto.chatID), + paymentRequestID: PaymentRequestID(proto.paymentRequestID) + ) + } + + /// The requested payment a send or receive notification carries; `nil` for other kinds and for + /// payments that paid no request. + init?(_ proto: Flipcash_Activity_V1_Notification.OneOf_AdditionalMetadata?) { + switch proto { + case .directlySentCrypto(let metadata): + guard case .requestedPayment(let payment)? = metadata.context else { return nil } + self.init(payment) + case .receivedCrypto(let metadata): + guard case .requestedPayment(let payment)? = metadata.context else { return nil } + self.init(payment) + case .withdrewCrypto, .indirectlySentCrypto, .depositedCrypto, .boughtCrypto, .soldCrypto, .swappedCrypto, .none: return nil } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift b/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift index 6ef67273d..0c5058a32 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift @@ -31,6 +31,10 @@ public struct Activity: Identifiable, Sendable, Equatable, Hashable { /// The reaction this payment boosted, when it was a boost. public let boostedReaction: BoostedReaction? + /// The payment request this payment paid a share of, when it was one. Not persisted by the + /// activity store yet, so it is `nil` on activities read back from the database. + public let requestedPayment: RequestedPayment? + public var cancellableCashLinkMetadata: CashLinkMetadata? { switch state { case .pending: @@ -51,7 +55,7 @@ public struct Activity: Identifiable, Sendable, Equatable, Hashable { return nil } - public init(id: PublicKey, state: State, kind: Kind, title: String, exchangedFiat: ExchangedFiat, date: Date, metadata: Metadata?, textSubstitutions: [TextSubstitution] = [], counterparty: Counterparty? = nil, boostedReaction: BoostedReaction? = nil) { + public init(id: PublicKey, state: State, kind: Kind, title: String, exchangedFiat: ExchangedFiat, date: Date, metadata: Metadata?, textSubstitutions: [TextSubstitution] = [], counterparty: Counterparty? = nil, boostedReaction: BoostedReaction? = nil, requestedPayment: RequestedPayment? = nil) { self.id = id self.state = state self.kind = kind @@ -62,6 +66,7 @@ public struct Activity: Identifiable, Sendable, Equatable, Hashable { self.textSubstitutions = textSubstitutions self.counterparty = counterparty self.boostedReaction = boostedReaction + self.requestedPayment = requestedPayment } } @@ -222,7 +227,8 @@ extension Activity { metadata: .init(proto.additionalMetadata), textSubstitutions: substitutions, counterparty: .init(proto.additionalMetadata), - boostedReaction: .init(proto.additionalMetadata) + boostedReaction: .init(proto.additionalMetadata), + requestedPayment: .init(proto.additionalMetadata) ) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index edd6f818b..bede89f01 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -393,6 +393,14 @@ public struct ConversationStore: Sendable { case .lobbyChanged: // Lobby membership is not part of the feed; no consumer holds it yet. return .none + case .rulesChanged(let conversationID, let rules): + applyRulesChanged(rules, in: conversationID) + return .none + case .paymentRequestsChanged: + // TODO: apply to payment request state (created: keep greater version; updates at V vs + // local L: V<=L ignore, V==L+1 apply in order then set version, V>L+1 or an unknown + // update refetch). No consumer holds request state in the conversation store yet. + return .none } } @@ -583,6 +591,14 @@ public struct ConversationStore: Sendable { conversations[index].coverPicture = coverPicture } + /// Apply a rules change delivered via `MetadataUpdate.RulesChanged`. `rules` replaces the whole set. + /// Same best-effort, no-version caveat as ``applyTitleChanged(_:in:)``. No-ops for a chat the + /// store doesn't hold. + public mutating func applyRulesChanged(_ rules: ConversationRules, in conversationID: ConversationID) { + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } + conversations[index].rules = rules + } + /// Drops the cached viewer state for a chat the signed-in user just left. /// /// The server clears mute on leave and restarts the version with it, so this discards the diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift index d9d41cdc1..aacd78749 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift @@ -69,6 +69,15 @@ public enum ConversationStreamEvent: Sendable { /// Members entered or left a private chat's lobby. Delivered to the chat's admitting members; /// surfaced as received, with no version to compare. case lobbyChanged(conversationID: ConversationID, updates: [LobbyUpdate]) + + /// A group chat's rules changed (via `Chat.EditChat`), including on the editor's other devices. + /// `rules` is the full replacement set. Best-effort and applied as received, like ``titleChanged``. + case rulesChanged(conversationID: ConversationID, rules: ConversationRules) + + /// Payment requests in the chat changed. Best-effort and outside the gap-detected event log: a + /// missed update is reconciled by the next fetch of the request. Applying them to request state + /// follows `PaymentRequestUpdate`'s contract; see ``DecodedPaymentRequestUpdate``. + case paymentRequestsChanged(conversationID: ConversationID, updates: [DecodedPaymentRequestUpdate]) } /// One reaction added or removed on one message. @@ -244,6 +253,9 @@ extension ConversationStreamEvent { case .coverPictureChanged(let changed): guard changed.hasNewCoverPicture, let coverPicture = ProfilePicture(changed.newCoverPicture) else { break } events.append(.coverPictureChanged(conversationID: conversationID, coverPicture: coverPicture)) + case .rulesChanged(let changed): + guard changed.hasNewRules else { break } + events.append(.rulesChanged(conversationID: conversationID, rules: ConversationRules(changed.newRules))) case nil: break } @@ -282,6 +294,11 @@ extension ConversationStreamEvent { events.append(.lobbyChanged(conversationID: conversationID, updates: lobbyUpdates)) } + let paymentRequestUpdates = update.paymentRequestUpdates.paymentRequestUpdates.compactMap(DecodedPaymentRequestUpdate.init) + if !paymentRequestUpdates.isEmpty { + events.append(.paymentRequestsChanged(conversationID: conversationID, updates: paymentRequestUpdates)) + } + return events } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest.swift index 0237eb5fa..b16eeea01 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest.swift @@ -253,3 +253,70 @@ extension GroupPaymentRequest { } } } + +/// One best-effort, real-time change to a payment request from the chat event stream. See +/// `paymentrequest.v1.PaymentRequestUpdate`. +/// +/// Apply rules: `.created` is treated like a fetched copy (keep the greater version). `.updates` at +/// version V against local version L: V <= L is ignored, V == L + 1 applies its updates in order +/// then sets the version, V > L + 1 refetches. A ``Change/Update/unknown`` update, or a share update +/// without a share, also refetches. +public struct DecodedPaymentRequestUpdate: Hashable, Sendable { + + public enum Change: Hashable, Sendable { + case created(GroupPaymentRequest) + case updates(version: UInt64, [Update]) + + public enum Update: Hashable, Sendable { + /// A share was paid; the share carries the user it belongs to. + case sharePaid(GroupPaymentRequest.Share) + /// The request completed or closed. `closedAt` is `nil` when the server left it unset. + case statusChanged(GroupPaymentRequest.Status, closedAt: Date?) + /// An update kind this build doesn't know, or a share update without its share. + case unknown + } + } + + public let id: PaymentRequestID + public let change: Change + + public init(id: PaymentRequestID, change: Change) { + self.id = id + self.change = change + } +} + +extension DecodedPaymentRequestUpdate { + /// Nil when the update has no request id, or its kind (or a `created` copy) isn't one this build can read. + init?(_ proto: Flipcash_Paymentrequest_V1_PaymentRequestUpdate) { + guard proto.hasPaymentRequestID else { return nil } + switch proto.kind { + case .created(let metadata): + guard let request = GroupPaymentRequest(metadata) else { return nil } + self.init(id: PaymentRequestID(proto.paymentRequestID), change: .created(request)) + case .updates(let updates): + self.init( + id: PaymentRequestID(proto.paymentRequestID), + change: .updates(version: updates.version, updates.updates.map(Change.Update.init)) + ) + case nil: + return nil + } + } +} + +extension DecodedPaymentRequestUpdate.Change.Update { + init(_ proto: Flipcash_Paymentrequest_V1_PaymentRequestUpdate.Update) { + switch proto.kind { + case .sharePaymentUpdate(let update): + self = update.hasShare ? .sharePaid(GroupPaymentRequest.Share(update.share)) : .unknown + case .statusUpdate(let update): + self = .statusChanged( + GroupPaymentRequest.Status(update.status), + closedAt: update.hasClosedAt ? update.closedAt.date : nil + ) + case nil: + self = .unknown + } + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift index 0789e827b..b53dc32ce 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift @@ -544,4 +544,51 @@ struct ConversationStreamEventDecodeTests { #expect(member.enteredAt == Date(timeIntervalSince1970: 70)) #expect(left == leaver) } + + @Test("A RulesChanged metadata update decodes to the full replacement rules") + func decodesRulesChanged() { + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.metadataUpdates = [.with { + $0.rulesChanged = .with { $0.newRules = .with { $0.speaker = [.with { $0.never = .init() }] } } + }] + } + } + guard case .rulesChanged(let conversationID, let rules) = ConversationStreamEvent.decode(event).first else { + Issue.record("expected .rulesChanged"); return + } + #expect(conversationID == ConversationID(data: conversationBytes)) + #expect(rules.listener.isEmpty) + #expect(rules.speaker == [.never]) + } + + @Test("Payment request updates decode with unknown update kinds kept as .unknown") + func decodesPaymentRequestUpdates() { + let requestID = Data(repeating: 0x09, count: 16) + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.paymentRequestUpdates = .with { + $0.paymentRequestUpdates = [.with { + $0.paymentRequestID = .with { $0.value = requestID } + $0.updates = .with { + $0.version = 4 + $0.updates = [ + .with { $0.statusUpdate = .with { $0.status = .completed } }, + .with { $0.sharePaymentUpdate = .init() }, + .init(), + ] + } + }] + } + } + } + guard case .paymentRequestsChanged(_, let updates) = ConversationStreamEvent.decode(event).first else { + Issue.record("expected .paymentRequestsChanged"); return + } + #expect(updates.count == 1) + #expect(updates[0].id == PaymentRequestID(data: requestID)) + #expect(updates[0].change == .updates(version: 4, [.statusChanged(.completed, closedAt: nil), .unknown, .unknown])) + } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index cda8599c4..fba3237f6 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -50,6 +50,7 @@ struct TransportClassificationTests { @Test func errorFetchTransactionHistoryItemsByID() { assertClassifies(ErrorFetchTransactionHistoryItemsByID.self) } @Test func errorAddToken() { assertClassifies(ErrorAddToken.self) } @Test func errorDeleteToken() { assertClassifies(ErrorDeleteToken.self) } + @Test func errorSendTestPush() { assertClassifies(ErrorSendTestPush.self) } @Test func errorUpdateSettings() { assertClassifies(ErrorUpdateSettings.self) } @Test func errorFetchJWT() { assertClassifies(ErrorFetchJWT.self) } @Test func errorSendEmailCode() { assertClassifies(ErrorSendEmailCode.self) } @@ -188,6 +189,8 @@ struct TransportClassificationTests { (4, .pictureBlobNotAccepted), (5, .coverPictureBlobNotAccepted), (6, .descriptionModerated(.nsfw)), + (7, .invalidRules), + (8, .rulesNotSatisfied), ] for (raw, expected) in wire { let result = Flipcash_Chat_V1_EditChatResponse.Result(rawValue: raw)