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
40 changes: 40 additions & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ final class ConversationController {
for await event in events {
guard let self else { return }
let gap = self.store.apply(event)
self.handleRosterUpdates(event)
self.persist(event: event)
self.hydrateIfUnknown(event)
self.logCounterpartRead(event)
Expand Down Expand Up @@ -453,6 +454,13 @@ final class ConversationController {
switch event {
case .chatEvents(let id, _), .lastActivityChanged(let id, _), .readPointersChanged(let id, _):
conversationID = id
case .rosterChanged(let id, let updates):
// A self-leave already dropped this conversation on purpose (`handleRosterUpdates` runs
// first) — don't re-fetch a chat the server no longer considers us a member of.
guard !updates.contains(where: { if case .left(let userID) = $0.change { userID == selfUserID } else { false } }) else {
return
}
conversationID = id
case .metadataRefresh:
return
case .typingChanged:
Expand Down Expand Up @@ -481,6 +489,32 @@ final class ConversationController {
}
}

/// Applies the two roster effects that need the signed-in user's identity, which the pure
/// `ConversationStore` doesn't hold: a self-join inserts the chat from its embedded snapshot
/// (`metadata` is set only for the member who just joined), and a self-leave drops the chat from
/// the feed and the database. Every other member's join/leave is already folded into the
/// conversation's roster by `store.apply(event)`, persisted generically by `persist(event:)`.
private func handleRosterUpdates(_ event: ConversationStreamEvent) {
guard case .rosterChanged(let conversationID, let updates) = event else { return }
for update in updates {
switch update.change {
case .joined(_, let chat?):
// RosterUpdate.roster_summary is authoritative for versioning; the embedded metadata's
// own roster_summary is not compared separately, so it's overwritten here.
var chat = chat
chat.rosterSummary = update.rosterSummary
store.apply(.metadataRefresh(chat))
case .joined:
break
case .left(let userID) where userID == selfUserID:
store.remove(conversationID)
persist(operation: "delete-conversation") { try database.deleteConversation(conversationID: conversationID) }
case .left:
break
}
}
}

// MARK: - Feed

func loadFeed() async {
Expand Down Expand Up @@ -643,6 +677,12 @@ final class ConversationController {
case .lastActivityChanged(let conversationID, _),
.readPointersChanged(let conversationID, _):
persistConversation(conversationID)
case .rosterChanged(let conversationID, _):
// Persists whatever the store now holds: a generic in-place roster patch, or a fresh
// self-join `handleRosterUpdates` already inserted. No-ops for a self-leave — that
// conversation was already removed from both the store and the database.
persistConversation(conversationID)
refreshFeedPreview(for: conversationID)
case .typingChanged:
break
}
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.4.0"
case .flipcash2: return "0.6.0"
case .flipcash2: return "0.7.0"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,49 @@ extension FlipClient {
}
}

/// Page the group chat feed to exhaustion against a single pinned snapshot. Unlike a DM feed, the
/// caller's own membership can change mid-read (a join or a leave lands as a `rosterChanged`
/// stream event, not a feed mutation) — the same live-stream caveat as `getDmChatFeed` applies.
public func getGroupChatFeed(owner: KeyPair) async throws -> [Conversation] {
var all: [Conversation] = []
var pagingToken: Data?

while true {
let page = try await withCheckedThrowingContinuation { c in
chatService.getGroupChatFeed(owner: owner, pagingToken: pagingToken) { c.resume(with: $0) }
}
all.append(contentsOf: page.conversations)
if !page.hasMore { break }
pagingToken = page.pagingToken
}

return all
}

/// Starts a new group chat and returns its metadata on success. `rules` gates who may read/join
/// and who may send; `nil` leaves the chat unrestricted.
public func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?) async throws -> Conversation {
try await withCheckedThrowingContinuation { c in
chatService.startChat(owner: owner, title: title, pictureBlobID: pictureBlobID, rules: rules) { c.resume(with: $0) }
}
}

/// Joins an existing group chat. Fails with `.rulesNotSatisfied` when the caller doesn't meet the
/// chat's `ConversationRules`.
public func joinChat(owner: KeyPair, conversationID: ConversationID) async throws -> Conversation {
try await withCheckedThrowingContinuation { c in
chatService.joinChat(owner: owner, conversationID: conversationID) { c.resume(with: $0) }
}
}

/// Leaves a group chat. The caller's own `rosterChanged` event (naming itself) is what actually
/// drops the chat from the local feed and database — this call just tells the server to emit it.
public func leaveChat(owner: KeyPair, conversationID: ConversationID) async throws {
try await withCheckedThrowingContinuation { c in
chatService.leaveChat(owner: owner, conversationID: conversationID) { c.resume(with: $0) }
}
}

public func getMessages(owner: KeyPair, conversationID: ConversationID, before: MessageID?) async throws -> [ConversationMessage] {
try await withCheckedThrowingContinuation { c in
chatMessagingService.getMessages(owner: owner, conversationID: conversationID, pagingToken: before?.pagingToken) { c.resume(with: $0) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,134 @@ final class ChatService: Sendable {
}
}
}
struct GroupFeedPage: Sendable {
let conversations: [Conversation]
let pagingToken: Data
let hasMore: Bool
}

func getGroupChatFeed(owner: KeyPair, pageSize: Int = 50, pagingToken: Data?, completion: @Sendable @escaping (Result<GroupFeedPage, ErrorGetGroupChatFeed>) -> Void) {
let request = Flipcash_Chat_V1_GetGroupChatFeedRequest.with {
$0.queryOptions = .with {
$0.pageSize = Int32(pageSize)
if let pagingToken {
$0.pagingToken = .with { $0.value = pagingToken }
}
}
$0.auth = owner.authFor(message: $0)
}

Task {
do {
let response = try await service.getGroupChatFeed(request, options: .unaryDefault)
let error = ErrorGetGroupChatFeed(rawValue: response.result.rawValue) ?? .unknown
guard error == .ok else {
logger.error("Failed to fetch group chat feed")
await MainActor.run { completion(.failure(error)) }
return
}
let page = GroupFeedPage(
conversations: response.chats.map(Conversation.init),
pagingToken: response.pagingToken.value,
hasMore: response.hasMore_p
)
await MainActor.run { completion(.success(page)) }
} catch let error as RPCError {
await MainActor.run { completion(.failure(.from(transportError: error))) }
} catch {
await MainActor.run { completion(.failure(.unknown)) }
}
}
}

/// Starts a new group chat. `pictureBlobID` must already be `READY` (uploaded via
/// `BlobService`); `rules` gate who may read/join and who may send — `nil` means no
/// restrictions. On `.titleModerated` the server also reports which category flagged the
/// title; `ErrorStartChat.titleModerated` carries it through so callers can say why, not just
/// that the title was rejected.
func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?, completion: @Sendable @escaping (Result<Conversation, ErrorStartChat>) -> Void) {
let request = Flipcash_Chat_V1_StartChatRequest.with {
$0.group = .with {
$0.title = title
if let pictureBlobID {
$0.picture = .with { $0.value = pictureBlobID.data }
}
if let rules {
$0.rules = rules.proto
}
}
$0.auth = owner.authFor(message: $0)
}

Task {
do {
let response = try await service.startChat(request, options: .unaryDefault)
guard response.result == .ok else {
logger.error("Failed to start chat")
await MainActor.run { completion(.failure(ErrorStartChat(response.result, flaggedCategory: response.flaggedCategory))) }
return
}
guard response.hasChat else {
logger.error("Failed to start chat")
await MainActor.run { completion(.failure(.unknown)) }
return
}
await MainActor.run { completion(.success(Conversation(response.chat))) }
} catch let error as RPCError {
await MainActor.run { completion(.failure(.from(transportError: error))) }
} catch {
await MainActor.run { completion(.failure(.unknown)) }
}
}
}

func joinChat(owner: KeyPair, conversationID: ConversationID, completion: @Sendable @escaping (Result<Conversation, ErrorJoinChat>) -> Void) {
let request = Flipcash_Chat_V1_JoinChatRequest.with {
$0.chatID = conversationID.proto
$0.auth = owner.authFor(message: $0)
}

Task {
do {
let response = try await service.joinChat(request, options: .unaryDefault)
let error = ErrorJoinChat(rawValue: response.result.rawValue) ?? .unknown
guard error == .ok, response.hasChat else {
logger.error("Failed to join chat")
await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) }
return
}
await MainActor.run { completion(.success(Conversation(response.chat))) }
} catch let error as RPCError {
await MainActor.run { completion(.failure(.from(transportError: error))) }
} catch {
await MainActor.run { completion(.failure(.unknown)) }
}
}
}

