diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 9b0afcdf3..2cb1855fd 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.5.0" - case .flipcash2: return "0.9.0" + case .flipcash2: return "0.10.0" } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index 90fe4cdde..b9a23af03 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -28,9 +28,25 @@ extension FlipClient { return all } - public func getChat(owner: KeyPair, conversationID: ConversationID) async throws -> Conversation { + public func getChat(owner: KeyPair, conversationID: ConversationID, viewMode: ConversationViewMode = .full) async throws -> Conversation { try await withCheckedThrowingContinuation { c in - chatService.getChat(owner: owner, conversationID: conversationID) { c.resume(with: $0) } + chatService.getChat(owner: owner, conversationID: conversationID, viewMode: viewMode) { c.resume(with: $0) } + } + } + + /// Mutes `conversationID` for `owner` until `mute` lapses (or forever). Returns the resulting + /// viewer state, including the version stamp so a caller applying it locally can compare against + /// the same greater-value-wins rule as a stream-delivered `ConversationStreamEvent.viewerStateChanged`. + public func muteChat(owner: KeyPair, conversationID: ConversationID, mute: ConversationMuteState) async throws -> ConversationViewerState { + try await withCheckedThrowingContinuation { c in + chatService.muteChat(owner: owner, conversationID: conversationID, mute: mute) { c.resume(with: $0) } + } + } + + /// Unmutes `conversationID` for `owner`. Returns the resulting viewer state. + public func unmuteChat(owner: KeyPair, conversationID: ConversationID) async throws -> ConversationViewerState { + try await withCheckedThrowingContinuation { c in + chatService.unmuteChat(owner: owner, conversationID: conversationID) { c.resume(with: $0) } } } @@ -81,9 +97,9 @@ extension FlipClient { } } - public func getMessages(owner: KeyPair, conversationID: ConversationID, before: MessageID?) async throws -> [ConversationMessage] { + public func getMessages(owner: KeyPair, conversationID: ConversationID, before: MessageID?, viewMode: ConversationViewMode = .full) async throws -> [ConversationMessage] { try await withCheckedThrowingContinuation { c in - chatMessagingService.getMessages(owner: owner, conversationID: conversationID, pagingToken: before?.pagingToken) { c.resume(with: $0) } + chatMessagingService.getMessages(owner: owner, conversationID: conversationID, pagingToken: before?.pagingToken, viewMode: viewMode) { c.resume(with: $0) } } } @@ -94,10 +110,11 @@ extension FlipClient { owner: KeyPair, conversationID: ConversationID, afterSequence: UInt64, + viewMode: ConversationViewMode = .full, onBatch: @MainActor @Sendable @escaping (_ messages: [ConversationMessage], _ checkpoint: UInt64?) -> Void ) async throws -> UInt64 { try await withCheckedThrowingContinuation { c in - chatMessagingService.getDelta(owner: owner, conversationID: conversationID, afterSequence: afterSequence, onBatch: onBatch) { c.resume(with: $0) } + chatMessagingService.getDelta(owner: owner, conversationID: conversationID, afterSequence: afterSequence, viewMode: viewMode, onBatch: onBatch) { c.resume(with: $0) } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Reporting.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Reporting.swift new file mode 100644 index 000000000..65b54cadc --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Reporting.swift @@ -0,0 +1,19 @@ +// +// FlipClient+Reporting.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +extension FlipClient { + + /// Files a report against a user, chat, message, or blob. Advisory only — the server never + /// surfaces an outcome back to the reporter, and reporting the same target again is a no-op. + public func report(owner: KeyPair, target: ReportTarget, description: String? = nil) async throws { + try await withCheckedThrowingContinuation { c in + reportingService.report(owner: owner, target: target, description: description) { 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 a651295c6..7f0cb23aa 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift @@ -38,6 +38,7 @@ public class FlipClient: ObservableObject { internal let chatService: ChatService internal let chatMessagingService: ChatMessagingService internal let blocklistService: BlocklistService + internal let reportingService: ReportingService /// The single per-user event stream. Started on login, stopped on logout. public let eventStreamer: EventStreamer @@ -85,6 +86,7 @@ public class FlipClient: ObservableObject { self.chatService = ChatService(client: client) self.chatMessagingService = ChatMessagingService(client: client) self.blocklistService = BlocklistService(client: client) + self.reportingService = ReportingService(client: client) self.eventStreamer = EventStreamer(service: EventStreamingService(client: client)) } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift index 9a836d81b..3ab07e85c 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift @@ -36,7 +36,7 @@ final class ChatMessagingService: Sendable { /// Fetches the newest `pageSize` messages (descending on the wire — without /// an explicit order the server defaults to ascending and a long chat's /// first page would be its OLDEST messages), returned oldest-first. - func getMessages(owner: KeyPair, conversationID: ConversationID, pageSize: Int = 50, pagingToken: Data?, completion: @Sendable @escaping (Result<[ConversationMessage], ErrorGetMessages>) -> Void) { + func getMessages(owner: KeyPair, conversationID: ConversationID, pageSize: Int = 50, pagingToken: Data?, viewMode: ConversationViewMode = .full, completion: @Sendable @escaping (Result<[ConversationMessage], ErrorGetMessages>) -> Void) { let request = Flipcash_Messaging_V1_GetMessagesRequest.with { $0.chatID = conversationID.proto $0.options = .with { @@ -46,6 +46,7 @@ final class ChatMessagingService: Sendable { $0.pagingToken = .with { $0.value = pagingToken } } } + $0.viewMode = viewMode.proto $0.auth = owner.authFor(message: $0) } @@ -82,12 +83,14 @@ final class ChatMessagingService: Sendable { owner: KeyPair, conversationID: ConversationID, afterSequence: UInt64, + viewMode: ConversationViewMode = .full, onBatch: @MainActor @Sendable @escaping (_ messages: [ConversationMessage], _ checkpoint: UInt64?) -> Void, completion: @Sendable @escaping (Result) -> Void ) { let request = Flipcash_Messaging_V1_GetDeltaRequest.with { $0.chatID = conversationID.proto $0.afterSequence = afterSequence + $0.viewMode = viewMode.proto $0.auth = owner.authFor(message: $0) } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index 794baf4ad..54899baaf 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -60,9 +60,10 @@ final class ChatService: Sendable { } } - func getChat(owner: KeyPair, conversationID: ConversationID, completion: @Sendable @escaping (Result) -> Void) { + func getChat(owner: KeyPair, conversationID: ConversationID, viewMode: ConversationViewMode = .full, completion: @Sendable @escaping (Result) -> Void) { let request = Flipcash_Chat_V1_GetChatRequest.with { $0.chatID = conversationID.proto + $0.viewMode = viewMode.proto $0.auth = owner.authFor(message: $0) } @@ -221,6 +222,55 @@ final class ChatService: Sendable { } } } + + func muteChat(owner: KeyPair, conversationID: ConversationID, mute: ConversationMuteState, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_MuteChatRequest.with { + $0.chatID = conversationID.proto + $0.mute = mute.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.muteChat(request, options: .unaryDefault) + let error = ErrorMuteChat(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok, response.hasViewerState else { + logger.error("Failed to mute chat") + await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + return + } + await MainActor.run { completion(.success(ConversationViewerState(response.viewerState))) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + func unmuteChat(owner: KeyPair, conversationID: ConversationID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_UnmuteChatRequest.with { + $0.chatID = conversationID.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.unmuteChat(request, options: .unaryDefault) + let error = ErrorUnmuteChat(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok, response.hasViewerState else { + logger.error("Failed to unmute chat") + await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + return + } + await MainActor.run { completion(.success(ConversationViewerState(response.viewerState))) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } } // MARK: - Errors - @@ -292,6 +342,26 @@ public enum ErrorLeaveChat: Int, Error { case rejected = -4 } +public enum ErrorMuteChat: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorUnmuteChat: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + extension ErrorGetDmChatFeed: ServerError, TransportClassifiableError { public var reportingLevel: ErrorReportingLevel { switch self { @@ -383,3 +453,25 @@ extension ErrorLeaveChat: ServerError, TransportClassifiableError { } } } + +extension ErrorMuteChat: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorUnmuteChat: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/EventStreamer.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/EventStreamer.swift index 01a2d8512..a34b231f7 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/EventStreamer.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/EventStreamer.swift @@ -239,6 +239,12 @@ public actor EventStreamer { case .invalidTimestamp: logger.warning("Event stream rejected timestamp, reconnecting") reconnect() + case .notFound, .streamExpired: + // Neither names a client mistake worth stopping for: the stream's target chat/cursor + // is gone or its session lapsed, and a fresh connect re-establishes both, same as an + // invalid timestamp. + logger.warning("Event stream reported \(error.code), reconnecting") + reconnect() case .UNRECOGNIZED: logger.warning("Event stream returned an unrecognized error code") reconnect() diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ReportingService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ReportingService.swift new file mode 100644 index 000000000..ecc1a9c0c --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ReportingService.swift @@ -0,0 +1,101 @@ +// +// ReportingService.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI +import GRPCCore + +private let logger = Logger(label: "flipcash.reporting-service") + +/// What a report names. See `reporting.v1.ReportRequest.target`. +public enum ReportTarget: Sendable { + case user(UserID) + case chat(ConversationID) + case message(chatID: ConversationID, messageID: MessageID) + case blob(BlobID) +} + +extension ReportTarget { + var proto: Flipcash_Reporting_V1_ReportRequest.OneOf_Target { + switch self { + case .user(let userID): + return .userID(userID.proto) + case .chat(let chatID): + return .chatID(chatID.proto) + case .message(let chatID, let messageID): + return .message(.with { + $0.chatID = chatID.proto + $0.messageID = messageID.proto + }) + case .blob(let blobID): + return .blobID(.with { $0.value = blobID.data }) + } + } +} + +final class ReportingService: Sendable { + + private let service: Flipcash_Reporting_V1_Reporting.Client + + init(client: GRPCClient) { + self.service = Flipcash_Reporting_V1_Reporting.Client(wrapping: client) + } + + /// Files a report against a user, chat, message, or blob. Reports are advisory: filing one has + /// no immediate client-visible effect and outcomes are never surfaced back to the reporter — a + /// `.success` here means the report was filed, not that any action was taken. Reporting the same + /// target more than once is a no-op that still returns `.ok`. + func report(owner: KeyPair, target: ReportTarget, description: String?, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Reporting_V1_ReportRequest.with { + $0.target = target.proto + if let description { + $0.description_p = description + } + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.report(request, options: .unaryDefault) + let error = ErrorReport(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to file report") + 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 - + +public enum ErrorReport: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +extension ErrorReport: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index 4b7fd0929..493c47623 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -45,7 +45,11 @@ public struct Conversation: Identifiable, Hashable, Sendable { /// group chats; `nil` means the chat has no participation requirements. public var rules: ConversationRules? - public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm, isHidden: Bool = false, title: String? = nil, latestEventSequence: UInt64 = 0, picture: ProfilePicture? = nil, rosterSummary: ConversationRosterSummary = ConversationRosterSummary(memberCount: 0, version: 0), rules: ConversationRules? = nil) { + /// The signed-in user's per-viewer state for this chat (currently just mute status), when the + /// server has reported one. See ``ConversationViewerState``. + public var viewerState: ConversationViewerState? + + public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm, isHidden: Bool = false, title: String? = nil, latestEventSequence: UInt64 = 0, picture: ProfilePicture? = nil, rosterSummary: ConversationRosterSummary = ConversationRosterSummary(memberCount: 0, version: 0), rules: ConversationRules? = nil, viewerState: ConversationViewerState? = nil) { self.id = id self.members = members self.lastMessage = lastMessage @@ -57,6 +61,7 @@ public struct Conversation: Identifiable, Hashable, Sendable { self.picture = picture self.rosterSummary = rosterSummary self.rules = rules + self.viewerState = viewerState } } @@ -91,6 +96,39 @@ extension ConversationType { } } +/// How much of a message's content a read should return. See `messaging.v1.ViewMode`. +public enum ConversationViewMode: Sendable { + /// Full content or nothing: never returns a redacted message. + case full + /// The most the viewer's standing allows — a mix of full and redacted messages within the same + /// page/stream, `redacted` set on each message that came back that way. + case fullOrRedacted + /// Redacted content, always — the mode to use whenever the client intends to render the chat + /// blurred regardless of whether the viewer could read it in full. + case redacted +} + +extension ConversationViewMode { + var proto: Flipcash_Messaging_V1_ViewMode { + switch self { + case .full: .full + case .fullOrRedacted: .fullOrRedacted + case .redacted: .redacted + } + } + + init(_ proto: Flipcash_Messaging_V1_ViewMode) { + switch proto { + case .fullOrRedacted: + self = .fullOrRedacted + case .redacted: + self = .redacted + case .full, .UNRECOGNIZED: + self = .full + } + } +} + extension Conversation { public init(_ proto: Flipcash_Chat_V1_Metadata) { self.id = ConversationID(proto.chatID) @@ -106,6 +144,7 @@ extension Conversation { self.picture = proto.hasPicture ? ProfilePicture(proto.picture) : nil self.rosterSummary = ConversationRosterSummary(proto.rosterSummary) self.rules = proto.hasRules ? ConversationRules(proto.rules) : nil + self.viewerState = proto.hasViewerState ? ConversationViewerState(proto.viewerState) : nil } /// The member that isn't the signed-in user, used to title the conversation. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 1b58112ff..acafa174a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -79,6 +79,10 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// loaded from the server, the stream, or the cache. Mutable so the store can carry it onto the /// confirmed server copy during reconciliation without rebuilding the whole value. public var clientMessageID: UUID? + /// Whether the server returned this message under a redacted `ViewMode` rather than its full + /// content — the request asked to render the chat blurred/placeholder-only, not that the server + /// withheld something unrelated. `false` for every message fetched under the default `.full` mode. + public let redacted: Bool public init( id: MessageID, @@ -91,7 +95,8 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { lastEditedTs: Date? = nil, repliedTo: MessageID? = nil, status: SendStatus = .sent, - clientMessageID: UUID? = nil + clientMessageID: UUID? = nil, + redacted: Bool = false ) { self.id = id self.senderID = senderID @@ -104,6 +109,7 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { self.repliedTo = repliedTo self.status = status self.clientMessageID = clientMessageID + self.redacted = redacted } } @@ -135,7 +141,8 @@ extension ConversationMessage { lastEditedTs: lastEditedTs, repliedTo: repliedTo, status: status, - clientMessageID: clientMessageID + clientMessageID: clientMessageID, + redacted: redacted ) } } @@ -202,5 +209,6 @@ extension ConversationMessage { self.repliedTo = repliedTo self.status = .sent self.clientMessageID = nil + self.redacted = proto.redacted } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift index f97ded2b0..13e02eb32 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift @@ -40,6 +40,68 @@ extension ConversationRosterSummary { } } +/// The signed-in user's per-viewer state for a chat — currently just mute status. See +/// `chat.v1.ViewerState`. +public struct ConversationViewerState: Hashable, Sendable { + + /// Present iff the chat is muted for the viewer. Muted pushes are still delivered (so the + /// client can store the message) but flagged for the client to suppress the notification. + public let mute: ConversationMuteState? + + /// Opaque version, advanced by exactly one on every real change. Compared like + /// ``ConversationRosterSummary/version``: apply the greater value and drop the rest — delivery + /// order does not matter, and there is no delta to fetch, only a refetch. + public let version: UInt64 + + public init(mute: ConversationMuteState? = nil, version: UInt64 = 0) { + self.mute = mute + self.version = version + } +} + +extension ConversationViewerState { + public init(_ proto: Flipcash_Chat_V1_ViewerState) { + self.init( + mute: (proto.hasSettings && proto.settings.hasMute) ? ConversationMuteState(proto.settings.mute) : nil, + version: proto.version + ) + } +} + +/// Whether, and until when, the signed-in user has muted a chat. See `chat.v1.MuteState`. +public enum ConversationMuteState: Hashable, Sendable { + /// Muted until this date, after which the mute lapses client-side. Nothing is sent when it + /// does, so the client owns the countdown. + case until(Date) + /// Muted forever, until explicitly unmuted. + case forever +} + +extension ConversationMuteState { + /// Falls back to `.forever` when `duration` is unset — defensive only: the server never sends + /// a `MuteState` without one, since its presence on `Settings.mute` is itself the mute signal. + init(_ proto: Flipcash_Chat_V1_MuteState) { + switch proto.duration { + case .until(let timestamp): + self = .until(timestamp.date) + case .forever, nil: + self = .forever + } + } + + /// Builds the wire form for `MuteChatRequest.mute`. + var proto: Flipcash_Chat_V1_MuteState { + .with { + switch self { + case .until(let date): + $0.until = .init(date: date) + case .forever: + $0.forever = .init() + } + } + } +} + /// Requirements a user must satisfy to participate in a group chat. Only /// ever set for group chats; unset means the chat has no participation /// requirements. See `chat.v1.Rules`. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index cff05c31e..1cbc654d9 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -334,6 +334,9 @@ public struct ConversationStore: Sendable { case .rosterChanged(let conversationID, let updates): applyRosterUpdates(updates, in: conversationID) return .none + case .viewerStateChanged(let conversationID, let viewerState): + applyViewerStateChanged(viewerState, in: conversationID) + return .none } } @@ -478,6 +481,15 @@ public struct ConversationStore: Sendable { } } + /// Apply a viewer-state update convergently: only if the incoming version is greater than the + /// cached one, mirroring `applyRosterUpdates` — delivery order and re-delivery don't matter. + /// No-ops for a chat the store doesn't hold. + public mutating func applyViewerStateChanged(_ viewerState: ConversationViewerState, in conversationID: ConversationID) { + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } + guard viewerState.version > (conversations[index].viewerState?.version ?? 0) else { return } + conversations[index].viewerState = viewerState + } + private mutating func upsert(_ conversation: Conversation) { let conversation = seated(conversation) if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift index 8d2de5fc4..82738ea43 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift @@ -37,6 +37,10 @@ public enum ConversationStreamEvent: Sendable { /// `RosterSummary.version` (a greater version wins, drop-if-not-greater), so delivery order /// doesn't matter. case rosterChanged(conversationID: ConversationID, updates: [DecodedRosterUpdate]) + + /// The signed-in user's viewer state changed (currently: mute status). Compared like + /// `rosterChanged` — apply by ``ConversationViewerState/version``, greater wins, drop the rest. + case viewerStateChanged(conversationID: ConversationID, viewerState: ConversationViewerState) } /// One durable event in a chat's log: a contiguous run of mutations delivered atomically. `sequence` @@ -133,6 +137,9 @@ extension ConversationStreamEvent { events.append(.metadataRefresh(Conversation(refresh.metadata))) case .lastActivityChanged(let changed): events.append(.lastActivityChanged(conversationID: conversationID, date: changed.newLastActivity.date)) + case .viewerStateChanged(let changed): + guard changed.hasViewerState else { break } + events.append(.viewerStateChanged(conversationID: conversationID, viewerState: ConversationViewerState(changed.viewerState))) case nil: break } diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index aaf97793e..c6878097e 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -60,4 +60,16 @@ public enum NotificationPayload { guard payload.chatMetadata.hasMessage else { return nil } return ConversationMessage(payload.chatMetadata.message) } + + /// Whether the recipient had the chat muted when a CHAT push was sent. The push is still + /// delivered so the client can store the message, but the client must not present a + /// notification for it. `false` when the push isn't a chat message or carries no chat metadata + /// (system messages, or a legacy server that omits it) — scaffolded here only; no caller wires + /// this into notification presentation yet. + public static func isMuted(_ userInfo: [AnyHashable: Any]) -> Bool { + guard let payload = decode(userInfo), payload.category == .chat, payload.hasChatMetadata else { + return false + } + return payload.chatMetadata.muted + } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index 206e6e14f..6a0590848 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -81,6 +81,9 @@ struct TransportClassificationTests { @Test func errorStartChat() { assertClassifies(ErrorStartChat.self) } @Test func errorJoinChat() { assertClassifies(ErrorJoinChat.self) } @Test func errorLeaveChat() { assertClassifies(ErrorLeaveChat.self) } + @Test func errorMuteChat() { assertClassifies(ErrorMuteChat.self) } + @Test func errorUnmuteChat() { assertClassifies(ErrorUnmuteChat.self) } + @Test func errorReport() { assertClassifies(ErrorReport.self) } // In-band outcomes fall outside the generic four-case contract. @Test("Explicit server outcomes never retry")