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
10 changes: 10 additions & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,11 @@ final class ConversationController {
// unknown chat here means the feed hasn't landed, and the feed will bring the state
// with it. Nothing to fetch.
return
case .titleChanged, .pictureChanged:
// Only delivered to a chat's members, same reasoning as `.viewerStateChanged`: an unknown
// chat here means the feed hasn't landed yet, and it will bring the current title/picture
// with it. Nothing to fetch.
return
}
guard !store.conversations.contains(where: { $0.id == conversationID }),
!hydratingConversationIDs.contains(conversationID) else {
Expand Down Expand Up @@ -874,6 +879,11 @@ final class ConversationController {
// Mute is cached so a chat restored cold renders muted rather than flickering unmuted
// until the next metadata fetch — the same reason the roster summary is cached.
persistConversation(conversationID)
case .titleChanged(let conversationID, _),
.pictureChanged(let conversationID, _):
// 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 .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.5.0"
case .flipcash2: return "0.10.0"
case .flipcash2: return "0.11.0"
}
}

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

/// Represents one paged read of a chat's roster: the members returned, most recently joined
/// first, and the roster summary as of that page's read.
///
/// This is a single pinned read, not a merged view — a large group's roster can page from an
/// index that trails the event stream (see `chat.v1.GetRoster`'s staleness contract), so the
/// caller must reconcile `members` against whatever it already holds from the stream, by
/// ``ConversationMember/version``, greater winning, rather than trusting this to be current or
/// complete on its own.
public struct RosterPage: Sendable {
public let members: [ConversationMember]
public let rosterSummary: ConversationRosterSummary
}

/// Pages a chat's roster to exhaustion against a single pinned read. The caller must already be
/// consuming its `subscribeConversationStream` events — as with `getDmChatFeed`/
/// `getGroupChatFeed` — and must merge the result against them by ``ConversationMember/version``
/// rather than treating it as an authoritative snapshot; see ``RosterPage``.
public func getRoster(owner: KeyPair, conversationID: ConversationID) async throws -> RosterPage {
var members: [ConversationMember] = []
var rosterSummary = ConversationRosterSummary(memberCount: 0, version: 0)
var pagingToken: Data?

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

return RosterPage(members: members, rosterSummary: rosterSummary)
}

/// Edits a group chat's title and/or picture; every parameter left `nil` leaves that field
/// unchanged, and passing both `nil` is a no-op that still returns the current metadata. Only a
/// member with ``ConversationViewerState/canEdit`` may call this.
///
/// `pictureBlobID` must already be `READY` (uploaded via `BlobService`) — this call does not
/// upload it. Real changes also arrive on the event stream as `MetadataUpdate.titleChanged`/
/// `.pictureChanged` 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?, pictureBlobID: BlobID?) async throws -> Conversation {
try await withCheckedThrowingContinuation { c in
chatService.editChat(owner: owner, conversationID: conversationID, title: title, pictureBlobID: pictureBlobID) { c.resume(with: $0) }
}
}

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, viewMode: viewMode) { c.resume(with: $0) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,104 @@ final class ChatService: Sendable {
}
}
}

struct RosterFeedPage: Sendable {
let members: [ConversationMember]
let rosterSummary: ConversationRosterSummary
let pagingToken: Data
let hasMore: Bool
}

/// Pages a chat's roster, most recently joined first. Leave `pagingToken` `nil` on the first
/// call; on every later call pass back the previous page's `pagingToken` — it is opaque,
/// server-generated, and bound to `conversationID`. `pageSize` is capped at 100 server-side.
///
/// A page may lag the returned `rosterSummary` for a large group (see `chat.v1.GetRoster`'s
/// staleness contract) — callers must merge each page against what the event stream has already
/// told them, by ``ConversationMember/version``, greater winning, rather than trusting a page to
/// be a complete, current snapshot.
func getRoster(owner: KeyPair, conversationID: ConversationID, pageSize: Int = 50, pagingToken: Data?, completion: @Sendable @escaping (Result<RosterFeedPage, ErrorGetRoster>) -> Void) {
let request = Flipcash_Chat_V1_GetRosterRequest.with {
$0.chatID = conversationID.proto
$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.getRoster(request, options: .unaryDefault)
guard response.result == .ok else {
logger.error("Failed to fetch roster")
await MainActor.run { completion(.failure(ErrorGetRoster(response.result))) }
return
}
guard response.hasRosterSummary else {
logger.error("Failed to fetch roster")
await MainActor.run { completion(.failure(.unknown)) }
return
}
let page = RosterFeedPage(
members: response.members.map(ConversationMember.init),
rosterSummary: ConversationRosterSummary(response.rosterSummary),
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)) }
}
}
}

