diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 06040ef0d..c51cd4b7b 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f17a20c394cb2f9ff4303f14ce7697f1acc08d81d8439e2b02f2289ea87dc0aa", + "originHash" : "3ee17d36ad31821cf605e1fe4265849c1713bd8469ddbae5a0189b76ad0be421", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "297e75a42b77b77da359ac553251e161abbf1f81", - "version" : "0.23.0" + "revision" : "417c89e138c6419bc36f8977f1ca544b0eb39560", + "version" : "0.24.0" } }, { diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index fe30826ce..fc5060d25 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -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. @@ -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 diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index a228ef887..9dcc365d4 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -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 } } diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 9d383a308..e6bd91829 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.9.0" - case .flipcash2: return "0.23.0" + case .flipcash2: return "0.24.0" } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index bb4b7cf42..9003567f0 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -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. diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index aa5e9a57f..158ad871d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -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) -> 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) -> 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) -> 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) -> Void) { let request = Flipcash_Chat_V1_MuteChatRequest.with { $0.chatID = conversationID.proto @@ -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 + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index fb17b8cef..e35f9d509 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -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 @@ -104,6 +110,7 @@ public struct Conversation: Identifiable, Hashable, Sendable { self.isPrivate = isPrivate self.inLobby = inLobby self.holdingsSummary = holdingsSummary + self.pinSummary = pinSummary } } @@ -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. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationPin.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationPin.swift new file mode 100644 index 000000000..0dcc5c36b --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationPin.swift @@ -0,0 +1,147 @@ +// +// ConversationPin.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI +import SwiftProtobuf + +/// One message pinned in a chat. See `chat.v1.Pin`. +public struct ConversationPin: Hashable, Sendable { + + public let messageID: MessageID + + /// Who pinned the message; `nil` when the id does not parse. + public let pinnedBy: UserID? + + /// When the message was pinned; `nil` when the server left it unset. + public let pinnedAt: Date? + + /// The pinned message, which may be redacted per the read's view mode. `nil` when the message + /// does not map to a displayable one. + public let message: ConversationMessage? + + /// The `chat.v1.Pin` bytes this pin was read from, kept so the cache can store the pin whole: + /// the pinned message may not be in the local message store. Not part of equality. + private let wireBytes: Data? + + public init(messageID: MessageID, pinnedBy: UserID?, pinnedAt: Date?, message: ConversationMessage?) { + self.init(messageID: messageID, pinnedBy: pinnedBy, pinnedAt: pinnedAt, message: message, wireBytes: nil) + } + + private init(messageID: MessageID, pinnedBy: UserID?, pinnedAt: Date?, message: ConversationMessage?, wireBytes: Data?) { + self.messageID = messageID + self.pinnedBy = pinnedBy + self.pinnedAt = pinnedAt + self.message = message + self.wireBytes = wireBytes + } + + public static func == (lhs: ConversationPin, rhs: ConversationPin) -> Bool { + lhs.messageID == rhs.messageID && lhs.pinnedBy == rhs.pinnedBy && lhs.pinnedAt == rhs.pinnedAt && lhs.message == rhs.message + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(messageID) + hasher.combine(pinnedBy) + hasher.combine(pinnedAt) + hasher.combine(message) + } + + /// Whether the pinned message was deleted after it was pinned. The server keeps such a pin as a + /// tombstone; clients don't show it. + public var isDeleted: Bool { + message?.isDeleted ?? false + } +} + +extension ConversationPin { + public init(_ proto: Flipcash_Chat_V1_Pin) { + self.init( + messageID: MessageID(proto.messageID), + pinnedBy: proto.hasPinnedBy ? try? UUID(data: proto.pinnedBy.value) : nil, + pinnedAt: proto.hasPinnedAt ? proto.pinnedAt.date : nil, + message: proto.hasMessage ? ConversationMessage(proto.message) : nil, + wireBytes: try? proto.serializedData() + ) + } + + /// Restores a pin from the bytes ``serializedBytes`` produced; `nil` when they do not decode. + public init?(serializedBytes: Data) { + guard let proto = try? Flipcash_Chat_V1_Pin(serializedBytes: serializedBytes) else { return nil } + self.init(proto) + } + + /// The serialized `chat.v1.Pin`, for the cache. A pin built without a proto carries only its ids + /// and times; the message is left out. + public var serializedBytes: Data? { + if let wireBytes { return wireBytes } + let proto = Flipcash_Chat_V1_Pin.with { + $0.messageID = .with { $0.value = messageID.value } + if let pinnedBy { + $0.pinnedBy = .with { $0.value = withUnsafeBytes(of: pinnedBy.uuid) { Data($0) } } + } + if let pinnedAt { $0.pinnedAt = .init(date: pinnedAt) } + } + return try? proto.serializedData() + } +} + +/// Summary of a chat's pins without holding the list. See `chat.v1.PinSummary`. +public struct ConversationPinSummary: Hashable, Sendable { + + /// The most recently pinned message; `nil` when nothing is pinned. + public let latest: ConversationPin? + + /// How many messages are currently pinned. + public let count: UInt32 + + /// Advanced by exactly one on every pin and unpin. Compared like + /// ``ConversationRosterSummary/version``: keep the greater value and drop the rest. + public let version: UInt64 + + public init(latest: ConversationPin?, count: UInt32, version: UInt64) { + self.latest = latest + self.count = count + self.version = version + } +} + +extension ConversationPinSummary { + public init(_ proto: Flipcash_Chat_V1_PinSummary) { + self.init( + latest: proto.hasLatest ? ConversationPin(proto.latest) : nil, + count: proto.count, + version: proto.version + ) + } +} + +/// What a `PinsChanged` update did to the pins. See `chat.v1.MetadataUpdate.PinsChanged`. +public enum ConversationPinChange: Hashable, Sendable { + case pinned(ConversationPin) + case unpinned(MessageID) +} + +/// One page of a chat's pins, most recently pinned first. See `chat.v1.GetPinnedMessagesResponse`. +public struct ConversationPinsPage: Sendable { + public let pins: [ConversationPin] + + /// The chat's pin summary as of the read. + public let summary: ConversationPinSummary? + + /// The cursor for the next page; pass it back to continue. + public let pagingToken: Data + + public let hasMore: Bool + + public init(pins: [ConversationPin], summary: ConversationPinSummary?, pagingToken: Data, hasMore: Bool) { + self.pins = pins + self.summary = summary + self.pagingToken = pagingToken + self.hasMore = hasMore + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift index e468d4a89..1d278cba1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift @@ -75,7 +75,11 @@ public struct ConversationViewerState: Hashable, Codable, Sendable { /// Whether the viewer may create invites to this chat. Server-computed; never derive it client-side. public let canInvite: Bool - public init(mute: ConversationMuteState? = nil, canEdit: Bool = false, version: UInt64 = 0, silence: ConversationSilenceState? = nil, canSilence: Bool = false, canBan: Bool = false, canInvite: Bool = false) { + /// Whether the viewer may pin and unpin messages in this chat. Server-computed; never derive it + /// client-side. Defaults to `false`, so an unset permission always means not permitted. + public let canPin: Bool + + public init(mute: ConversationMuteState? = nil, canEdit: Bool = false, version: UInt64 = 0, silence: ConversationSilenceState? = nil, canSilence: Bool = false, canBan: Bool = false, canInvite: Bool = false, canPin: Bool = false) { self.mute = mute self.canEdit = canEdit self.version = version @@ -83,10 +87,11 @@ public struct ConversationViewerState: Hashable, Codable, Sendable { self.canSilence = canSilence self.canBan = canBan self.canInvite = canInvite + self.canPin = canPin } private enum CodingKeys: String, CodingKey { - case mute, canEdit, version, silence, canSilence, canBan + case mute, canEdit, version, silence, canSilence, canBan, canInvite, canPin } /// Reads a row stored before a field existed. The synthesized decoder throws on a missing key, @@ -100,7 +105,9 @@ public struct ConversationViewerState: Hashable, Codable, Sendable { version: try container.decodeIfPresent(UInt64.self, forKey: .version) ?? 0, silence: try container.decodeIfPresent(ConversationSilenceState.self, forKey: .silence), canSilence: try container.decodeIfPresent(Bool.self, forKey: .canSilence) ?? false, - canBan: try container.decodeIfPresent(Bool.self, forKey: .canBan) ?? false + canBan: try container.decodeIfPresent(Bool.self, forKey: .canBan) ?? false, + canInvite: try container.decodeIfPresent(Bool.self, forKey: .canInvite) ?? false, + canPin: try container.decodeIfPresent(Bool.self, forKey: .canPin) ?? false ) } } @@ -114,7 +121,8 @@ extension ConversationViewerState { silence: (proto.hasRestrictions && proto.restrictions.hasSilence) ? ConversationSilenceState(proto.restrictions.silence) : nil, canSilence: (proto.hasPermissions && proto.permissions.hasModeration) ? proto.permissions.moderation.canSilence : false, canBan: (proto.hasPermissions && proto.permissions.hasModeration) ? proto.permissions.moderation.canBan : false, - canInvite: proto.hasPermissions ? proto.permissions.canInvite : false + canInvite: proto.hasPermissions ? proto.permissions.canInvite : false, + canPin: proto.hasPermissions ? proto.permissions.canPin : false ) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index c122ab295..9421899e0 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -59,7 +59,7 @@ public struct ConversationStore: Sendable { /// Replace the feed from a paged load, sorted most-recent-activity first. public mutating func setFeed(_ conversations: [Conversation]) { - let merged = conversations.map { keepingCoverPicture(keepingNewerActivity(keepingSelfReadPointer(seated($0)))) } + let merged = conversations.map { keepingPinSummary(keepingCoverPicture(keepingNewerActivity(keepingSelfReadPointer(seated($0))))) } self.conversations = merged.sorted { $0.lastActivity > $1.lastActivity } } @@ -69,7 +69,7 @@ public struct ConversationStore: Sendable { public mutating func setFeed(_ conversations: [Conversation], type: ConversationType) { // Only the incoming rows are merged: re-merging the other types against themselves would // read the store's own copy as the server acknowledging an unsynced READ pointer. - let incoming = conversations.filter { $0.type == type }.map { keepingCoverPicture(keepingNewerActivity(keepingSelfReadPointer(seated($0)))) } + let incoming = conversations.filter { $0.type == type }.map { keepingPinSummary(keepingCoverPicture(keepingNewerActivity(keepingSelfReadPointer(seated($0))))) } self.conversations = (self.conversations.filter { $0.type != type } + incoming) .sorted { $0.lastActivity > $1.lastActivity } } @@ -396,6 +396,9 @@ public struct ConversationStore: Sendable { case .rulesChanged(let conversationID, let rules): applyRulesChanged(rules, in: conversationID) return .none + case .pinsChanged(let conversationID, _, let summary): + applyPinsChanged(summary, in: conversationID) + return .none case .paymentRequestsChanged: // Request state lives in the app's `PaymentRequests` store, not here; the controller // hands the event to it. @@ -565,7 +568,7 @@ public struct ConversationStore: Sendable { public mutating func refreshViewerPermissions(from fetched: ConversationViewerState, in conversationID: ConversationID) -> Bool { guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return false } let current = conversations[index].viewerState ?? ConversationViewerState() - guard current.canEdit != fetched.canEdit || current.canSilence != fetched.canSilence || current.canBan != fetched.canBan else { + guard current.canEdit != fetched.canEdit || current.canSilence != fetched.canSilence || current.canBan != fetched.canBan || current.canPin != fetched.canPin else { return false } conversations[index].viewerState = ConversationViewerState( @@ -574,7 +577,8 @@ public struct ConversationStore: Sendable { version: current.version, silence: current.silence, canSilence: fetched.canSilence, - canBan: fetched.canBan + canBan: fetched.canBan, + canPin: fetched.canPin ) return true } @@ -625,6 +629,15 @@ public struct ConversationStore: Sendable { conversations[index].rules = rules } + /// Apply a pin summary delivered via `MetadataUpdate.PinsChanged`, or fetched with the chat. + /// Convergent like ``applyRosterUpdates(_:in:)``: only a version greater than the held one is + /// kept, so delivery order and re-delivery don't matter. No-ops for a chat the store doesn't hold. + public mutating func applyPinsChanged(_ summary: ConversationPinSummary, in conversationID: ConversationID) { + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } + guard summary.version > (conversations[index].pinSummary?.version ?? 0) else { return } + conversations[index].pinSummary = summary + } + /// Drops the cached viewer state for a chat the signed-in user just left. /// /// The server clears mute on leave and restarts the version with it, so this discards the @@ -636,7 +649,7 @@ public struct ConversationStore: Sendable { } private mutating func upsert(_ conversation: Conversation) { - var conversation = keepingViewerState(keepingCoverPicture(keepingNewerActivity(keepingSelfReadPointer(seated(conversation))))) + var conversation = keepingViewerState(keepingPinSummary(keepingCoverPicture(keepingNewerActivity(keepingSelfReadPointer(seated(conversation)))))) if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { if conversation.type == .group { conversation.members = mergedMembers(conversation.members, over: conversations[index].members) @@ -726,7 +739,8 @@ public struct ConversationStore: Sendable { version: incoming.version, silence: incoming.silence, canSilence: incoming.canSilence || held.canSilence, - canBan: incoming.canBan || held.canBan + canBan: incoming.canBan || held.canBan, + canPin: incoming.canPin || held.canPin ) } return conversation @@ -745,6 +759,21 @@ public struct ConversationStore: Sendable { return conversation } + /// `conversation` with the stored pin summary kept when the incoming copy carries none or an older + /// one. Feed and list RPCs may omit the summary, so an unset one there is "not reported", not + /// "cleared"; `GetChat` and `pinsChanged` are what change it. + private func keepingPinSummary(_ conversation: Conversation) -> Conversation { + guard let held = conversations.first(where: { $0.id == conversation.id })?.pinSummary else { + return conversation + } + if let incoming = conversation.pinSummary, incoming.version >= held.version { + return conversation + } + var conversation = conversation + conversation.pinSummary = held + return conversation + } + /// `conversation` with the self READ pointer raised to the one the store already holds, when the /// server's copy is behind it. The server never lowers a pointer, so a lower copy means an advance /// it hasn't taken yet; that advance is kept and recorded as unsynced for the caller to re-send. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift index aacd78749..7041b6673 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift @@ -74,6 +74,11 @@ public enum ConversationStreamEvent: Sendable { /// `rules` is the full replacement set. Best-effort and applied as received, like ``titleChanged``. case rulesChanged(conversationID: ConversationID, rules: ConversationRules) + /// A message was pinned or unpinned. Like ``rosterChanged``, a convergent overlay: the store keeps + /// `summary` only when its version is greater than the one held. `summary` is always set on the wire, + /// so an update without it is dropped at decode. + case pinsChanged(conversationID: ConversationID, change: ConversationPinChange?, summary: ConversationPinSummary) + /// Payment requests in the chat changed. Best-effort and outside the gap-detected event log: a /// missed update is reconciled by the next fetch of the request. Applying them to request state /// follows `PaymentRequestUpdate`'s contract; see ``DecodedPaymentRequestUpdate``. @@ -256,6 +261,15 @@ extension ConversationStreamEvent { case .rulesChanged(let changed): guard changed.hasNewRules else { break } events.append(.rulesChanged(conversationID: conversationID, rules: ConversationRules(changed.newRules))) + case .pinsChanged(let changed): + guard changed.hasSummary else { break } + let change: ConversationPinChange? + switch changed.change { + case .pinned(let pin): change = .pinned(ConversationPin(pin)) + case .unpinned(let messageID): change = .unpinned(MessageID(messageID)) + case nil: change = nil + } + events.append(.pinsChanged(conversationID: conversationID, change: change, summary: ConversationPinSummary(changed.summary))) case nil: break } diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 33804e5c7..698a711fe 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -109,7 +109,8 @@ nonisolated extension Database { creator: row[c.creator], useE2Ee: row[c.useE2Ee], isPrivate: row[c.isPrivate], - coverPicture: conversationCoverPicture(from: row) + coverPicture: conversationCoverPicture(from: row), + pinSummary: conversationPinSummary(from: row) ) } } @@ -482,6 +483,29 @@ nonisolated extension Database { } } + /// Store a pin summary delivered by `PinsChanged`: applied only when strictly newer than the stored + /// one, like ``ConversationStore/applyPinsChanged(_:in:)``. No-ops for a conversation not yet in + /// the feed. + public func applyPinSummary(_ summary: ConversationPinSummary, for conversationID: ConversationID) throws { + try write { _ in + try writePinSummary(summary, conversationID: conversationID, replacingEqualVersion: false) + } + } + + private func writePinSummary(_ summary: ConversationPinSummary, conversationID: ConversationID, replacingEqualVersion: Bool) throws { + try write { writer in + let c = ConversationTable() + let versionGuard = replacingEqualVersion ? c.pinVersion <= summary.version : c.pinVersion < summary.version + try writer.run( + c.table.filter(c.id == conversationID.data && versionGuard).update( + c.pinCount <- UInt64(summary.count), + c.pinVersion <- summary.version, + c.pinLatest <- summary.latest?.serializedBytes + ) + ) + } + } + /// Must be called inside a `writer.transaction`. private func writeConversation(_ conversation: Conversation) throws { try write { writer in @@ -528,6 +552,12 @@ nonisolated extension Database { ) } + // Same keep rule as ConversationStore: a copy with no summary or an older one leaves the + // stored pins alone; an equal or newer version replaces them. + if let pins = conversation.pinSummary { + try writePinSummary(pins, conversationID: conversation.id, replacingEqualVersion: true) + } + try writer.run(m.table.filter(m.conversationId == conversation.id.data).delete()) for member in conversation.members { try writer.run( @@ -847,6 +877,17 @@ nonisolated extension Database { return try? JSONDecoder().decode(ConversationRules.self, from: data) } + /// Nil for a row that has never held a summary (count 0, version 0, no pin), which is what every + /// row reads as before its first pin write. A blob that no longer decodes loads as no latest pin. + private func conversationPinSummary(from row: RowIterator.Element) -> ConversationPinSummary? { + let c = ConversationTable() + let latest = row[c.pinLatest].flatMap { ConversationPin(serializedBytes: $0) } + let count = row[c.pinCount] + let version = row[c.pinVersion] + guard latest != nil || count > 0 || version > 0 else { return nil } + return ConversationPinSummary(latest: latest, count: UInt32(clamping: count), version: version) + } + /// Returns nil when the chat carries no viewer state, and also when the /// stored JSON doesn't decode. Degrading to "nothing known" is safe: it /// renders as unmuted for the moment it takes the next metadata fetch to diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index b1f2f1593..3f6892d01 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 61 + public static let schemaVersion = 62 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 49b67f702..a425b4db6 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -322,6 +322,13 @@ nonisolated public struct ConversationTable: Sendable { // as its until-timestamp, never as a computed muted boolean, because a // timed mute lapses with no server signal. public let viewerState = Expression ("viewerState") + // Pin summary. Count and version default to 0, which also reads back as "no summary". The latest + // pin is the serialized `chat.v1.Pin`: the pinned message may not be in the local message store. + // Written only by a copy whose version is not older than the stored one, so a feed row without a + // summary never clears pins. + public let pinCount = Expression ("pinCount") + public let pinVersion = Expression ("pinVersion") + public let pinLatest = Expression ("pinLatest") } // The group chats the signed-in user has joined. Its own table rather than a @@ -672,6 +679,9 @@ nonisolated extension Database { t.column(conversationTable.creator) t.column(conversationTable.useE2Ee, defaultValue: false) t.column(conversationTable.isPrivate, defaultValue: false) + t.column(conversationTable.pinCount, defaultValue: 0) + t.column(conversationTable.pinVersion, defaultValue: 0) + t.column(conversationTable.pinLatest) }) } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationPinTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationPinTests.swift new file mode 100644 index 000000000..a6cade288 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationPinTests.swift @@ -0,0 +1,210 @@ +// +// ConversationPinTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashAPI +@testable import FlipcashCore + +@Suite("Conversation pins") +struct ConversationPinTests { + + private let conversationBytes = Data(repeating: 0xAB, count: 32) + private let userBytes = Data(repeating: 0x11, count: 16) + + private func pinProto(_ id: UInt64, text: String = "hi") -> Flipcash_Chat_V1_Pin { + .with { + $0.messageID = .with { $0.value = id } + $0.pinnedBy = .with { $0.value = userBytes } + $0.pinnedAt = .init(date: Date(timeIntervalSince1970: 700)) + $0.message = .with { + $0.messageID = .with { $0.value = id } + $0.ts = .init(date: Date(timeIntervalSince1970: 600)) + $0.content = [.with { $0.text = .with { $0.text = text } }] + } + } + } + + private func summaryProto(count: UInt32, version: UInt64, latest: UInt64? = nil) -> Flipcash_Chat_V1_PinSummary { + .with { + $0.count = count + $0.version = version + if let latest { $0.latest = pinProto(latest) } + } + } + + private func conversationID(_ byte: UInt8 = 1) -> ConversationID { + ConversationID(data: Data(repeating: byte, count: 32)) + } + + private func conversation(pinSummary: ConversationPinSummary?) -> Conversation { + Conversation(id: conversationID(), members: [], lastMessage: nil, lastActivity: Date(timeIntervalSince1970: 0), pinSummary: pinSummary) + } + + // MARK: - Cache bytes + + @Test("A pin restored from its serialized bytes keeps the pinned message") + func serializedBytesRoundTrip() throws { + let pin = ConversationPin(pinProto(7, text: "pinned")) + let bytes = try #require(pin.serializedBytes) + let restored = try #require(ConversationPin(serializedBytes: bytes)) + #expect(restored == pin) + #expect(restored.message?.content == .text("pinned")) + #expect(restored.serializedBytes == bytes) + } + + @Test("Bytes that are not a pin do not restore") + func garbageBytesDoNotRestore() { + #expect(ConversationPin(serializedBytes: Data([0xFF, 0xFF, 0xFF])) == nil) + } + + // MARK: - Mapping + + @Test("Pin maps ids, pinner, time and message") + func pinMaps() { + let pin = ConversationPin(pinProto(7, text: "pinned")) + #expect(pin.messageID == MessageID(value: 7)) + #expect(pin.pinnedBy == (try? UUID(data: userBytes))) + #expect(pin.pinnedAt == Date(timeIntervalSince1970: 700)) + #expect(pin.message?.content == .text("pinned")) + #expect(!pin.isDeleted) + } + + @Test("A pin whose message was deleted reports isDeleted") + func deletedPin() { + var proto = pinProto(7) + proto.message.content = [.with { $0.deleted = .init() }] + let pin = ConversationPin(proto) + #expect(pin.isDeleted) + } + + @Test("PinSummary maps latest, count and version; latest is optional") + func summaryMaps() { + let summary = ConversationPinSummary(summaryProto(count: 2, version: 9, latest: 7)) + #expect(summary.count == 2) + #expect(summary.version == 9) + #expect(summary.latest?.messageID == MessageID(value: 7)) + #expect(ConversationPinSummary(summaryProto(count: 0, version: 1)).latest == nil) + } + + @Test("Metadata carries pinSummary only when set") + func metadataMapsPinSummary() { + let withSummary = Flipcash_Chat_V1_Metadata.with { + $0.chatID = .with { $0.value = conversationBytes } + $0.pinSummary = summaryProto(count: 1, version: 4) + } + #expect(Conversation(withSummary).pinSummary?.version == 4) + #expect(Conversation(.with { $0.chatID = .with { $0.value = conversationBytes } }).pinSummary == nil) + } + + @Test("Viewer state maps canPin and defaults it to false") + func viewerStateMapsCanPin() { + let granted = ConversationViewerState(.with { $0.permissions = .with { $0.canPin = true } }) + #expect(granted.canPin) + #expect(!ConversationViewerState(.init()).canPin) + } + + // MARK: - Stream decode + + @Test("PinsChanged with a pin decodes to pinsChanged(.pinned)") + func decodePinned() { + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.metadataUpdates = [.with { + $0.pinsChanged = .with { + $0.pinned = pinProto(7) + $0.summary = summaryProto(count: 1, version: 3, latest: 7) + } + }] + } + } + + guard case .pinsChanged(let id, let change, let summary)? = ConversationStreamEvent.decode(event).first else { + Issue.record("expected .pinsChanged"); return + } + #expect(id == ConversationID(data: conversationBytes)) + guard case .pinned(let pin)? = change else { Issue.record("expected .pinned"); return } + #expect(pin.messageID == MessageID(value: 7)) + #expect(summary.version == 3) + } + + @Test("PinsChanged with an unpin decodes to pinsChanged(.unpinned)") + func decodeUnpinned() { + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.metadataUpdates = [.with { + $0.pinsChanged = .with { + $0.unpinned = .with { $0.value = 7 } + $0.summary = summaryProto(count: 0, version: 4) + } + }] + } + } + + guard case .pinsChanged(_, let change, let summary)? = ConversationStreamEvent.decode(event).first else { + Issue.record("expected .pinsChanged"); return + } + #expect(change == .unpinned(MessageID(value: 7))) + #expect(summary.count == 0) + } + + @Test("PinsChanged without its required summary decodes to nothing") + func decodeWithoutSummary() { + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.metadataUpdates = [.with { $0.pinsChanged = .with { $0.unpinned = .with { $0.value = 7 } } }] + } + } + #expect(ConversationStreamEvent.decode(event).isEmpty) + } + + // MARK: - Store + + @Test("A pin summary applies only when its version is greater") + func storeAppliesGreaterVersion() { + var store = ConversationStore() + store.setFeed([conversation(pinSummary: ConversationPinSummary(latest: nil, count: 1, version: 5))]) + + store.applyPinsChanged(ConversationPinSummary(latest: nil, count: 9, version: 4), in: conversationID()) + store.applyPinsChanged(ConversationPinSummary(latest: nil, count: 9, version: 5), in: conversationID()) + #expect(store.conversations[0].pinSummary?.count == 1) + + store.applyPinsChanged(ConversationPinSummary(latest: nil, count: 2, version: 6), in: conversationID()) + #expect(store.conversations[0].pinSummary?.count == 2) + } + + @Test("A feed copy without a pin summary, or with an older one, keeps the held summary") + func feedKeepsHeldSummary() { + var store = ConversationStore() + store.setFeed([conversation(pinSummary: ConversationPinSummary(latest: nil, count: 3, version: 8))]) + + store.setFeed([conversation(pinSummary: nil)]) + #expect(store.conversations[0].pinSummary?.version == 8) + + store.setFeed([conversation(pinSummary: ConversationPinSummary(latest: nil, count: 1, version: 2))]) + #expect(store.conversations[0].pinSummary?.version == 8) + + store.setFeed([conversation(pinSummary: ConversationPinSummary(latest: nil, count: 4, version: 9))]) + #expect(store.conversations[0].pinSummary?.version == 9) + } + + @Test("Pin results map positionally and unknown raw values fall to nil") + func resultEnumsMapPositionally() { + typealias R = Flipcash_Chat_V1_PinMessageResponse.Result + #expect(ErrorPinMessage(rawValue: R.ok.rawValue) == .ok) + #expect(ErrorPinMessage(rawValue: R.denied.rawValue) == .denied) + #expect(ErrorPinMessage(rawValue: R.notFound.rawValue) == .notFound) + #expect(ErrorPinMessage(rawValue: R.messageNotFound.rawValue) == .messageNotFound) + #expect(ErrorPinMessage(rawValue: R.tooManyPins.rawValue) == .tooManyPins) + #expect(ErrorPinMessage(rawValue: R.messageNotAllowed.rawValue) == .messageNotAllowed) + #expect(ErrorUnpinMessage(rawValue: Flipcash_Chat_V1_UnpinMessageResponse.Result.notFound.rawValue) == .notFound) + #expect(ErrorGetPinnedMessages(rawValue: Flipcash_Chat_V1_GetPinnedMessagesResponse.Result.denied.rawValue) == .denied) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index fba3237f6..cf2661ba5 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -111,6 +111,9 @@ struct TransportClassificationTests { @Test func errorBanUser() { assertClassifies(ErrorBanUser.self) } @Test func errorUnbanUser() { assertClassifies(ErrorUnbanUser.self) } @Test func errorGetRestrictedUsers() { assertClassifies(ErrorGetRestrictedUsers.self) } + @Test func errorPinMessage() { assertClassifies(ErrorPinMessage.self) } + @Test func errorUnpinMessage() { assertClassifies(ErrorUnpinMessage.self) } + @Test func errorGetPinnedMessages() { assertClassifies(ErrorGetPinnedMessages.self) } @Test func errorCreatePaymentRequest() { assertClassifies(ErrorCreatePaymentRequest.self) } @Test func errorGetPaymentRequest() { assertClassifies(ErrorGetPaymentRequest.self) } @Test func errorGetChatPaymentRequests() { assertClassifies(ErrorGetChatPaymentRequests.self) } diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 7b04b908b..57e8a4498 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -10,6 +10,7 @@ import Testing @testable import FlipcashCore import FlipcashStore @testable import Flipcash +import FlipcashAPI @Suite("Conversation offline cache round-trip") struct DatabaseConversationsTests { @@ -802,6 +803,19 @@ struct DatabaseConversationsTests { #expect(loaded.viewerState?.canInvite == true) } + @Test("canPin round-trips, so a pinner's pin controls survive a cold start") + func viewerStateCanPinRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + var stored = conversation(byte: 1) + stored.viewerState = ConversationViewerState(canPin: true) + + try database.upsertConversation(stored) + + let loaded = try #require(try database.getConversations().first) + #expect(loaded.viewerState?.canPin == true) + } + @Test("A conversation with no viewer state round-trips as none") func viewerStateAbsentRoundTrip() throws { let (database, url) = try Database.makeTemp() @@ -992,6 +1006,114 @@ struct DatabaseConversationsTests { #expect(try database.getConversations().first?.coverPicture == cover(0x20)) } + // MARK: - Pin summary - + + private func pin(_ id: UInt64) -> ConversationPin { + ConversationPin(.with { + $0.messageID = .with { $0.value = id } + $0.pinnedAt = .init(date: Date(timeIntervalSince1970: 700)) + $0.message = .with { + $0.messageID = .with { $0.value = id } + $0.ts = .init(date: Date(timeIntervalSince1970: 600)) + $0.content = [.with { $0.text = .with { $0.text = "pin-\(id)" } }] + } + }) + } + + private func pinned(_ summary: ConversationPinSummary?) -> Conversation { + Conversation( + id: ConversationID.test(9), + members: [], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 100), + type: .group, + pinSummary: summary + ) + } + + private func storedPins(_ database: Database) throws -> ConversationPinSummary? { + try database.getConversations().first?.pinSummary + } + + @Test("A row that never held pins loads without a summary") + func pinSummaryAbsentByDefault() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + try database.upsertConversation(pinned(nil)) + #expect(try storedPins(database) == nil) + } + + @Test("The pin summary, with the latest pin's message, survives a cold load") + func pinSummaryRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let summary = ConversationPinSummary(latest: pin(5), count: 2, version: 4) + try database.upsertConversation(pinned(summary)) + + let loaded = try #require(try storedPins(database)) + #expect(loaded == summary) + #expect(loaded.latest?.message?.content == .text("pin-5")) + } + + @Test("A write with no summary, or an older one, keeps the stored pins; equal or newer replaces") + func pinSummaryWriteRules() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let v3 = ConversationPinSummary(latest: pin(3), count: 3, version: 3) + try database.upsertConversation(pinned(v3)) + + try database.upsertConversation(pinned(nil)) + #expect(try storedPins(database) == v3) + + try database.upsertConversation(pinned(ConversationPinSummary(latest: pin(2), count: 2, version: 2))) + #expect(try storedPins(database) == v3) + + let equal = ConversationPinSummary(latest: pin(4), count: 4, version: 3) + try database.upsertConversation(pinned(equal)) + #expect(try storedPins(database) == equal) + + let newer = ConversationPinSummary(latest: nil, count: 0, version: 4) + try database.upsertConversation(pinned(newer)) + #expect(try storedPins(database) == newer) + } + + @Test("A feed replace without a summary keeps the stored pins") + func pinSummarySurvivesFeedReplace() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let v3 = ConversationPinSummary(latest: pin(3), count: 1, version: 3) + try database.upsertConversation(pinned(v3)) + try database.replaceGroupFeed([pinned(nil)], departed: []) + #expect(try storedPins(database) == v3) + } + + @Test("PinsChanged applies only when strictly newer than the stored version") + func applyPinSummaryStrictlyNewer() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(9) + let v3 = ConversationPinSummary(latest: pin(3), count: 1, version: 3) + try database.upsertConversation(pinned(v3)) + + try database.applyPinSummary(ConversationPinSummary(latest: pin(2), count: 5, version: 2), for: id) + #expect(try storedPins(database) == v3) + + try database.applyPinSummary(ConversationPinSummary(latest: pin(8), count: 5, version: 3), for: id) + #expect(try storedPins(database) == v3) + + let v4 = ConversationPinSummary(latest: pin(4), count: 2, version: 4) + try database.applyPinSummary(v4, for: id) + #expect(try storedPins(database) == v4) + } + + @Test("PinsChanged for a conversation that is not stored writes nothing") + func applyPinSummaryUnknownConversation() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + try database.applyPinSummary(ConversationPinSummary(latest: nil, count: 1, version: 1), for: ConversationID.test(9)) + #expect(try database.getConversations().isEmpty) + } + @Test("A typed feed replace keeps the other type's conversations and members") func typedFeedReplaceKeepsOtherType() throws { let (database, url) = try Database.makeTemp()