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.

8 changes: 8 additions & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,10 @@ final class ConversationController {
// Only delivered to a chat's members, same reasoning as `.titleChanged`: the feed brings
// the current rules with it. Nothing to fetch.
return
case .pinsChanged:
// Only delivered to a chat's members, same reasoning as `.titleChanged`: `GetChat` brings
// the current pin summary 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.
Expand Down Expand Up @@ -1573,6 +1577,10 @@ final class ConversationController {
// 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 .pinsChanged(let conversationID, _, let summary):
// The database applies the same strictly-newer rule as the store, so a stale or re-delivered
// update never overwrites the stored pins.
persist(operation: "apply-pin-summary") { try database.applyPinSummary(summary, for: conversationID) }
case .paymentRequestsChanged:
// Request state is session-only and refetched; nothing to persist.
break
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, .rulesChanged, .paymentRequestsChanged:
.viewerStateChanged, .titleChanged, .pictureChanged, .coverPictureChanged, .descriptionChanged, .reactionsChanged, .lobbyChanged, .rulesChanged, .pinsChanged, .paymentRequestsChanged:
return event
}
}
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.23.0"
case .flipcash2: return "0.24.0"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,29 @@ extension FlipClient {
return all
}

/// Pins `messageID` in `conversationID` and returns the pin summary after the change.
public func pinMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws -> ConversationPinSummary {
try await withCheckedThrowingContinuation { c in
chatService.pinMessage(owner: owner, conversationID: conversationID, messageID: messageID) { c.resume(with: $0) }
}
}

/// Unpins `messageID` in `conversationID` and returns the pin summary after the change. Succeeds
/// as a no-op when the message isn't pinned.
public func unpinMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws -> ConversationPinSummary {
try await withCheckedThrowingContinuation { c in
chatService.unpinMessage(owner: owner, conversationID: conversationID, messageID: messageID) { c.resume(with: $0) }
}
}

/// One page of the pins in `conversationID`, most recently pinned first. Pass the previous page's
/// `pagingToken` to continue while `hasMore` is set.
public func getPinnedMessages(owner: KeyPair, conversationID: ConversationID, pagingToken: Data? = nil, viewMode: ConversationViewMode = .full) async throws -> ConversationPinsPage {
try await withCheckedThrowingContinuation { c in
chatService.getPinnedMessages(owner: owner, conversationID: conversationID, pagingToken: pagingToken, viewMode: viewMode) { c.resume(with: $0) }
}
}

/// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,100 @@ final class ChatService: Sendable {
}
}

/// Pins `messageID` in `conversationID`. Returns the pin summary after the change. The server
/// enforces the pin cap and answers `.tooManyPins` past it.
func pinMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, completion: @Sendable @escaping (Result<ConversationPinSummary, ErrorPinMessage>) -> Void) {
let request = Flipcash_Chat_V1_PinMessageRequest.with {
$0.chatID = conversationID.proto
$0.messageID = messageID.proto
$0.auth = owner.authFor(message: $0)
}

Task {
do {
let response = try await service.pinMessage(request, options: .unaryDefault)
let error = ErrorPinMessage(rawValue: response.result.rawValue) ?? .unknown
guard error == .ok else {
logger.error("Failed to pin message", metadata: ["error": "\(error)"])
await MainActor.run { completion(.failure(error)) }
return
}
// An OK without a summary reads as version 0, which never replaces a held summary.
await MainActor.run { completion(.success(ConversationPinSummary(response.pinSummary))) }
} catch let error as RPCError {
await MainActor.run { completion(.failure(.from(transportError: error))) }
} catch {
await MainActor.run { completion(.failure(.unknown)) }
}
}
}

