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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Controllers/EncryptedChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
2 changes: 2 additions & 0 deletions Flipcash/Utilities/Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion FlipcashAPI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ enum ContractPackage: String, CaseIterable {
var version: Version {
switch self {
case .ocp: return "0.9.0"
case .flipcash2: return "0.22.0"
case .flipcash2: return "0.23.0"
}
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Conversation, ErrorEditChat>) -> Void) {
func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit = .unchanged, pictureBlobID: BlobID?, coverPictureBlobID: BlobID? = nil, rules: ConversationRules? = nil, completion: @Sendable @escaping (Result<Conversation, ErrorEditChat>) -> Void) {
let request = Self.editChatRequest(
owner: owner,
conversationID: conversationID,
title: title,
description: description,
pictureBlobID: pictureBlobID,
coverPictureBlobID: coverPictureBlobID
coverPictureBlobID: coverPictureBlobID,
rules: rules
)

Task {
Expand All @@ -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 {
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -
Expand All @@ -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 {
Expand Down
52 changes: 47 additions & 5 deletions FlipcashCore/Sources/FlipcashCore/Models/Activity+Metadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
10 changes: 8 additions & 2 deletions FlipcashCore/Sources/FlipcashCore/Models/Activity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -62,6 +66,7 @@ public struct Activity: Identifiable, Sendable, Equatable, Hashable {
self.textSubstitutions = textSubstitutions
self.counterparty = counterparty
self.boostedReaction = boostedReaction
self.requestedPayment = requestedPayment
}
}

Expand Down Expand Up @@ -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)
)
}

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

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