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
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.5.0"
case .flipcash2: return "0.9.0"
case .flipcash2: return "0.10.0"
}
}

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

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

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -46,6 +46,7 @@ final class ChatMessagingService: Sendable {
$0.pagingToken = .with { $0.value = pagingToken }
}
}
$0.viewMode = viewMode.proto
$0.auth = owner.authFor(message: $0)
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,10 @@ final class ChatService: Sendable {
}
}

func getChat(owner: KeyPair, conversationID: ConversationID, completion: @Sendable @escaping (Result<Conversation, ErrorGetChat>) -> Void) {
func getChat(owner: KeyPair, conversationID: ConversationID, viewMode: ConversationViewMode = .full, completion: @Sendable @escaping (Result<Conversation, ErrorGetChat>) -> Void) {
let request = Flipcash_Chat_V1_GetChatRequest.with {
$0.chatID = conversationID.proto
$0.viewMode = viewMode.proto
$0.auth = owner.authFor(message: $0)
}

Expand Down Expand Up @@ -221,6 +222,55 @@ final class ChatService: Sendable {
}
}
}

func muteChat(owner: KeyPair, conversationID: ConversationID, mute: ConversationMuteState, completion: @Sendable @escaping (Result<ConversationViewerState, ErrorMuteChat>) -> 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<ConversationViewerState, ErrorUnmuteChat>) -> 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 -
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AppTransport>

init(client: GRPCClient<AppTransport>) {
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, ErrorReport>) -> 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
}
}
}
Loading
Loading