func leaveChat(owner: KeyPair, conversationID: ConversationID, completion: @Sendable @escaping (Result<Void, ErrorLeaveChat>) -> Void) {
let request = Flipcash_Chat_V1_LeaveChatRequest.with {
$0.chatID = conversationID.proto
$0.auth = owner.authFor(message: $0)
}

Task {
do {
let response = try await service.leaveChat(request, options: .unaryDefault)
let error = ErrorLeaveChat(rawValue: response.result.rawValue) ?? .unknown
guard error == .ok else {
logger.error("Failed to leave chat")
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 @@ -107,6 +235,53 @@ public enum ErrorGetChat: Int, Error {
case rejected = -4
}

public enum ErrorGetGroupChatFeed: Int, Error {
case ok
case denied
case notFound
case unknown = -1
case transportFailure = -2
case cancelled = -3
case rejected = -4
}

/// Associated-value error, modelled on `ErrorProfile`, so `.titleModerated` can carry the
/// `flaggedCategory` the server reports for it — the moderation category the plain-Int pattern
/// used by this file's other error enums has no room for. No `.ok` case: a success response
/// resolves to `Conversation` in `startChat`'s `Result`, it never reaches this type.
public enum ErrorStartChat: Error, Sendable, Equatable {
case denied
case titleModerated(Flipcash_Moderation_V1_FlaggedCategory)
case pictureBlobNotAccepted
case invalidRules
case rulesNotSatisfied
case unknown
case transportFailure
case cancelled
case rejected
}

public enum ErrorJoinChat: Int, Error {
case ok
case denied
case notFound
case rulesNotSatisfied
case unknown = -1
case transportFailure = -2
case cancelled = -3
case rejected = -4
}

public enum ErrorLeaveChat: 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 All @@ -128,3 +303,73 @@ extension ErrorGetChat: ServerError, TransportClassifiableError {
}
}
}

extension ErrorGetGroupChatFeed: ServerError, TransportClassifiableError {
public var reportingLevel: ErrorReportingLevel {
switch self {
case .ok, .transportFailure: .suppressed
case .cancelled: .info
case .denied, .notFound: .info
case .unknown, .rejected: .error
}
}
}

extension ErrorStartChat: ServerError, TransportClassifiableError {
public var reportingLevel: ErrorReportingLevel {
switch self {
case .transportFailure: .suppressed
case .cancelled: .info
case .denied, .titleModerated, .pictureBlobNotAccepted, .invalidRules, .rulesNotSatisfied: .info
case .unknown, .rejected: .error
}
}
}

extension ErrorStartChat {
/// Maps a non-`.ok` `StartChatResponse.Result` to its domain error, carrying `flaggedCategory`
/// through on `.titleModerated` rather than flattening it. Pure and synchronous so the mapping is
/// unit-testable without a live RPC. Callers only reach this once they've confirmed `result != .ok`;
/// `.ok` itself resolves to `Conversation` in `ChatService.startChat`, not this type, but is handled
/// here too (as `.unknown`) so the mapping is total over every case of the proto enum.
init(_ result: Flipcash_Chat_V1_StartChatResponse.Result, flaggedCategory: Flipcash_Moderation_V1_FlaggedCategory) {
switch result {
case .ok:
self = .unknown
case .denied:
self = .denied
case .titleModerated:
self = .titleModerated(flaggedCategory)
case .pictureBlobNotAccepted:
self = .pictureBlobNotAccepted
case .invalidRules:
self = .invalidRules
case .rulesNotSatisfied:
self = .rulesNotSatisfied
case .UNRECOGNIZED:
self = .unknown
}
}
}

extension ErrorJoinChat: ServerError, TransportClassifiableError {
public var reportingLevel: ErrorReportingLevel {
switch self {
case .ok, .transportFailure: .suppressed
case .cancelled: .info
case .denied, .notFound, .rulesNotSatisfied: .info
case .unknown, .rejected: .error
}
}
}

extension ErrorLeaveChat: 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