diff --git a/CHANGELOG.md b/CHANGELOG.md index bcdc9ed..9c092a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,79 @@ called out explicitly even when nothing else did. release notes, so a version with no entry here does not release. Write the entry in the same PR that syncs the contract, while the diff is still in front of you. +## 0.6.0 + +Synced to [`flipcash2-protobuf-api@35f99814`](https://github.com/code-payments/flipcash2-protobuf-api/commit/35f9981400947921bbe2872be63b0bb77a569e34), +picking up six upstream changes: [#93](https://github.com/code-payments/flipcash2-protobuf-api/pull/93) +through [#98](https://github.com/code-payments/flipcash2-protobuf-api/pull/98). + +Three fields were renamed in place. Nothing was renumbered, so the wire format is unchanged and an +old client keeps decoding new messages — but the generated accessors move, and upgrading will not +compile until you rename with them. Details under Changed. + +### Changed + +- `messaging.v1.EmojiReaction.sequence` and `messaging.v1.ReactionUpdate.sequence` are both now + `version`. **Source-breaking.** Field numbers (5 and 6), types and semantics are untouched; the + name was wrong for what the value does. It is a per-aggregate version advanced by one on every + change, not a position in a sequence, and it was already documented as opaque and ordering-only. + Swift `.sequence` becomes `.version`; Kotlin `getSequence()`/`setSequence` become + `getVersion()`/`setVersion`. + + Keep applying reaction updates last-writer-wins by this value per (message, emoji), and per actor + for `reacted_by_self`. It is still not the chat event sequence and still not gapless. + +- `blob.v1.AccessContext.profile` is now `user_profile`, and the `scope` oneof gains a third arm. + **Source-breaking twice over.** The rename keeps field number 2 and type `common.v1.UserId`, so + Swift's oneof case `.profile` becomes `.userProfile` and Kotlin's `ScopeCase.PROFILE` becomes + `ScopeCase.USER_PROFILE`. Separately, an exhaustive `switch` or `when` over `scope` needs the new + `chat_profile` arm below. Code that only sets one arm sees the rename; code that reads the oneof + exhaustively sees both. + +### Added + +- `chat_profile` on `blob.v1.AccessContext`, field 3, typed `common.v1.ChatId` — the third `scope` + arm. It authorizes reading a chat's public profile picture, and only that: the blob must be a + rendition of the chat's *current* picture, so renditions of a superseded picture stop resolving + through it. That expiry is the difference from the `chat` scope, which does not narrow to one blob. + +- `picture` on `chat.v1.Metadata`, field 9, typed `blob.v1.Media`. Group chats only, and optional. + This is what makes `chat/v1/model.proto` import `blob/v1/model.proto` for the first time, so a + build that compiles the chat package now needs the blob package alongside it. + +- `roster_summary` on `chat.v1.Metadata`, field 10, typed `chat.v1.RosterSummary` and marked + required. `RosterSummary` describes a chat's member list without containing it: `member_count` + (field 1) is the roster's true size, where `Metadata.members` is only a subset for large group + chats, and `version` (field 2) advances by one on every membership-record change — a join, a + leave, and in future anything the chat records about a member, such as a role. + + `version` is opaque. Compare it against the last value you held: different means your cached member + list may be stale and should be refetched. On a stream, apply the greater value and drop the rest, + so delivery order stops mattering. There is no delta to fetch against it, only a refetch. It never + moves for a profile change — profiles are hydrated fresh onto every response carrying a member. + +- `rules` on `chat.v1.Metadata`, field 11, typed `chat.v1.Rules`. Group chats only, and unset means + no participation requirements, so existing chats are unaffected. + + `Rules` splits into two independently optional classes: `listener` (field 1, up to 32) gates + reading and joining, `speaker` (field 2, up to 32) gates sending. Empty `listener` means anyone can + read and join; empty `speaker` means any member can send. All rules within a class must be + satisfied, and speaker rules apply on top of listener rules — a user has to be able to listen + before they can speak. + + `ListenerRules` and `SpeakerRules` are separate messages with identical `kind` oneofs, each + requiring one of `minimum_balance` (field 1) or `staff` (field 2). `StaffRequirement` is empty and + means `UserFlags.is_staff`. `MinimumBalanceRequirement` carries a required fiat `amount` and a + `mints` list that is currently capped at one entry — empty applies the requirement across all + mints, and the field is repeated only so more can be allowed later. + +### Unchanged + +No field or enum was renumbered, and no result enum gained, lost or reordered a case — the three +renames all keep their field numbers, and the new `chat_profile` arm appends at 3. No service, RPC or +message was removed, and no existing message changed the type of an existing field. Every break in +this release is a name your compiler will point at, not a value that silently means something else. + ## 0.5.0 Synced to [`flipcash2-protobuf-api@797052dd`](https://github.com/code-payments/flipcash2-protobuf-api/commit/797052dd1070662f97407427665fd48967abfac6), diff --git a/Sources/Flipcash2ClientProtocol/blob_v1_model.pb.swift b/Sources/Flipcash2ClientProtocol/blob_v1_model.pb.swift index 36c9dc9..bedb991 100644 --- a/Sources/Flipcash2ClientProtocol/blob_v1_model.pb.swift +++ b/Sources/Flipcash2ClientProtocol/blob_v1_model.pb.swift @@ -726,12 +726,24 @@ public struct Flipcash_Blob_V1_AccessContext: Sendable { /// /// A caller never needs this for its OWN profile picture, since it owns /// those blobs. - public var profile: Flipcash_Common_V1_UserId { + public var userProfile: Flipcash_Common_V1_UserId { get { - if case .profile(let v)? = scope {return v} + if case .userProfile(let v)? = scope {return v} return Flipcash_Common_V1_UserId() } - set {scope = .profile(newValue)} + set {scope = .userProfile(newValue)} + } + + /// The caller is accessing these blobs from this chat's public profile. + /// Authorized iff the blob is a rendition of that chat's CURRENT profile + /// picture — a profile grants nothing else, and a superseded picture's + /// renditions stop resolving through it. + public var chatProfile: Flipcash_Common_V1_ChatId { + get { + if case .chatProfile(let v)? = scope {return v} + return Flipcash_Common_V1_ChatId() + } + set {scope = .chatProfile(newValue)} } public var unknownFields = SwiftProtobuf.UnknownStorage() @@ -747,7 +759,12 @@ public struct Flipcash_Blob_V1_AccessContext: Sendable { /// /// A caller never needs this for its OWN profile picture, since it owns /// those blobs. - case profile(Flipcash_Common_V1_UserId) + case userProfile(Flipcash_Common_V1_UserId) + /// The caller is accessing these blobs from this chat's public profile. + /// Authorized iff the blob is a rendition of that chat's CURRENT profile + /// picture — a profile grants nothing else, and a superseded picture's + /// renditions stop resolving through it. + case chatProfile(Flipcash_Common_V1_ChatId) } @@ -1388,7 +1405,7 @@ extension Flipcash_Blob_V1_RejectionMetadata: SwiftProtobuf.Message, SwiftProtob extension Flipcash_Blob_V1_AccessContext: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".AccessContext" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}chat\0\u{1}profile\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}chat\0\u{3}user_profile\0\u{3}chat_profile\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -1414,12 +1431,25 @@ extension Flipcash_Blob_V1_AccessContext: SwiftProtobuf.Message, SwiftProtobuf._ var hadOneofValue = false if let current = self.scope { hadOneofValue = true - if case .profile(let m) = current {v = m} + if case .userProfile(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.scope = .userProfile(v) + } + }() + case 3: try { + var v: Flipcash_Common_V1_ChatId? + var hadOneofValue = false + if let current = self.scope { + hadOneofValue = true + if case .chatProfile(let m) = current {v = m} } try decoder.decodeSingularMessageField(value: &v) if let v = v { if hadOneofValue {try decoder.handleConflictingOneOf()} - self.scope = .profile(v) + self.scope = .chatProfile(v) } }() default: break @@ -1437,10 +1467,14 @@ extension Flipcash_Blob_V1_AccessContext: SwiftProtobuf.Message, SwiftProtobuf._ guard case .chat(let v)? = self.scope else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 1) }() - case .profile?: try { - guard case .profile(let v)? = self.scope else { preconditionFailure() } + case .userProfile?: try { + guard case .userProfile(let v)? = self.scope else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 2) }() + case .chatProfile?: try { + guard case .chatProfile(let v)? = self.scope else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) diff --git a/Sources/Flipcash2ClientProtocol/chat_v1_model.pb.swift b/Sources/Flipcash2ClientProtocol/chat_v1_model.pb.swift index 3edc524..ddad3bf 100644 --- a/Sources/Flipcash2ClientProtocol/chat_v1_model.pb.swift +++ b/Sources/Flipcash2ClientProtocol/chat_v1_model.pb.swift @@ -84,7 +84,8 @@ public struct Flipcash_Chat_V1_Metadata: @unchecked Sendable { /// Members of this chat /// - /// For large group chats, this is a subset of all members. + /// For large group chats, this is a subset of all members. Use + /// RosterSummary.member_count to infer if there are more members. public var members: [Flipcash_Chat_V1_Member] { get {return _storage._members} set {_uniqueStorage()._members = newValue} @@ -138,6 +139,37 @@ public struct Flipcash_Chat_V1_Metadata: @unchecked Sendable { set {_uniqueStorage()._title = newValue} } + /// Picture for this chat. Only supported for group chats + public var picture: Flipcash_Blob_V1_Media { + get {return _storage._picture ?? Flipcash_Blob_V1_Media()} + set {_uniqueStorage()._picture = newValue} + } + /// Returns true if `picture` has been explicitly set. + public var hasPicture: Bool {return _storage._picture != nil} + /// Clears the value of `picture`. Subsequent reads from it will return its default value. + public mutating func clearPicture() {_uniqueStorage()._picture = nil} + + /// Chat roster summary + public var rosterSummary: Flipcash_Chat_V1_RosterSummary { + get {return _storage._rosterSummary ?? Flipcash_Chat_V1_RosterSummary()} + set {_uniqueStorage()._rosterSummary = newValue} + } + /// Returns true if `rosterSummary` has been explicitly set. + public var hasRosterSummary: Bool {return _storage._rosterSummary != nil} + /// Clears the value of `rosterSummary`. Subsequent reads from it will return its default value. + public mutating func clearRosterSummary() {_uniqueStorage()._rosterSummary = nil} + + /// Rules governing participation in this chat. Only supported for group + /// chats. If not set, the chat has no participation requirements. + public var rules: Flipcash_Chat_V1_Rules { + get {return _storage._rules ?? Flipcash_Chat_V1_Rules()} + set {_uniqueStorage()._rules = newValue} + } + /// Returns true if `rules` has been explicitly set. + public var hasRules: Bool {return _storage._rules != nil} + /// Clears the value of `rules`. Subsequent reads from it will return its default value. + public mutating func clearRules() {_uniqueStorage()._rules = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -145,6 +177,145 @@ public struct Flipcash_Chat_V1_Metadata: @unchecked Sendable { fileprivate var _storage = _StorageClass.defaultInstance } +/// Rules define the requirements a user must satisfy to participate in a chat. +/// +/// Rules are split into two classes, each of which is independently optional: +/// - ListenerRules gate reading and joining the chat +/// - SpeakerRules gate sending messages in the chat +/// +/// All rules within a class must be satisfied. Speaker rules are applied in +/// addition to listener rules: a user must be able to listen before they can +/// speak. +public struct Flipcash_Chat_V1_Rules: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Rules to read and join the chat. If empty, anyone can read and join. + public var listener: [Flipcash_Chat_V1_ListenerRules] = [] + + /// Rules to send messages in the chat. If empty, any member can send + /// messages. + public var speaker: [Flipcash_Chat_V1_SpeakerRules] = [] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +/// ListenerRules are the rules a user must satisfy to read and join a chat +public struct Flipcash_Chat_V1_ListenerRules: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var kind: Flipcash_Chat_V1_ListenerRules.OneOf_Kind? = nil + + public var minimumBalance: Flipcash_Chat_V1_MinimumBalanceRequirement { + get { + if case .minimumBalance(let v)? = kind {return v} + return Flipcash_Chat_V1_MinimumBalanceRequirement() + } + set {kind = .minimumBalance(newValue)} + } + + public var staff: Flipcash_Chat_V1_StaffRequirement { + get { + if case .staff(let v)? = kind {return v} + return Flipcash_Chat_V1_StaffRequirement() + } + set {kind = .staff(newValue)} + } + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public enum OneOf_Kind: Equatable, Sendable { + case minimumBalance(Flipcash_Chat_V1_MinimumBalanceRequirement) + case staff(Flipcash_Chat_V1_StaffRequirement) + + } + + public init() {} +} + +/// SpeakerRules are the rules a user must satisfy to send messages in a chat +public struct Flipcash_Chat_V1_SpeakerRules: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var kind: Flipcash_Chat_V1_SpeakerRules.OneOf_Kind? = nil + + public var minimumBalance: Flipcash_Chat_V1_MinimumBalanceRequirement { + get { + if case .minimumBalance(let v)? = kind {return v} + return Flipcash_Chat_V1_MinimumBalanceRequirement() + } + set {kind = .minimumBalance(newValue)} + } + + public var staff: Flipcash_Chat_V1_StaffRequirement { + get { + if case .staff(let v)? = kind {return v} + return Flipcash_Chat_V1_StaffRequirement() + } + set {kind = .staff(newValue)} + } + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public enum OneOf_Kind: Equatable, Sendable { + case minimumBalance(Flipcash_Chat_V1_MinimumBalanceRequirement) + case staff(Flipcash_Chat_V1_StaffRequirement) + + } + + public init() {} +} + +/// StaffRequirement requires the user to be a Flipcash staff member, as +/// indicated by UserFlags.is_staff. +public struct Flipcash_Chat_V1_StaffRequirement: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +/// MinimumBalanceRequirement requires a user to hold a minimum balance, +/// denominated in fiat, in an acceptable mint. +public struct Flipcash_Chat_V1_MinimumBalanceRequirement: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// The minimum balance, denominated in fiat + public var amount: Flipcash_Common_V1_FiatPaymentAmount { + get {return _amount ?? Flipcash_Common_V1_FiatPaymentAmount()} + set {_amount = newValue} + } + /// Returns true if `amount` has been explicitly set. + public var hasAmount: Bool {return self._amount != nil} + /// Clears the value of `amount`. Subsequent reads from it will return its default value. + public mutating func clearAmount() {self._amount = nil} + + /// The mints the balance may be held in. If empty, the requirement applies + /// to all mints. Otherwise, it applies only to the one listed mint. + /// + /// Currently limited to at most one mint. This is a repeated field so that + /// multiple mints can be specified in the future. + public var mints: [Flipcash_Common_V1_PublicKey] = [] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _amount: Flipcash_Common_V1_FiatPaymentAmount? = nil +} + public struct Flipcash_Chat_V1_Member: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -184,6 +355,40 @@ public struct Flipcash_Chat_V1_Member: Sendable { fileprivate var _userProfile: Flipcash_Profile_V1_UserProfile? = nil } +/// RosterSummary describes a chat's roster — its member list — without +/// containing it: what a client needs in order to know whether its copy of +/// that list is stale, without holding the list. +/// +/// It says nothing about member profiles. Profiles are hydrated afresh onto +/// every response that carries a member, and a profile change never moves +/// this summary. +public struct Flipcash_Chat_V1_RosterSummary: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Number of currently joined members. For a large group chat, + /// Metadata.members is a subset of the roster; this is its true size. + public var memberCount: UInt64 = 0 + + /// Version of the roster: advanced by exactly one on every change to the + /// membership records — a member joining, a member leaving, and in future + /// any change to what the chat records about a member (e.g. a role) — and + /// never by an idempotent no-op or a profile change. + /// + /// Opaque to clients. Compare it against the last value seen: a different + /// value means the cached member list may be stale and should be refetched. + /// On a stream, apply a greater value and drop the rest, so delivery order + /// does not matter. It is compared the same way as ReactionSummary.version + /// and, like it, is NOT the chat event sequence: there is no delta to fetch + /// against it, only a refetch of the members. + public var version: UInt64 = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + public struct Flipcash_Chat_V1_MetadataUpdate: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -272,7 +477,7 @@ extension Flipcash_Chat_V1_ChatType: SwiftProtobuf._ProtoNameProviding { extension Flipcash_Chat_V1_Metadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Metadata" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}chat_id\0\u{1}type\0\u{1}members\0\u{3}last_message\0\u{3}last_activity\0\u{3}latest_event_sequence\0\u{3}is_hidden\0\u{1}title\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}chat_id\0\u{1}type\0\u{1}members\0\u{3}last_message\0\u{3}last_activity\0\u{3}latest_event_sequence\0\u{3}is_hidden\0\u{1}title\0\u{1}picture\0\u{3}roster_summary\0\u{1}rules\0") fileprivate class _StorageClass { var _chatID: Flipcash_Common_V1_ChatId? = nil @@ -283,6 +488,9 @@ extension Flipcash_Chat_V1_Metadata: SwiftProtobuf.Message, SwiftProtobuf._Messa var _latestEventSequence: UInt64 = 0 var _isHidden: Bool = false var _title: String = String() + var _picture: Flipcash_Blob_V1_Media? = nil + var _rosterSummary: Flipcash_Chat_V1_RosterSummary? = nil + var _rules: Flipcash_Chat_V1_Rules? = nil // This property is used as the initial default value for new instances of the type. // The type itself is protecting the reference to its storage via CoW semantics. @@ -301,6 +509,9 @@ extension Flipcash_Chat_V1_Metadata: SwiftProtobuf.Message, SwiftProtobuf._Messa _latestEventSequence = source._latestEventSequence _isHidden = source._isHidden _title = source._title + _picture = source._picture + _rosterSummary = source._rosterSummary + _rules = source._rules } } @@ -327,6 +538,9 @@ extension Flipcash_Chat_V1_Metadata: SwiftProtobuf.Message, SwiftProtobuf._Messa case 6: try { try decoder.decodeSingularUInt64Field(value: &_storage._latestEventSequence) }() case 7: try { try decoder.decodeSingularBoolField(value: &_storage._isHidden) }() case 8: try { try decoder.decodeSingularStringField(value: &_storage._title) }() + case 9: try { try decoder.decodeSingularMessageField(value: &_storage._picture) }() + case 10: try { try decoder.decodeSingularMessageField(value: &_storage._rosterSummary) }() + case 11: try { try decoder.decodeSingularMessageField(value: &_storage._rules) }() default: break } } @@ -363,6 +577,15 @@ extension Flipcash_Chat_V1_Metadata: SwiftProtobuf.Message, SwiftProtobuf._Messa if !_storage._title.isEmpty { try visitor.visitSingularStringField(value: _storage._title, fieldNumber: 8) } + try { if let v = _storage._picture { + try visitor.visitSingularMessageField(value: v, fieldNumber: 9) + } }() + try { if let v = _storage._rosterSummary { + try visitor.visitSingularMessageField(value: v, fieldNumber: 10) + } }() + try { if let v = _storage._rules { + try visitor.visitSingularMessageField(value: v, fieldNumber: 11) + } }() } try unknownFields.traverse(visitor: &visitor) } @@ -380,6 +603,9 @@ extension Flipcash_Chat_V1_Metadata: SwiftProtobuf.Message, SwiftProtobuf._Messa if _storage._latestEventSequence != rhs_storage._latestEventSequence {return false} if _storage._isHidden != rhs_storage._isHidden {return false} if _storage._title != rhs_storage._title {return false} + if _storage._picture != rhs_storage._picture {return false} + if _storage._rosterSummary != rhs_storage._rosterSummary {return false} + if _storage._rules != rhs_storage._rules {return false} return true } if !storagesAreEqual {return false} @@ -389,6 +615,233 @@ extension Flipcash_Chat_V1_Metadata: SwiftProtobuf.Message, SwiftProtobuf._Messa } } +extension Flipcash_Chat_V1_Rules: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".Rules" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}listener\0\u{1}speaker\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedMessageField(value: &self.listener) }() + case 2: try { try decoder.decodeRepeatedMessageField(value: &self.speaker) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.listener.isEmpty { + try visitor.visitRepeatedMessageField(value: self.listener, fieldNumber: 1) + } + if !self.speaker.isEmpty { + try visitor.visitRepeatedMessageField(value: self.speaker, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Flipcash_Chat_V1_Rules, rhs: Flipcash_Chat_V1_Rules) -> Bool { + if lhs.listener != rhs.listener {return false} + if lhs.speaker != rhs.speaker {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Flipcash_Chat_V1_ListenerRules: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ListenerRules" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}minimum_balance\0\u{1}staff\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { + var v: Flipcash_Chat_V1_MinimumBalanceRequirement? + var hadOneofValue = false + if let current = self.kind { + hadOneofValue = true + if case .minimumBalance(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.kind = .minimumBalance(v) + } + }() + case 2: try { + var v: Flipcash_Chat_V1_StaffRequirement? + var hadOneofValue = false + if let current = self.kind { + hadOneofValue = true + if case .staff(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.kind = .staff(v) + } + }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + switch self.kind { + case .minimumBalance?: try { + guard case .minimumBalance(let v)? = self.kind else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + }() + case .staff?: try { + guard case .staff(let v)? = self.kind else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + }() + case nil: break + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Flipcash_Chat_V1_ListenerRules, rhs: Flipcash_Chat_V1_ListenerRules) -> Bool { + if lhs.kind != rhs.kind {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Flipcash_Chat_V1_SpeakerRules: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".SpeakerRules" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}minimum_balance\0\u{1}staff\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { + var v: Flipcash_Chat_V1_MinimumBalanceRequirement? + var hadOneofValue = false + if let current = self.kind { + hadOneofValue = true + if case .minimumBalance(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.kind = .minimumBalance(v) + } + }() + case 2: try { + var v: Flipcash_Chat_V1_StaffRequirement? + var hadOneofValue = false + if let current = self.kind { + hadOneofValue = true + if case .staff(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.kind = .staff(v) + } + }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + switch self.kind { + case .minimumBalance?: try { + guard case .minimumBalance(let v)? = self.kind else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + }() + case .staff?: try { + guard case .staff(let v)? = self.kind else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + }() + case nil: break + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Flipcash_Chat_V1_SpeakerRules, rhs: Flipcash_Chat_V1_SpeakerRules) -> Bool { + if lhs.kind != rhs.kind {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Flipcash_Chat_V1_StaffRequirement: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".StaffRequirement" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Flipcash_Chat_V1_StaffRequirement, rhs: Flipcash_Chat_V1_StaffRequirement) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Flipcash_Chat_V1_MinimumBalanceRequirement: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".MinimumBalanceRequirement" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}amount\0\u{1}mints\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._amount) }() + case 2: try { try decoder.decodeRepeatedMessageField(value: &self.mints) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._amount { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + if !self.mints.isEmpty { + try visitor.visitRepeatedMessageField(value: self.mints, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Flipcash_Chat_V1_MinimumBalanceRequirement, rhs: Flipcash_Chat_V1_MinimumBalanceRequirement) -> Bool { + if lhs._amount != rhs._amount {return false} + if lhs.mints != rhs.mints {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension Flipcash_Chat_V1_Member: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Member" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}user_id\0\u{3}user_profile\0\u{1}pointers\0") @@ -433,6 +886,41 @@ extension Flipcash_Chat_V1_Member: SwiftProtobuf.Message, SwiftProtobuf._Message } } +extension Flipcash_Chat_V1_RosterSummary: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".RosterSummary" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}member_count\0\u{1}version\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt64Field(value: &self.memberCount) }() + case 2: try { try decoder.decodeSingularUInt64Field(value: &self.version) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.memberCount != 0 { + try visitor.visitSingularUInt64Field(value: self.memberCount, fieldNumber: 1) + } + if self.version != 0 { + try visitor.visitSingularUInt64Field(value: self.version, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Flipcash_Chat_V1_RosterSummary, rhs: Flipcash_Chat_V1_RosterSummary) -> Bool { + if lhs.memberCount != rhs.memberCount {return false} + if lhs.version != rhs.version {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension Flipcash_Chat_V1_MetadataUpdate: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".MetadataUpdate" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}full_refresh\0\u{3}last_activity_changed\0") diff --git a/Sources/Flipcash2ClientProtocol/messaging_v1_model.pb.swift b/Sources/Flipcash2ClientProtocol/messaging_v1_model.pb.swift index 346a3df..6836e82 100644 --- a/Sources/Flipcash2ClientProtocol/messaging_v1_model.pb.swift +++ b/Sources/Flipcash2ClientProtocol/messaging_v1_model.pb.swift @@ -548,14 +548,18 @@ public struct Flipcash_Messaging_V1_EmojiReaction: Sendable { /// reactor list is fetched on demand via GetReactors. public var sampleReactors: [Flipcash_Messaging_V1_Reactor] = [] - /// Monotonic version of this emoji's aggregate on the message, assigned by - /// the server and advanced on every change to it. Ordering only: clients - /// apply reaction updates last-writer-wins by this value per (message, emoji) - /// — and per actor for reacted_by_self — and treat a loaded summary as stale - /// when a higher sequence arrives. It is NOT the chat event sequence - /// (reactions never advance that), and it is NOT gapless: it carries no - /// gap-detection meaning. - public var sequence: UInt64 = 0 + /// Version of this emoji's aggregate on the message: assigned by the server + /// and advanced by one on every change to it — a reactor added or removed. + /// + /// Opaque to clients, and for ordering only. Apply reaction updates + /// last-writer-wins by this value per (message, emoji) — and per actor for + /// reacted_by_self — and treat a loaded summary as stale when a higher + /// version arrives. It is compared the same way as chat.v1.RosterSummary. + /// version. It is NOT the chat event sequence (reactions never advance + /// that), and it is NOT gapless: a skipped value means nothing, and there is + /// no delta to fetch against it — a missed update is reconciled by + /// refreshing the summary on view. + public var version: UInt64 = 0 public var unknownFields = SwiftProtobuf.UnknownStorage() @@ -612,11 +616,11 @@ public struct Flipcash_Messaging_V1_ReactionUpdate: Sendable { /// remain and the client should drop the entry from the summary. public var count: UInt64 = 0 - /// The emoji aggregate's new version after this change. Clients apply - /// last-writer-wins by this value: ignore the count if sequence <= the - /// count watermark held, and ignore the actor's reacted_by_self toggle if - /// sequence <= the per-actor watermark held. Matches EmojiReaction.sequence. - public var sequence: UInt64 = 0 + /// The emoji aggregate's version after this change. Clients apply + /// last-writer-wins by this value: ignore the count if version <= the count + /// watermark held, and ignore the actor's reacted_by_self toggle if + /// version <= the per-actor watermark held. Matches EmojiReaction.version. + public var version: UInt64 = 0 /// When the actor reacted. On ADDED, clients record this as the actor's /// Reactor.reacted_ts (e.g. when slotting them into sample_reactors); ignored @@ -1630,7 +1634,7 @@ extension Flipcash_Messaging_V1_ReactionSummary: SwiftProtobuf.Message, SwiftPro extension Flipcash_Messaging_V1_EmojiReaction: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".EmojiReaction" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}emoji\0\u{1}count\0\u{3}reacted_by_self\0\u{3}sample_reactors\0\u{1}sequence\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}emoji\0\u{1}count\0\u{3}reacted_by_self\0\u{3}sample_reactors\0\u{1}version\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -1642,7 +1646,7 @@ extension Flipcash_Messaging_V1_EmojiReaction: SwiftProtobuf.Message, SwiftProto case 2: try { try decoder.decodeSingularUInt64Field(value: &self.count) }() case 3: try { try decoder.decodeSingularBoolField(value: &self.reactedBySelf) }() case 4: try { try decoder.decodeRepeatedMessageField(value: &self.sampleReactors) }() - case 5: try { try decoder.decodeSingularUInt64Field(value: &self.sequence) }() + case 5: try { try decoder.decodeSingularUInt64Field(value: &self.version) }() default: break } } @@ -1665,8 +1669,8 @@ extension Flipcash_Messaging_V1_EmojiReaction: SwiftProtobuf.Message, SwiftProto if !self.sampleReactors.isEmpty { try visitor.visitRepeatedMessageField(value: self.sampleReactors, fieldNumber: 4) } - if self.sequence != 0 { - try visitor.visitSingularUInt64Field(value: self.sequence, fieldNumber: 5) + if self.version != 0 { + try visitor.visitSingularUInt64Field(value: self.version, fieldNumber: 5) } try unknownFields.traverse(visitor: &visitor) } @@ -1676,7 +1680,7 @@ extension Flipcash_Messaging_V1_EmojiReaction: SwiftProtobuf.Message, SwiftProto if lhs.count != rhs.count {return false} if lhs.reactedBySelf != rhs.reactedBySelf {return false} if lhs.sampleReactors != rhs.sampleReactors {return false} - if lhs.sequence != rhs.sequence {return false} + if lhs.version != rhs.version {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -1684,7 +1688,7 @@ extension Flipcash_Messaging_V1_EmojiReaction: SwiftProtobuf.Message, SwiftProto extension Flipcash_Messaging_V1_ReactionUpdate: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ReactionUpdate" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}message_id\0\u{1}emoji\0\u{1}actor\0\u{1}action\0\u{1}count\0\u{1}sequence\0\u{3}reacted_ts\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}message_id\0\u{1}emoji\0\u{1}actor\0\u{1}action\0\u{1}count\0\u{1}version\0\u{3}reacted_ts\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -1697,7 +1701,7 @@ extension Flipcash_Messaging_V1_ReactionUpdate: SwiftProtobuf.Message, SwiftProt case 3: try { try decoder.decodeSingularMessageField(value: &self._actor) }() case 4: try { try decoder.decodeSingularEnumField(value: &self.action) }() case 5: try { try decoder.decodeSingularUInt64Field(value: &self.count) }() - case 6: try { try decoder.decodeSingularUInt64Field(value: &self.sequence) }() + case 6: try { try decoder.decodeSingularUInt64Field(value: &self.version) }() case 7: try { try decoder.decodeSingularMessageField(value: &self._reactedTs) }() default: break } @@ -1724,8 +1728,8 @@ extension Flipcash_Messaging_V1_ReactionUpdate: SwiftProtobuf.Message, SwiftProt if self.count != 0 { try visitor.visitSingularUInt64Field(value: self.count, fieldNumber: 5) } - if self.sequence != 0 { - try visitor.visitSingularUInt64Field(value: self.sequence, fieldNumber: 6) + if self.version != 0 { + try visitor.visitSingularUInt64Field(value: self.version, fieldNumber: 6) } try { if let v = self._reactedTs { try visitor.visitSingularMessageField(value: v, fieldNumber: 7) @@ -1739,7 +1743,7 @@ extension Flipcash_Messaging_V1_ReactionUpdate: SwiftProtobuf.Message, SwiftProt if lhs._actor != rhs._actor {return false} if lhs.action != rhs.action {return false} if lhs.count != rhs.count {return false} - if lhs.sequence != rhs.sequence {return false} + if lhs.version != rhs.version {return false} if lhs._reactedTs != rhs._reactedTs {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true diff --git a/flipcash2.lock b/flipcash2.lock index c6c055a..f34d0e7 100644 --- a/flipcash2.lock +++ b/flipcash2.lock @@ -1,4 +1,4 @@ # Pinned upstream contract. Regenerate with scripts/sync-protos.sh . upstream: code-payments/flipcash2-protobuf-api -commit: 797052dd1070662f97407427665fd48967abfac6 -subject: Add chat message to push payload ChatMetadata (#92) +commit: 35f9981400947921bbe2872be63b0bb77a569e34 +subject: Update chat rules to be a repeated with and introduce staff requirements (#98) diff --git a/proto/blob/v1/model.proto b/proto/blob/v1/model.proto index af8c41c..ff57f9a 100644 --- a/proto/blob/v1/model.proto +++ b/proto/blob/v1/model.proto @@ -321,6 +321,12 @@ message AccessContext { // // A caller never needs this for its OWN profile picture, since it owns // those blobs. - common.v1.UserId profile = 2 [(validate.rules).message.required = true]; + common.v1.UserId user_profile = 2 [(validate.rules).message.required = true]; + + // The caller is accessing these blobs from this chat's public profile. + // Authorized iff the blob is a rendition of that chat's CURRENT profile + // picture — a profile grants nothing else, and a superseded picture's + // renditions stop resolving through it. + common.v1.ChatId chat_profile = 3 [(validate.rules).message.required = true]; } } diff --git a/proto/chat/v1/model.proto b/proto/chat/v1/model.proto index d654e68..6eac1bc 100644 --- a/proto/chat/v1/model.proto +++ b/proto/chat/v1/model.proto @@ -6,6 +6,7 @@ option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/g option java_package = "com.codeinc.flipcash.gen.chat.v1"; option objc_class_prefix = "FPBChatV1"; +import "blob/v1/model.proto"; import "common/v1/common.proto"; import "profile/v1/model.proto"; import "messaging/v1/model.proto"; @@ -29,7 +30,8 @@ message Metadata { // Members of this chat // - // For large group chats, this is a subset of all members. + // For large group chats, this is a subset of all members. Use + // RosterSummary.member_count to infer if there are more members. repeated Member members = 3; // The last message in this chat @@ -59,6 +61,82 @@ message Metadata { min_len: 0 max_len: 64 }]; + + // Picture for this chat. Only supported for group chats + blob.v1.Media picture = 9; + + // Chat roster summary + RosterSummary roster_summary = 10 [(validate.rules).message.required = true]; + + // Rules governing participation in this chat. Only supported for group + // chats. If not set, the chat has no participation requirements. + Rules rules = 11; +} + +// Rules define the requirements a user must satisfy to participate in a chat. +// +// Rules are split into two classes, each of which is independently optional: +// - ListenerRules gate reading and joining the chat +// - SpeakerRules gate sending messages in the chat +// +// All rules within a class must be satisfied. Speaker rules are applied in +// addition to listener rules: a user must be able to listen before they can +// speak. +message Rules { + // Rules to read and join the chat. If empty, anyone can read and join. + repeated ListenerRules listener = 1 [(validate.rules).repeated = { + min_items: 0 + max_items: 32 + }]; + + // Rules to send messages in the chat. If empty, any member can send + // messages. + repeated SpeakerRules speaker = 2 [(validate.rules).repeated = { + min_items: 0 + max_items: 32 + }]; +} + +// ListenerRules are the rules a user must satisfy to read and join a chat +message ListenerRules { + oneof kind { + option (validate.required) = true; + + MinimumBalanceRequirement minimum_balance = 1; + StaffRequirement staff = 2; + } +} + +// SpeakerRules are the rules a user must satisfy to send messages in a chat +message SpeakerRules { + oneof kind { + option (validate.required) = true; + + MinimumBalanceRequirement minimum_balance = 1; + StaffRequirement staff = 2; + } +} + +// StaffRequirement requires the user to be a Flipcash staff member, as +// indicated by UserFlags.is_staff. +message StaffRequirement { +} + +// MinimumBalanceRequirement requires a user to hold a minimum balance, +// denominated in fiat, in an acceptable mint. +message MinimumBalanceRequirement { + // The minimum balance, denominated in fiat + common.v1.FiatPaymentAmount amount = 1 [(validate.rules).message.required = true]; + + // The mints the balance may be held in. If empty, the requirement applies + // to all mints. Otherwise, it applies only to the one listed mint. + // + // Currently limited to at most one mint. This is a repeated field so that + // multiple mints can be specified in the future. + repeated common.v1.PublicKey mints = 2 [(validate.rules).repeated = { + min_items: 0 + max_items: 1 + }]; } message Member { @@ -78,6 +156,32 @@ message Member { }]; } +// RosterSummary describes a chat's roster — its member list — without +// containing it: what a client needs in order to know whether its copy of +// that list is stale, without holding the list. +// +// It says nothing about member profiles. Profiles are hydrated afresh onto +// every response that carries a member, and a profile change never moves +// this summary. +message RosterSummary { + // Number of currently joined members. For a large group chat, + // Metadata.members is a subset of the roster; this is its true size. + uint64 member_count = 1; + + // Version of the roster: advanced by exactly one on every change to the + // membership records — a member joining, a member leaving, and in future + // any change to what the chat records about a member (e.g. a role) — and + // never by an idempotent no-op or a profile change. + // + // Opaque to clients. Compare it against the last value seen: a different + // value means the cached member list may be stale and should be refetched. + // On a stream, apply a greater value and drop the rest, so delivery order + // does not matter. It is compared the same way as ReactionSummary.version + // and, like it, is NOT the chat event sequence: there is no delta to fetch + // against it, only a refetch of the members. + uint64 version = 2; +} + message MetadataUpdate { oneof kind { option (validate.required) = true; diff --git a/proto/messaging/v1/model.proto b/proto/messaging/v1/model.proto index ea30fa8..ddaada1 100644 --- a/proto/messaging/v1/model.proto +++ b/proto/messaging/v1/model.proto @@ -254,14 +254,18 @@ message EmojiReaction { max_items: 8 // Sample only; not the full list }]; - // Monotonic version of this emoji's aggregate on the message, assigned by - // the server and advanced on every change to it. Ordering only: clients - // apply reaction updates last-writer-wins by this value per (message, emoji) - // — and per actor for reacted_by_self — and treat a loaded summary as stale - // when a higher sequence arrives. It is NOT the chat event sequence - // (reactions never advance that), and it is NOT gapless: it carries no - // gap-detection meaning. - uint64 sequence = 5 [(validate.rules).uint64.gte = 1]; + // Version of this emoji's aggregate on the message: assigned by the server + // and advanced by one on every change to it — a reactor added or removed. + // + // Opaque to clients, and for ordering only. Apply reaction updates + // last-writer-wins by this value per (message, emoji) — and per actor for + // reacted_by_self — and treat a loaded summary as stale when a higher + // version arrives. It is compared the same way as chat.v1.RosterSummary. + // version. It is NOT the chat event sequence (reactions never advance + // that), and it is NOT gapless: a skipped value means nothing, and there is + // no delta to fetch against it — a missed update is reconciled by + // refreshing the summary on view. + uint64 version = 5 [(validate.rules).uint64.gte = 1]; } // ReactionUpdate is a best-effort, real-time reaction change for a single @@ -294,11 +298,11 @@ message ReactionUpdate { // remain and the client should drop the entry from the summary. uint64 count = 5; - // The emoji aggregate's new version after this change. Clients apply - // last-writer-wins by this value: ignore the count if sequence <= the - // count watermark held, and ignore the actor's reacted_by_self toggle if - // sequence <= the per-actor watermark held. Matches EmojiReaction.sequence. - uint64 sequence = 6 [(validate.rules).uint64.gte = 1]; + // The emoji aggregate's version after this change. Clients apply + // last-writer-wins by this value: ignore the count if version <= the count + // watermark held, and ignore the actor's reacted_by_self toggle if + // version <= the per-actor watermark held. Matches EmojiReaction.version. + uint64 version = 6 [(validate.rules).uint64.gte = 1]; // When the actor reacted. On ADDED, clients record this as the actor's // Reactor.reacted_ts (e.g. when slotting them into sample_reactors); ignored