/// Unpins `messageID` in `conversationID`. Unpinning a message that isn't pinned succeeds as a
/// no-op. Returns the pin summary after the change.
func unpinMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, completion: @Sendable @escaping (Result<ConversationPinSummary, ErrorUnpinMessage>) -> Void) {
let request = Flipcash_Chat_V1_UnpinMessageRequest.with {
$0.chatID = conversationID.proto
$0.messageID = messageID.proto
$0.auth = owner.authFor(message: $0)
}

Task {
do {
let response = try await service.unpinMessage(request, options: .unaryDefault)
let error = ErrorUnpinMessage(rawValue: response.result.rawValue) ?? .unknown
guard error == .ok else {
logger.error("Failed to unpin message", metadata: ["error": "\(error)"])
await MainActor.run { completion(.failure(error)) }
return
}
// An OK without a summary reads as version 0, which never replaces a held summary.
await MainActor.run { completion(.success(ConversationPinSummary(response.pinSummary))) }
} catch let error as RPCError {
await MainActor.run { completion(.failure(.from(transportError: error))) }
} catch {
await MainActor.run { completion(.failure(.unknown)) }
}
}
}

/// One page of the pins in `conversationID`, most recently pinned first.
func getPinnedMessages(owner: KeyPair, conversationID: ConversationID, pageSize: Int = 50, pagingToken: Data?, viewMode: ConversationViewMode = .full, completion: @Sendable @escaping (Result<ConversationPinsPage, ErrorGetPinnedMessages>) -> Void) {
let request = Flipcash_Chat_V1_GetPinnedMessagesRequest.with {
$0.chatID = conversationID.proto
$0.queryOptions = .with {
$0.pageSize = Int32(pageSize)
if let pagingToken {
$0.pagingToken = .with { $0.value = pagingToken }
}
}
$0.viewMode = viewMode.proto
$0.auth = owner.authFor(message: $0)
}

Task {
do {
let response = try await service.getPinnedMessages(request, options: .unaryDefault)
let error = ErrorGetPinnedMessages(rawValue: response.result.rawValue) ?? .unknown
guard error == .ok else {
logger.error("Failed to fetch pinned messages", metadata: ["error": "\(error)"])
await MainActor.run { completion(.failure(error)) }
return
}
let page = ConversationPinsPage(
pins: response.pins.map(ConversationPin.init),
summary: response.hasPinSummary ? ConversationPinSummary(response.pinSummary) : nil,
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)) }
}
}
}

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
Expand Down Expand Up @@ -1903,3 +1997,69 @@ extension ErrorGetRestrictedUsers: ServerError, TransportClassifiableError {
}
}
}

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

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

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

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

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

extension ErrorGetPinnedMessages: 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 @@ -84,7 +84,13 @@ public struct Conversation: Identifiable, Hashable, Sendable {
/// server's copy refreshes it.
public var holdingsSummary: HoldingsSummary?

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, creator: UserID? = nil, useE2Ee: Bool = false, isPrivate: Bool = false, inLobby: Bool = false, description: String? = nil, coverPicture: ProfilePicture? = nil, holdingsSummary: HoldingsSummary? = nil) {
/// Summary of the chat's pins. `nil` until the server reports one: the feed RPCs may leave it
/// unset, so the store keeps a held summary when a copy carries none (see
/// ``ConversationPinSummary/version``) and `GetChat` is what fetches it. Not persisted: a
/// conversation restored from the local database reports `nil` until the server's copy refreshes it.
public var pinSummary: ConversationPinSummary?

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, creator: UserID? = nil, useE2Ee: Bool = false, isPrivate: Bool = false, inLobby: Bool = false, description: String? = nil, coverPicture: ProfilePicture? = nil, holdingsSummary: HoldingsSummary? = nil, pinSummary: ConversationPinSummary? = nil) {
self.id = id
self.members = members
self.lastMessage = lastMessage
Expand All @@ -104,6 +110,7 @@ public struct Conversation: Identifiable, Hashable, Sendable {
self.isPrivate = isPrivate
self.inLobby = inLobby
self.holdingsSummary = holdingsSummary
self.pinSummary = pinSummary
}
}

Expand Down Expand Up @@ -194,6 +201,7 @@ extension Conversation {
self.isPrivate = proto.isPrivate
self.inLobby = proto.inLobby
self.holdingsSummary = proto.hasHoldingsSummary ? HoldingsSummary(proto.holdingsSummary) : nil
self.pinSummary = proto.hasPinSummary ? ConversationPinSummary(proto.pinSummary) : nil
}

/// The member that isn't the signed-in user, used to title the conversation.
Expand Down
Loading
Loading