/// Edits a group chat's title and/or picture. Every field is optional — only fields set on the
/// request change, atomically; a request that sets nothing is a no-op returning `.ok`. Only a
/// member the server permits to edit (``ConversationViewerState/canEdit``) may call this; anyone
/// else is `.denied`.
///
/// `pictureBlobID`, when supplied, must already be `READY` (uploaded via `BlobService`) — this
/// call does not upload it, mirroring `startChat`'s `pictureBlobID` contract. On `.titleModerated`
/// the server also reports which category flagged the title, carried the same way
/// `ErrorStartChat.titleModerated` carries it.
func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, pictureBlobID: BlobID?, completion: @Sendable @escaping (Result<Conversation, ErrorEditChat>) -> Void) {
let request = Flipcash_Chat_V1_EditChatRequest.with {
$0.chatID = conversationID.proto
if let title {
$0.title = .with { $0.value = title }
}
if let pictureBlobID {
$0.picture = .with { $0.blobID = .with { $0.value = pictureBlobID.data } }
}
$0.auth = owner.authFor(message: $0)
}

Task {
do {
let response = try await service.editChat(request, options: .unaryDefault)
guard response.result == .ok else {
logger.error("Failed to edit chat")
await MainActor.run { completion(.failure(ErrorEditChat(response.result, flaggedCategory: response.flaggedCategory))) }
return
}
guard response.hasChat else {
logger.error("Failed to edit 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)) }
}
}
}
}

// MARK: - Errors -
Expand Down Expand Up @@ -362,6 +460,35 @@ public enum ErrorUnmuteChat: Int, Error {
case rejected = -4
}

/// No `.ok` case: a success response resolves to `RosterFeedPage` in `ChatService.getRoster`'s `Result`,
/// it never reaches this type. Mapped explicitly from `GetRosterResponse.Result` — see
/// `ErrorGetRoster.init(_:)` — rather than via positional `rawValue:`, per this repo's convention for
/// a new result enum (`ErrorStartChat`, `ErrorEditChat`).
public enum ErrorGetRoster: Error, Sendable, Equatable {
case denied
case notFound
case unknown
case transportFailure
case cancelled
case rejected
}

/// No `.ok` case, and modelled on `ErrorStartChat` for the same reason: `.titleModerated` carries the
/// `flaggedCategory` the server reports for it. Mapped explicitly from `EditChatResponse.Result` — see
/// `ErrorEditChat.init(_:flaggedCategory:)` — never via positional `rawValue:`, since
/// `EditChatResponse.Result` has five cases against this file's usual three and a coincidental
/// positional match would silently break the day a case is inserted upstream.
public enum ErrorEditChat: Error, Sendable, Equatable {
case denied
case notFound
case titleModerated(Flipcash_Moderation_V1_FlaggedCategory)
case pictureBlobNotAccepted
case unknown
case transportFailure
case cancelled
case rejected
}

extension ErrorGetDmChatFeed: ServerError, TransportClassifiableError {
public var reportingLevel: ErrorReportingLevel {
switch self {
Expand Down Expand Up @@ -475,3 +602,67 @@ extension ErrorUnmuteChat: ServerError, TransportClassifiableError {
}
}
}

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

extension ErrorGetRoster {
/// Maps a non-`.ok` `GetRosterResponse.Result` to its domain error. Pure and synchronous so the
/// mapping is unit-testable without a live RPC, and total over the proto enum (`.ok` and
/// `.UNRECOGNIZED` both fold to `.unknown`) even though callers only reach this once they've
/// confirmed `result != .ok`.
init(_ result: Flipcash_Chat_V1_GetRosterResponse.Result) {
switch result {
case .ok:
self = .unknown
case .denied:
self = .denied
case .notFound:
self = .notFound
case .UNRECOGNIZED:
self = .unknown
}
}
}

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

extension ErrorEditChat {
/// Maps a non-`.ok` `EditChatResponse.Result` to its domain error, carrying `flaggedCategory`
/// through on `.titleModerated` the same way `ErrorStartChat.init(_:flaggedCategory:)` does.
/// Pure, synchronous, and total over the proto enum; callers only reach this once they've
/// confirmed `result != .ok`.
init(_ result: Flipcash_Chat_V1_EditChatResponse.Result, flaggedCategory: Flipcash_Moderation_V1_FlaggedCategory) {
switch result {
case .ok:
self = .unknown
case .denied:
self = .denied
case .notFound:
self = .notFound
case .titleModerated:
self = .titleModerated(flaggedCategory)
case .pictureBlobNotAccepted:
self = .pictureBlobNotAccepted
case .UNRECOGNIZED:
self = .unknown
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -226,16 +226,35 @@ public struct ConversationMember: Hashable, Sendable, Identifiable {
/// conversation and needs no separate profile fetch.
public var username: Username?

/// When this member most recently joined the chat (a rejoin carries the rejoin time, not the
/// original one). Set for a group's members on a `Chat.GetRoster` page and on
/// `RosterUpdate.MemberJoined` — the two carriers a client builds its member list from. `nil` on
/// `Metadata.members` (which carries only the viewer's own entry) and for every DM participant,
/// whose roster is fixed at creation.
public var joinedAt: Date?

/// The roster version at which this member most recently joined — equal to
/// ``ConversationRosterSummary/version`` at the moment of that join. Zero for a member joined at
/// the chat's creation and for every DM participant.
///
/// This is the merge key a client must use when reconciling a `Chat.GetRoster` page against what
/// the event stream has already told it: keep the entry with the greater `version` so a page that
/// trails the stream (see the `GetRoster` staleness contract) cannot resurrect a member the stream
/// has already removed. Set wherever ``joinedAt`` is.
public var version: UInt64

public var id: String { userID?.uuidString ?? displayName }

public init(userID: UserID?, displayName: String, phoneE164: String? = nil, readPointer: MessageID? = nil, readPointerTimestamp: Date? = nil, profilePicture: ProfilePicture? = nil, username: Username? = nil) {
public init(userID: UserID?, displayName: String, phoneE164: String? = nil, readPointer: MessageID? = nil, readPointerTimestamp: Date? = nil, profilePicture: ProfilePicture? = nil, username: Username? = nil, joinedAt: Date? = nil, version: UInt64 = 0) {
self.userID = userID
self.displayName = displayName
self.phoneE164 = phoneE164
self.readPointer = readPointer
self.readPointerTimestamp = readPointerTimestamp
self.profilePicture = profilePicture
self.username = username
self.joinedAt = joinedAt
self.version = version
}

/// The member's phone number formatted for display, used as a conversation
Expand Down Expand Up @@ -272,5 +291,7 @@ extension ConversationMember {
self.username = proto.userProfile.hasUsername
? Username(proto.userProfile.username)
: nil
self.joinedAt = proto.hasJoinedAt ? proto.joinedAt.date : nil
self.version = proto.version
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,22 @@ public struct ConversationViewerState: Hashable, Codable, Sendable {
/// client can store the message) but flagged for the client to suppress the notification.
public let mute: ConversationMuteState?

/// Whether the viewer may call `Chat.EditChat` on this chat. `false` for every DM and for a
/// group member the server does not permit to edit it.
///
/// Server-computed from the viewer's standing and the chat's rules and never derivable
/// client-side: show an edit affordance if and only if this is `true`. Defaults to `false`, so
/// an unset permission always means not permitted.
public let canEdit: Bool

/// 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) {
public init(mute: ConversationMuteState? = nil, canEdit: Bool = false, version: UInt64 = 0) {
self.mute = mute
self.canEdit = canEdit
self.version = version
}
}
Expand All @@ -63,6 +72,7 @@ extension ConversationViewerState {
public init(_ proto: Flipcash_Chat_V1_ViewerState) {
self.init(
mute: (proto.hasSettings && proto.settings.hasMute) ? ConversationMuteState(proto.settings.mute) : nil,
canEdit: proto.hasPermissions ? proto.permissions.canEdit : false,
version: proto.version
)
}
Expand Down
Loading
Loading