From 21a404a7ebcbcb5c4e6dfa956bc9e7b68745eb0e Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 12 Feb 2026 15:53:48 +0000 Subject: [PATCH 1/6] Add userClaim field to all chat event types Implements the userClaim feature from the chat SDK specification (CHA-M2h, CHA-ER2a, CHA-PR6g, CHA-T13a1, CHA-MR7d). This adds an optional server-provided userClaim string (extracted from JWT extras) to Message, RoomReaction, PresenceMember, TypingSetEvent.Change, and MessageReactionRawEvent.Reaction. For typing indicators, the userClaim is cached per-client and persisted across heartbeat events. The timer expiry handler signature was updated to pass the cached userClaim as a parameter, since state is cleared before the handler runs. Includes 26 new unit tests and integration test assertions verifying userClaim is nil under standard API key auth. [CHA-1243] Co-Authored-By: Claude Opus 4.6 --- .../AblyChat/DefaultMessageReactions.swift | 8 + Sources/AblyChat/DefaultMessages.swift | 2 + Sources/AblyChat/DefaultPresence.swift | 2 + Sources/AblyChat/DefaultRoomReactions.swift | 3 + Sources/AblyChat/DefaultTyping.swift | 26 +- Sources/AblyChat/JSONValue.swift | 5 + Sources/AblyChat/Message.swift | 13 +- Sources/AblyChat/MessageReaction.swift | 12 +- Sources/AblyChat/Presence.swift | 12 +- Sources/AblyChat/RoomReaction.swift | 12 +- Sources/AblyChat/Typing.swift | 15 +- Sources/AblyChat/TypingTimerManager.swift | 44 +- Tests/AblyChatTests/IntegrationTests.swift | 15 + .../TypingTimerManagerTests.swift | 8 +- Tests/AblyChatTests/UserClaimTests.swift | 523 ++++++++++++++++++ docs/plans/CHA-1243-user-claims.md | 220 ++++++++ 16 files changed, 896 insertions(+), 24 deletions(-) create mode 100644 Tests/AblyChatTests/UserClaimTests.swift create mode 100644 docs/plans/CHA-1243-user-claims.md diff --git a/Sources/AblyChat/DefaultMessageReactions.swift b/Sources/AblyChat/DefaultMessageReactions.swift index 7d914f07..c3260d22 100644 --- a/Sources/AblyChat/DefaultMessageReactions.swift +++ b/Sources/AblyChat/DefaultMessageReactions.swift @@ -117,6 +117,13 @@ internal final class DefaultMessageReactions: } let headers = (try? extras.optionalObjectValueForKey("headers"))?.compactMapValues { try? HeadersValue(jsonValue: $0) } ?? [:] // CHA-M4k2 + let userClaim = extras.userClaim // CHA-M2h let version = message.version ?? .init() let timestamp = message.timestamp ?? Date(timeIntervalSince1970: 0) // CHA-M4k5 let serial = message.serial ?? "" // CHA-M4k1 @@ -82,6 +83,7 @@ internal final class DefaultMessages: timestamp: timestamp, // TODO: Not sure of correct behaviour here, see https://github.com/ably/ably-chat-swift/issues/391 reactions: .empty, + userClaim: userClaim, // CHA-M2h ) let event = ChatMessageEvent(message: message) diff --git a/Sources/AblyChat/DefaultPresence.swift b/Sources/AblyChat/DefaultPresence.swift index bb94ba55..223219a4 100644 --- a/Sources/AblyChat/DefaultPresence.swift +++ b/Sources/AblyChat/DefaultPresence.swift @@ -207,6 +207,7 @@ internal final class DefaultPresence: Presence { data: member.data, extras: member.extras, updatedAt: member.timestamp ?? Date(timeIntervalSince1970: 0), // CHA-M4k5 + userClaim: member.extras?.userClaim, // CHA-PR6g ) logger.log(message: "Returning presence member: \(presenceMember)", level: .debug) @@ -222,6 +223,7 @@ internal final class DefaultPresence: Presence { data: message.data, extras: message.extras, updatedAt: message.timestamp ?? Date(timeIntervalSince1970: 0), // CHA-M4k5 + userClaim: message.extras?.userClaim, // CHA-PR6g ) let presenceEvent = PresenceEvent( diff --git a/Sources/AblyChat/DefaultRoomReactions.swift b/Sources/AblyChat/DefaultRoomReactions.swift index 5d207d15..a76a8253 100644 --- a/Sources/AblyChat/DefaultRoomReactions.swift +++ b/Sources/AblyChat/DefaultRoomReactions.swift @@ -55,6 +55,8 @@ internal final class DefaultRoomReactions: RoomReactions { let messageClientID = message.clientId ?? "" // CHA-ER4e3 + let userClaim = extras.userClaim // CHA-ER2a + let reaction = RoomReaction( name: dto?.name ?? "", // CHA-ER4e1 metadata: dto?.metadata ?? [:], @@ -62,6 +64,7 @@ internal final class DefaultRoomReactions: RoomReactions { createdAt: message.timestamp ?? Date(), // CHA-ER4e4 clientID: messageClientID, isSelf: messageClientID == realtime.clientId, + userClaim: userClaim, // CHA-ER2a ) let event = RoomReactionEvent(type: .reaction, reaction: reaction) diff --git a/Sources/AblyChat/DefaultTyping.swift b/Sources/AblyChat/DefaultTyping.swift index b6e4b133..dd9f1d94 100644 --- a/Sources/AblyChat/DefaultTyping.swift +++ b/Sources/AblyChat/DefaultTyping.swift @@ -42,21 +42,31 @@ internal final class DefaultTyping: Typing { logger.log(message: "Received started typing message: \(message)", level: .debug) + // CHA-T13a1: Extract userClaim from message extras + let extras = if let ablyCocoaExtras = message.extras { + JSONValue.objectFromAblyCocoaExtras(ablyCocoaExtras) + } else { + [String: JSONValue]() + } + let userClaim = extras.userClaim + let isNewClient = !typingTimerManager.isCurrentlyTyping(clientID: messageClientID) // Start or reset typing timer for this clientID per CHA-T13b1 and CHA-T13b2. typingTimerManager.startTypingTimer( for: messageClientID, - ) { [weak self] in + userClaim: userClaim, // CHA-T13a1 + ) { [weak self] expiredUserClaim in guard let self else { return } // (CHA-T13b3) (2/2) If the (CHA-T13b1) timeout expires, the client shall remove the clientId from the typing set and emit a synthetic typing stop event for the given client. + // (CHA-T13a1) The expiredUserClaim is provided by the timer manager before it clears the state. callback( TypingSetEvent( type: .setChanged, currentlyTyping: typingTimerManager.currentlyTypingClientIDs(), - change: .init(clientID: messageClientID, type: .stopped), + change: .init(clientID: messageClientID, type: .stopped, userClaim: expiredUserClaim), ), ) } @@ -68,7 +78,7 @@ internal final class DefaultTyping: Typing { TypingSetEvent( type: .setChanged, currentlyTyping: typingTimerManager.currentlyTypingClientIDs(), - change: .init(clientID: messageClientID, type: .started), + change: .init(clientID: messageClientID, type: .started, userClaim: typingTimerManager.userClaimForClient(messageClientID)), ), ) } @@ -81,6 +91,14 @@ internal final class DefaultTyping: Typing { logger.log(message: "Received stopped typing message: \(message)", level: .debug) + // CHA-T13a1: Extract userClaim from message extras, fall back to cached value + let extras = if let ablyCocoaExtras = message.extras { + JSONValue.objectFromAblyCocoaExtras(ablyCocoaExtras) + } else { + [String: JSONValue]() + } + let userClaim = extras.userClaim ?? typingTimerManager.userClaimForClient(messageClientID) + // (CHA-T13b5) If the event represents that a client has stopped typing, but the clientId for that client is not present in the typing set, then the event is ignored. if typingTimerManager.isCurrentlyTyping(clientID: messageClientID) { // (CHA-T13b4) If the event represents a client that has stopped typing, then the chat client shall remove the clientId from the typing set and emit the updated set to any subscribers. It shall also cancel the (CHA-T13b1) timeout for the typing client. @@ -91,7 +109,7 @@ internal final class DefaultTyping: Typing { TypingSetEvent( type: .setChanged, currentlyTyping: typingTimerManager.currentlyTypingClientIDs(), - change: .init(clientID: messageClientID, type: .stopped), + change: .init(clientID: messageClientID, type: .stopped, userClaim: userClaim), ), ) } diff --git a/Sources/AblyChat/JSONValue.swift b/Sources/AblyChat/JSONValue.swift index ecace1e0..99a6c19e 100644 --- a/Sources/AblyChat/JSONValue.swift +++ b/Sources/AblyChat/JSONValue.swift @@ -234,4 +234,9 @@ internal extension JSONObject { var toARTJsonCompatible: any ARTJsonCompatible { toAblyCocoaDataDictionary as (any ARTJsonCompatible) } + + /// Extracts the `userClaim` string from an extras dictionary, if present and a string. + var userClaim: String? { + self["userClaim"]?.stringValue + } } diff --git a/Sources/AblyChat/Message.swift b/Sources/AblyChat/Message.swift index 3432c234..74c8bf22 100644 --- a/Sources/AblyChat/Message.swift +++ b/Sources/AblyChat/Message.swift @@ -84,10 +84,19 @@ public struct Message: Sendable, Equatable { */ public var reactions: MessageReactionSummary + /** + * The user claim attached to this message by the server. + * + * Set automatically by the Ably server when a JWT contains a matching + * `ably.room.` claim. This is a read-only, server-provided value. + */ + // @spec CHA-M2h + public var userClaim: String? + /// Memberwise initializer to create a `Message`. /// /// - Note: You should not need to use this initializer when using the Chat SDK. It is exposed only to allow users to create mock versions of the SDK's protocols. - public init(serial: String, action: ChatMessageAction, clientID: String, text: String, metadata: MessageMetadata, headers: MessageHeaders, version: MessageVersion, timestamp: Date, reactions: MessageReactionSummary) { + public init(serial: String, action: ChatMessageAction, clientID: String, text: String, metadata: MessageMetadata, headers: MessageHeaders, version: MessageVersion, timestamp: Date, reactions: MessageReactionSummary, userClaim: String? = nil) { self.serial = serial self.action = action self.clientID = clientID @@ -97,6 +106,7 @@ public struct Message: Sendable, Equatable { self.version = version self.timestamp = timestamp self.reactions = reactions + self.userClaim = userClaim } /** @@ -193,6 +203,7 @@ extension Message: JSONObjectDecodable { version: .init(jsonObject: jsonObject.objectValueForKey("version"), defaultTimestamp: timestamp), timestamp: timestamp, reactions: reactionSummary, + userClaim: jsonObject.optionalStringValueForKey("userClaim"), // CHA-M2h ) } } diff --git a/Sources/AblyChat/MessageReaction.swift b/Sources/AblyChat/MessageReaction.swift index 4923f527..e63b9871 100644 --- a/Sources/AblyChat/MessageReaction.swift +++ b/Sources/AblyChat/MessageReaction.swift @@ -280,15 +280,25 @@ public struct MessageReactionRawEvent: Sendable { */ public var clientID: String + /** + * The user claim attached to this reaction event by the server. + * + * Set automatically by the Ably server when a JWT contains a matching + * `ably.room.` claim. This is a read-only, server-provided value. + */ + // @spec CHA-MR7d + public var userClaim: String? + /// Memberwise initializer to create a `Reaction`. /// /// - Note: You should not need to use this initializer when using the Chat SDK. It is exposed only to allow users to create mock versions of the SDK's protocols. - public init(type: MessageReactionType, name: String, messageSerial: String, count: Int? = nil, clientID: String) { + public init(type: MessageReactionType, name: String, messageSerial: String, count: Int? = nil, clientID: String, userClaim: String? = nil) { self.type = type self.name = name self.messageSerial = messageSerial self.count = count self.clientID = clientID + self.userClaim = userClaim } } diff --git a/Sources/AblyChat/Presence.swift b/Sources/AblyChat/Presence.swift index cf8f6a75..19cbc9b0 100644 --- a/Sources/AblyChat/Presence.swift +++ b/Sources/AblyChat/Presence.swift @@ -152,12 +152,13 @@ public struct PresenceMember: Sendable { /// Memberwise initializer to create a `PresenceMember`. /// /// - Note: You should not need to use this initializer when using the Chat SDK. It is exposed only to allow users to create mock versions of the SDK's protocols. - public init(clientID: String, connectionID: String, data: PresenceData?, extras: [String: JSONValue]?, updatedAt: Date) { + public init(clientID: String, connectionID: String, data: PresenceData?, extras: [String: JSONValue]?, updatedAt: Date, userClaim: String? = nil) { self.clientID = clientID self.connectionID = connectionID self.data = data self.extras = extras self.updatedAt = updatedAt + self.userClaim = userClaim } /** @@ -180,6 +181,15 @@ public struct PresenceMember: Sendable { public var extras: [String: JSONValue]? // swiftlint:disable:next missing_docs public var updatedAt: Date + + /** + * The user claim attached to this presence event by the server. + * + * Set automatically by the Ably server when a JWT contains a matching + * `ably.room.` claim. This is a read-only, server-provided value. + */ + // @spec CHA-PR6g + public var userClaim: String? } /** diff --git a/Sources/AblyChat/RoomReaction.swift b/Sources/AblyChat/RoomReaction.swift index c8ccea5a..cb0efcd3 100644 --- a/Sources/AblyChat/RoomReaction.swift +++ b/Sources/AblyChat/RoomReaction.swift @@ -46,15 +46,25 @@ public struct RoomReaction: Sendable { */ public var isSelf: Bool + /** + * The user claim attached to this reaction by the server. + * + * Set automatically by the Ably server when a JWT contains a matching + * `ably.room.` claim. This is a read-only, server-provided value. + */ + // @spec CHA-ER2a + public var userClaim: String? + /// Memberwise initializer to create a `RoomReaction`. /// /// - Note: You should not need to use this initializer when using the Chat SDK. It is exposed only to allow users to create mock versions of the SDK's protocols. - public init(name: String, metadata: RoomReactionMetadata, headers: RoomReactionHeaders, createdAt: Date, clientID: String, isSelf: Bool) { + public init(name: String, metadata: RoomReactionMetadata, headers: RoomReactionHeaders, createdAt: Date, clientID: String, isSelf: Bool, userClaim: String? = nil) { self.name = name self.metadata = metadata self.headers = headers self.createdAt = createdAt self.clientID = clientID self.isSelf = isSelf + self.userClaim = userClaim } } diff --git a/Sources/AblyChat/Typing.swift b/Sources/AblyChat/Typing.swift index 44996cae..26c37d84 100644 --- a/Sources/AblyChat/Typing.swift +++ b/Sources/AblyChat/Typing.swift @@ -116,12 +116,25 @@ public struct TypingSetEvent: Sendable { // swiftlint:disable:next missing_docs public var type: TypingEventType + /** + * The user claim attached to this typing event by the server. + * + * Set automatically by the Ably server when a JWT contains a matching + * `ably.room.` claim. This is a read-only, server-provided value. + * + * The `userClaim` must persist across heartbeat events and inactivity timeouts + * for a given `clientId`. + */ + // @spec CHA-T13a1 + public var userClaim: String? + /// Memberwise initializer to create a `Change`. /// /// - Note: You should not need to use this initializer when using the Chat SDK. It is exposed only to allow users to create mock versions of the SDK's protocols. - public init(clientID: String, type: TypingEventType) { + public init(clientID: String, type: TypingEventType, userClaim: String? = nil) { self.clientID = clientID self.type = type + self.userClaim = userClaim } } } diff --git a/Sources/AblyChat/TypingTimerManager.swift b/Sources/AblyChat/TypingTimerManager.swift index 8de4bf3b..cc64d61f 100644 --- a/Sources/AblyChat/TypingTimerManager.swift +++ b/Sources/AblyChat/TypingTimerManager.swift @@ -7,8 +7,14 @@ internal final class TypingTimerManager: TypingTimerMan private let logger: any InternalLogger private let clock: AnyClock - /// Stores the CHA-T13b1 "is somebody typing" timers. Keys are clientID. - private var whoIsTypingTimers = [String: TimerManager]() + /// Stores per-client typing state including the CHA-T13b1 "is somebody typing" timer and the CHA-T13a1 userClaim. + private struct TypingClientState { + var timer: TimerManager + var userClaim: String? + } + + /// Stores the CHA-T13b1 "is somebody typing" state. Keys are clientID. + private var whoIsTypingState = [String: TypingClientState]() /// Stores the moment when the CHA-T4a4 heartbeat timer (which we use for deciding whether to publish another typing event for the current user) was started. If `nil`, then there is no active heartbeat timer. private var heartbeatTimerStartedAt: AnyClock.Instant? @@ -42,9 +48,14 @@ internal final class TypingTimerManager: TypingTimerMan /// Starts a CHA-T13b1 "is this person typing" timer, thus adding this clientID to the typing set. /// If the clientID is already in the typing set, this will reset the timer (CHA-T13b2). - internal func startTypingTimer(for clientID: String, handler: (@MainActor () -> Void)? = nil) { - let timerManager = whoIsTypingTimers[clientID] ?? TimerManager(clock: clock) - whoIsTypingTimers[clientID] = timerManager + /// (CHA-T13a1) The `userClaim` is stored and persists across heartbeat events. If a new event lacks a `userClaim`, the previously stored value is preserved. + /// The `handler` receives the userClaim that was stored for the client at the time of timer expiry. + internal func startTypingTimer(for clientID: String, userClaim: String? = nil, handler: (@MainActor (_ userClaim: String?) -> Void)? = nil) { + let existingState = whoIsTypingState[clientID] + let timerManager = existingState?.timer ?? TimerManager(clock: clock) + // (CHA-T13a1) Preserve existing userClaim across heartbeats if new event doesn't provide one + let resolvedUserClaim = userClaim ?? existingState?.userClaim + whoIsTypingState[clientID] = TypingClientState(timer: timerManager, userClaim: resolvedUserClaim) // (CHA-T10a1) This grace period is used to determine how long to wait, beyond the heartbeat interval, before removing a client from the typing set. This is used to prevent flickering when a user is typing and stops typing for a short period of time. See CHA-T13b1 for a detailed description of how this is used. let timerDuration = heartbeatThrottle + gracePeriod @@ -59,21 +70,23 @@ internal final class TypingTimerManager: TypingTimerMan logger.log(message: "Typing timer expired for clientID: \(clientID)", level: .debug) // (CHA-T13b3) (1/2) If the (CHA-T13b1) timeout expires, the client shall remove the clientId from the typing set and emit a synthetic typing stop event for the given client. + // (CHA-T13a1) Preserve the userClaim before cancelling the timer state + let expiredUserClaim = whoIsTypingState[clientID]?.userClaim cancelTypingTimer(for: clientID) - handler?() + handler?(expiredUserClaim) } } /// Per CHA-T13b4, cancels the CHA-T13b1 "is this person typing", thus removing this clientID from the typing set. internal func cancelTypingTimer(for clientID: String) { - guard let timer = whoIsTypingTimers[clientID] else { + guard let state = whoIsTypingState[clientID] else { logger.log(message: "No typing timer to cancel for clientID: \(clientID)", level: .debug) return } logger.log(message: "Cancelling typing timer for clientID: \(clientID)", level: .debug) - timer.cancelTimer() - whoIsTypingTimers[clientID] = nil + state.timer.cancelTimer() + whoIsTypingState[clientID] = nil } /// Returns whether this client is present in the set of users who we consider to currently be typing. This is what should be used for the CHA-T13b1 "represents a new client typing" and CHA-T13b5 "is not present in the typing set" checks. @@ -83,7 +96,12 @@ internal final class TypingTimerManager: TypingTimerMan /// Returns the set of client IDs that we consider to currently be typing (also referred to in the spec as the "typing set"). internal func currentlyTypingClientIDs() -> Set { - Set(whoIsTypingTimers.keys) + Set(whoIsTypingState.keys) + } + + /// Returns the stored `userClaim` for a given client, if any (CHA-T13a1). + internal func userClaimForClient(_ clientID: String) -> String? { + whoIsTypingState[clientID]?.userClaim } } @@ -99,11 +117,15 @@ internal protocol TypingTimerManagerProtocol { func cancelHeartbeatTimer() /// Starts a CHA-T13b1 "is this person typing" timer, thus adding this clientID to the typing set. /// If the clientID is already in the typing set, this will reset the timer (CHA-T13b2). - func startTypingTimer(for clientID: String, handler: (@MainActor () -> Void)?) + /// (CHA-T13a1) The `userClaim` is stored and persists across heartbeat events. + /// The `handler` receives the userClaim that was stored for the client at the time of timer expiry. + func startTypingTimer(for clientID: String, userClaim: String?, handler: (@MainActor (_ userClaim: String?) -> Void)?) /// Per CHA-T13b4, cancels the CHA-T13b1 "is this person typing" timer, thus removing this clientID from the typing set. func cancelTypingTimer(for clientID: String) /// Returns whether this client is present in the set of users who we consider to currently be typing. This is what should be used for the CHA-T13b1 "represents a new client typing" and CHA-T13b5 "is not present in the typing set" checks. func isCurrentlyTyping(clientID: String) -> Bool /// Returns the set of client IDs that we consider to currently be typing (also referred to in the spec as the "typing set"). func currentlyTypingClientIDs() -> Set + /// Returns the stored `userClaim` for a given client, if any (CHA-T13a1). + func userClaimForClient(_ clientID: String) -> String? } diff --git a/Tests/AblyChatTests/IntegrationTests.swift b/Tests/AblyChatTests/IntegrationTests.swift index 1100c6da..876e56dd 100644 --- a/Tests/AblyChatTests/IntegrationTests.swift +++ b/Tests/AblyChatTests/IntegrationTests.swift @@ -168,6 +168,9 @@ struct IntegrationTests { Self.logAwait("AFTER rxMessageSubscription.first") #expect(areMessagesEqualModuloNonSerialVersionInfo(rxEventFromSubscription.message, txMessageAfterRxSubscribe)) + // @spec CHA-M2h - userClaim is nil when using API key auth (no JWT claims) + #expect(rxEventFromSubscription.message.userClaim == nil) + // MARK: - Message Reactions (Summary) let messageToReact = txMessageBeforeRxSubscribe @@ -272,6 +275,9 @@ struct IntegrationTests { #expect(reactionRawEvents[2].reaction.name == "😆") #expect(reactionRawEvents[2].reaction.messageSerial == messageToReact.serial) + // @spec CHA-MR7d - userClaim is nil when using API key auth (no JWT claims) + #expect(reactionRawEvents[0].reaction.userClaim == nil) + // Wait a little before requesting history Self.logAwait("BEFORE Task.sleep (2s)") try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) @@ -423,6 +429,9 @@ struct IntegrationTests { #expect(rxReactionFromSubscription.reaction.metadata == ["someMetadataKey": .number(123), "someOtherMetadataKey": .string("foo")]) #expect(rxReactionFromSubscription.reaction.headers == ["someHeadersKey": .number(456), "someOtherHeadersKey": .string("bar")]) + // @spec CHA-ER2a - userClaim is nil when using API key auth (no JWT claims) + #expect(rxReactionFromSubscription.reaction.userClaim == nil) + // MARK: - Occupancy // It can take a moment for the occupancy to update from the clients connecting above, so we'll wait a 2 seconds here. @@ -496,6 +505,9 @@ struct IntegrationTests { #expect(rxPresenceEnterTxEvent.type == .enter) #expect(rxPresenceEnterTxEvent.member.data == ["randomData": "randomValue"]) + // @spec CHA-PR6g - userClaim is nil when using API key auth (no JWT claims) + #expect(rxPresenceEnterTxEvent.member.userClaim == nil) + // (3) Fetch rxClient's presence members and check that txClient is there Self.logAwait("BEFORE rxRoom.presence.get()") let rxPresenceMembers = try await rxRoom.presence.get() @@ -574,6 +586,9 @@ struct IntegrationTests { #expect(typingEvents.count == 1) #expect(typingEvents[0].currentlyTyping.count == 1) + // @spec CHA-T13a1 - userClaim is nil when using API key auth (no JWT claims) + #expect(typingEvents[0].change.userClaim == nil) + // (5) Wait for the typing event to be received (auto sent from timeout) for await typingEvent in rxTypingSubscription { typingEvents.append(typingEvent) diff --git a/Tests/AblyChatTests/TypingTimerManagerTests.swift b/Tests/AblyChatTests/TypingTimerManagerTests.swift index 4383c043..fdd10bb8 100644 --- a/Tests/AblyChatTests/TypingTimerManagerTests.swift +++ b/Tests/AblyChatTests/TypingTimerManagerTests.swift @@ -88,7 +88,7 @@ final class TypingTimerManagerTests { var handlerCalled = false let semaphoreSignalledByHandler = AsyncSemaphore(value: 0) - timerManager.startTypingTimer(for: "client1") { + timerManager.startTypingTimer(for: "client1") { _ in handlerCalled = true semaphoreSignalledByHandler.signal() } @@ -112,7 +112,7 @@ final class TypingTimerManagerTests { var handlerCalled = false - timerManager.startTypingTimer(for: "client1") { + timerManager.startTypingTimer(for: "client1") { _ in handlerCalled = true } @@ -120,7 +120,7 @@ final class TypingTimerManagerTests { await mockClock.advance(by: 1.0) // Reset timer before it expires - timerManager.startTypingTimer(for: "client1") { + timerManager.startTypingTimer(for: "client1") { _ in handlerCalled = true } @@ -149,7 +149,7 @@ final class TypingTimerManagerTests { var handlerCalled = false let semaphoreSignalledByHandler = AsyncSemaphore(value: 0) - timerManager.startTypingTimer(for: "client1") { + timerManager.startTypingTimer(for: "client1") { _ in handlerCalled = true semaphoreSignalledByHandler.signal() } diff --git a/Tests/AblyChatTests/UserClaimTests.swift b/Tests/AblyChatTests/UserClaimTests.swift new file mode 100644 index 00000000..26f3d34c --- /dev/null +++ b/Tests/AblyChatTests/UserClaimTests.swift @@ -0,0 +1,523 @@ +import Ably +@testable import AblyChat +import Clocks +import Foundation +import Testing + +// MARK: - userClaim extraction helper tests + +@MainActor +struct UserClaimExtractionTests { + // @specPartial CHA-M2h - Tests extraction helper for userClaim from extras + @Test + func userClaim_extractsStringValue() { + let extras: [String: JSONValue] = ["userClaim": .string("admin")] + #expect(extras.userClaim == "admin") + } + + @Test + func userClaim_returnsNilForMissingKey() { + let extras: [String: JSONValue] = ["headers": .object([:])] + #expect(extras.userClaim == nil) + } + + @Test + func userClaim_returnsNilForNonStringValue() { + let extras: [String: JSONValue] = ["userClaim": .number(42)] + #expect(extras.userClaim == nil) + } + + @Test + func userClaim_returnsNilForNullValue() { + let extras: [String: JSONValue] = ["userClaim": .null] + #expect(extras.userClaim == nil) + } + + @Test + func userClaim_returnsEmptyStringForEmptyStringValue() { + let extras: [String: JSONValue] = ["userClaim": .string("")] + #expect(extras.userClaim == "") + } + + @Test + func userClaim_returnsNilForEmptyExtras() { + let extras: [String: JSONValue] = [:] + #expect(extras.userClaim == nil) + } +} + +// MARK: - Message userClaim tests + +@MainActor +struct MessageUserClaimTests { + // @spec CHA-M2h - Message includes userClaim from realtime extras + @Test + func subscribe_messageWithUserClaimInExtras() async throws { + // Given + let realtime = MockRealtime() + let chatAPI = ChatAPI(realtime: realtime) + + let message = ARTMessage() + message.action = .create + message.serial = "serial1" + message.clientId = "client1" + message.data = ["text": "hello", "metadata": [:]] as [String: Any] + message.extras = [ + "headers": [:], + "userClaim": "moderator", + ] as (any ARTJsonCompatible) + message.version = .init(serial: "v1") + message.timestamp = Date(timeIntervalSince1970: 1_000_000) + + let channel = MockRealtimeChannel( + properties: .init(attachSerial: "001", channelSerial: "001"), + initialState: .attached, + messageToEmitOnSubscribe: message, + ) + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomName: "test-room", logger: TestLogger()) + + // When / Then + _ = defaultMessages.subscribe { event in + #expect(event.message.userClaim == "moderator") + } + } + + // @spec CHA-M2h - Message without userClaim in extras has nil userClaim + @Test + func subscribe_messageWithoutUserClaimInExtras() async throws { + // Given + let realtime = MockRealtime() + let chatAPI = ChatAPI(realtime: realtime) + + let message = ARTMessage() + message.action = .create + message.serial = "serial1" + message.clientId = "client1" + message.data = ["text": "hello", "metadata": [:]] as [String: Any] + message.extras = [ + "headers": [:], + ] as (any ARTJsonCompatible) + message.version = .init(serial: "v1") + message.timestamp = Date(timeIntervalSince1970: 1_000_000) + + let channel = MockRealtimeChannel( + properties: .init(attachSerial: "001", channelSerial: "001"), + initialState: .attached, + messageToEmitOnSubscribe: message, + ) + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomName: "test-room", logger: TestLogger()) + + // When / Then + _ = defaultMessages.subscribe { event in + #expect(event.message.userClaim == nil) + } + } + + // @spec CHA-M2h - Message decoded from REST JSON includes userClaim + @Test + func jsonDecodable_messageWithUserClaim() throws { + let jsonObject: [String: JSONValue] = [ + "serial": "serial1", + "action": "message.create", + "clientId": "client1", + "text": "hello", + "metadata": [:], + "headers": [:], + "version": ["serial": "v1"], + "userClaim": "admin", + ] + + let message = try Message(jsonObject: jsonObject) + #expect(message.userClaim == "admin") + } + + // @spec CHA-M2h - Message decoded from REST JSON without userClaim has nil + @Test + func jsonDecodable_messageWithoutUserClaim() throws { + let jsonObject: [String: JSONValue] = [ + "serial": "serial1", + "action": "message.create", + "clientId": "client1", + "text": "hello", + "metadata": [:], + "headers": [:], + "version": ["serial": "v1"], + ] + + let message = try Message(jsonObject: jsonObject) + #expect(message.userClaim == nil) + } +} + +// MARK: - RoomReaction userClaim tests + +@MainActor +struct RoomReactionUserClaimTests { + // @spec CHA-ER2a - RoomReaction includes userClaim from realtime extras + @Test + func subscribe_reactionWithUserClaimInExtras() async throws { + // Given + let message = ARTMessage() + message.action = .create + message.name = "roomReaction" + message.serial = "serial1" + message.clientId = "client1" + message.data = ["name": "like"] as [String: Any] + message.extras = [ + "headers": [:], + "userClaim": "vip", + ] as (any ARTJsonCompatible) + message.timestamp = Date(timeIntervalSince1970: 1_000_000) + + let channel = MockRealtimeChannel( + messageToEmitOnSubscribe: message, + ) + let defaultRoomReactions = DefaultRoomReactions(realtime: MockRealtime(), channel: channel, roomName: "test-room", logger: TestLogger()) + + // When / Then + _ = defaultRoomReactions.subscribe { event in + #expect(event.reaction.userClaim == "vip") + } + } + + // @spec CHA-ER2a - RoomReaction without userClaim has nil userClaim + @Test + func subscribe_reactionWithoutUserClaimInExtras() async throws { + // Given + let message = ARTMessage() + message.action = .create + message.name = "roomReaction" + message.serial = "serial1" + message.clientId = "client1" + message.data = ["name": "like"] as [String: Any] + message.extras = [ + "headers": [:], + ] as (any ARTJsonCompatible) + message.timestamp = Date(timeIntervalSince1970: 1_000_000) + + let channel = MockRealtimeChannel( + messageToEmitOnSubscribe: message, + ) + let defaultRoomReactions = DefaultRoomReactions(realtime: MockRealtime(), channel: channel, roomName: "test-room", logger: TestLogger()) + + // When / Then + _ = defaultRoomReactions.subscribe { event in + #expect(event.reaction.userClaim == nil) + } + } +} + +// MARK: - PresenceMember userClaim tests + +@MainActor +struct PresenceMemberUserClaimTests { + // @spec CHA-PR6g - PresenceMember includes userClaim from presence extras + @Test + func presenceMember_extractsUserClaimFromExtras() { + let member = PresenceMember( + clientID: "client1", + connectionID: "conn1", + data: nil, + extras: ["userClaim": .string("subscriber")], + updatedAt: Date(), + userClaim: "subscriber", + ) + #expect(member.userClaim == "subscriber") + } + + // @spec CHA-PR6g - PresenceMember without userClaim in extras has nil + @Test + func presenceMember_nilUserClaimWhenMissingFromExtras() { + let member = PresenceMember( + clientID: "client1", + connectionID: "conn1", + data: nil, + extras: nil, + updatedAt: Date(), + ) + #expect(member.userClaim == nil) + } +} + +// MARK: - TypingSetEvent.Change userClaim tests + +@MainActor +struct TypingUserClaimTests { + @available(iOS 16.0, tvOS 16, *) + private func createTypingTimerManager(with testClock: MockTestClock) -> TypingTimerManager { + TypingTimerManager( + heartbeatThrottle: 1.0, + gracePeriod: 0.5, + logger: TestLogger(), + clock: testClock, + ) + } + + // @spec CHA-T13a1 - userClaim is stored with typing state + @Test + @available(iOS 16.0, tvOS 16, *) + func typingTimerManager_storesUserClaim() { + let mockClock = MockTestClock() + let timerManager = createTypingTimerManager(with: mockClock) + + timerManager.startTypingTimer(for: "client1", userClaim: "admin") + + #expect(timerManager.userClaimForClient("client1") == "admin") + } + + // @spec CHA-T13a1 - userClaim persists across heartbeat events + @Test + @available(iOS 16.0, tvOS 16, *) + func typingTimerManager_preservesUserClaimOnHeartbeat() { + let mockClock = MockTestClock() + let timerManager = createTypingTimerManager(with: mockClock) + + // Initial typing event with userClaim + timerManager.startTypingTimer(for: "client1", userClaim: "admin") + #expect(timerManager.userClaimForClient("client1") == "admin") + + // Heartbeat event without userClaim - should preserve the existing one + timerManager.startTypingTimer(for: "client1", userClaim: nil) + #expect(timerManager.userClaimForClient("client1") == "admin") + } + + // @spec CHA-T13a1 - userClaim is updated when a new one is provided + @Test + @available(iOS 16.0, tvOS 16, *) + func typingTimerManager_updatesUserClaimWhenNewOneProvided() { + let mockClock = MockTestClock() + let timerManager = createTypingTimerManager(with: mockClock) + + timerManager.startTypingTimer(for: "client1", userClaim: "admin") + #expect(timerManager.userClaimForClient("client1") == "admin") + + timerManager.startTypingTimer(for: "client1", userClaim: "moderator") + #expect(timerManager.userClaimForClient("client1") == "moderator") + } + + // @spec CHA-T13a1 - userClaim is nil for unknown client + @Test + @available(iOS 16.0, tvOS 16, *) + func typingTimerManager_returnsNilForUnknownClient() { + let mockClock = MockTestClock() + let timerManager = createTypingTimerManager(with: mockClock) + + #expect(timerManager.userClaimForClient("unknown") == nil) + } + + // @spec CHA-T13a1 - userClaim is removed when typing timer is cancelled + @Test + @available(iOS 16.0, tvOS 16, *) + func typingTimerManager_removesUserClaimOnCancel() { + let mockClock = MockTestClock() + let timerManager = createTypingTimerManager(with: mockClock) + + timerManager.startTypingTimer(for: "client1", userClaim: "admin") + #expect(timerManager.userClaimForClient("client1") == "admin") + + timerManager.cancelTypingTimer(for: "client1") + #expect(timerManager.userClaimForClient("client1") == nil) + } + + // @spec CHA-T13a1 - typing started event includes userClaim from message extras + @Test + @available(iOS 16.0, tvOS 16, *) + func subscribe_startedEventIncludesUserClaim() async throws { + // Given + let channel = MockRealtimeChannel() + let mockClock = MockTestClock() + let typing = DefaultTyping( + channel: channel, + roomName: "test-room", + logger: TestLogger(), + heartbeatThrottle: 5, + clock: mockClock, + ) + var receivedEvents: [TypingSetEvent] = [] + + _ = typing.subscribe { @MainActor event in + receivedEvents.append(event) + } + + // When - simulate a typing.started message with userClaim in extras + let message = ARTMessage(name: TypingEventType.started.rawValue, data: [], clientId: "test-client") + message.extras = ["userClaim": "admin"] as (any ARTJsonCompatible) + channel.simulateIncomingMessage(message, for: TypingEventType.started.rawValue) + + // Then + #expect(receivedEvents.count == 1) + #expect(receivedEvents[0].change.type == .started) + #expect(receivedEvents[0].change.clientID == "test-client") + #expect(receivedEvents[0].change.userClaim == "admin") + } + + // @spec CHA-T13a1 - typing stopped event includes userClaim from message extras + @Test + @available(iOS 16.0, tvOS 16, *) + func subscribe_stoppedEventIncludesUserClaim() async throws { + // Given + let channel = MockRealtimeChannel() + let mockClock = MockTestClock() + let typing = DefaultTyping( + channel: channel, + roomName: "test-room", + logger: TestLogger(), + heartbeatThrottle: 5, + clock: mockClock, + ) + var receivedEvents: [TypingSetEvent] = [] + + _ = typing.subscribe { @MainActor event in + receivedEvents.append(event) + } + + // First, start typing + let startMessage = ARTMessage(name: TypingEventType.started.rawValue, data: [], clientId: "test-client") + startMessage.extras = ["userClaim": "admin"] as (any ARTJsonCompatible) + channel.simulateIncomingMessage(startMessage, for: TypingEventType.started.rawValue) + + // When - simulate a typing.stopped message with userClaim + let stopMessage = ARTMessage(name: TypingEventType.stopped.rawValue, data: [], clientId: "test-client") + stopMessage.extras = ["userClaim": "admin"] as (any ARTJsonCompatible) + channel.simulateIncomingMessage(stopMessage, for: TypingEventType.stopped.rawValue) + + // Then + #expect(receivedEvents.count == 2) + #expect(receivedEvents[1].change.type == .stopped) + #expect(receivedEvents[1].change.clientID == "test-client") + #expect(receivedEvents[1].change.userClaim == "admin") + } + + // @spec CHA-T13a1 - typing stopped event falls back to cached userClaim when message lacks it + @Test + @available(iOS 16.0, tvOS 16, *) + func subscribe_stoppedEventFallsBackToCachedUserClaim() async throws { + // Given + let channel = MockRealtimeChannel() + let mockClock = MockTestClock() + let typing = DefaultTyping( + channel: channel, + roomName: "test-room", + logger: TestLogger(), + heartbeatThrottle: 5, + clock: mockClock, + ) + var receivedEvents: [TypingSetEvent] = [] + + _ = typing.subscribe { @MainActor event in + receivedEvents.append(event) + } + + // First, start typing with a userClaim + let startMessage = ARTMessage(name: TypingEventType.started.rawValue, data: [], clientId: "test-client") + startMessage.extras = ["userClaim": "admin"] as (any ARTJsonCompatible) + channel.simulateIncomingMessage(startMessage, for: TypingEventType.started.rawValue) + + // When - simulate a typing.stopped message WITHOUT userClaim + let stopMessage = ARTMessage(name: TypingEventType.stopped.rawValue, data: [], clientId: "test-client") + channel.simulateIncomingMessage(stopMessage, for: TypingEventType.stopped.rawValue) + + // Then - should fall back to cached userClaim + #expect(receivedEvents.count == 2) + #expect(receivedEvents[1].change.type == .stopped) + #expect(receivedEvents[1].change.userClaim == "admin") + } + + // @spec CHA-T13a1 - inactivity timeout synthetic stop event includes cached userClaim + @Test + @available(iOS 16.0, tvOS 16, *) + func subscribe_timeoutStopEventIncludesCachedUserClaim() async throws { + // Given + let channel = MockRealtimeChannel() + let mockClock = MockTestClock() + let heartbeatThrottle: TimeInterval = 5 + let typing = DefaultTyping( + channel: channel, + roomName: "test-room", + logger: TestLogger(), + heartbeatThrottle: heartbeatThrottle, + clock: mockClock, + ) + var receivedEvents: [TypingSetEvent] = [] + + _ = typing.subscribe { @MainActor event in + receivedEvents.append(event) + } + + // When - simulate a typing.started message with userClaim + let message = ARTMessage(name: TypingEventType.started.rawValue, data: [], clientId: "test-client") + message.extras = ["userClaim": "admin"] as (any ARTJsonCompatible) + channel.simulateIncomingMessage(message, for: TypingEventType.started.rawValue) + + #expect(receivedEvents.count == 1) + #expect(receivedEvents[0].change.type == .started) + + // Advance clock past heartbeat + grace period to trigger timeout + await mockClock.advance(by: heartbeatThrottle + 2 + 1) + + // Then - synthetic stop event should include the cached userClaim + #expect(receivedEvents.count == 2) + #expect(receivedEvents[1].change.type == .stopped) + #expect(receivedEvents[1].change.clientID == "test-client") + #expect(receivedEvents[1].change.userClaim == "admin") + } + + // @spec CHA-T13a1 - typing event without userClaim has nil + @Test + @available(iOS 16.0, tvOS 16, *) + func subscribe_startedEventWithoutUserClaim() async throws { + // Given + let channel = MockRealtimeChannel() + let mockClock = MockTestClock() + let typing = DefaultTyping( + channel: channel, + roomName: "test-room", + logger: TestLogger(), + heartbeatThrottle: 5, + clock: mockClock, + ) + var receivedEvents: [TypingSetEvent] = [] + + _ = typing.subscribe { @MainActor event in + receivedEvents.append(event) + } + + // When - simulate a typing.started message without extras + let message = ARTMessage(name: TypingEventType.started.rawValue, data: [], clientId: "test-client") + channel.simulateIncomingMessage(message, for: TypingEventType.started.rawValue) + + // Then + #expect(receivedEvents.count == 1) + #expect(receivedEvents[0].change.userClaim == nil) + } +} + +// MARK: - MessageReactionRawEvent.Reaction userClaim tests + +@MainActor +struct MessageReactionUserClaimTests { + // @spec CHA-MR7d - Reaction includes userClaim from annotation extras + @Test + func reaction_includesUserClaimFromInit() { + let reaction = MessageReactionRawEvent.Reaction( + type: .unique, + name: "like", + messageSerial: "serial1", + clientID: "client1", + userClaim: "admin", + ) + #expect(reaction.userClaim == "admin") + } + + // @spec CHA-MR7d - Reaction without userClaim defaults to nil + @Test + func reaction_defaultsToNilUserClaim() { + let reaction = MessageReactionRawEvent.Reaction( + type: .unique, + name: "like", + messageSerial: "serial1", + clientID: "client1", + ) + #expect(reaction.userClaim == nil) + } +} diff --git a/docs/plans/CHA-1243-user-claims.md b/docs/plans/CHA-1243-user-claims.md new file mode 100644 index 00000000..a7bb6548 --- /dev/null +++ b/docs/plans/CHA-1243-user-claims.md @@ -0,0 +1,220 @@ +# Plan: Add `userClaim` Field to Chat Events + +## Context + +The Chat specification (PR #423) adds an optional `userClaim` field across all chat event types. This field is a server-provided, read-only string extracted from JWT claims embedded in realtime message `extras.userClaim`. It enables per-room/channel authorization by exposing channel-specific JWT user claims to SDK consumers. The JS SDK has implemented this in PR #711. This plan covers the equivalent implementation in the Swift SDK. + +### Spec Items + +| Spec Item | Entity | Summary | +|-----------|--------|---------| +| CHA-M2h | Message | Optional `userClaim` on Message | +| CHA-MR7d | MessageReactionRawEvent.Reaction | Optional `userClaim` on raw reaction events | +| CHA-ER2a | RoomReaction | Optional `userClaim` on ephemeral room reactions | +| CHA-PR6g | PresenceMember | Optional `userClaim` on presence members | +| CHA-T13a1 | TypingSetEvent.Change | Optional `userClaim` on typing event changes; must persist across heartbeats and inactivity timeouts | + +All fields share these characteristics: +- Optional `String?`, read-only, server-provided +- Extracted from `extras.userClaim` on the underlying Ably realtime message/annotation/presence message +- Clients cannot send this field + +--- + +## Implementation Steps + +### Step 1: Add a `userClaim` extraction helper + +**File:** `Sources/AblyChat/JSONValue.swift` (or a new small extension) + +Add a helper function to extract `userClaim` from an extras dictionary, following the existing pattern of `objectFromAblyCocoaExtras`: + +```swift +internal extension Dictionary where Key == String, Value == JSONValue { + var userClaim: String? { + self["userClaim"]?.stringValue + } +} +``` + +This centralizes extraction and ensures type safety (must be a string). + +--- + +### Step 2: Add `userClaim` to `Message` + +**File:** `Sources/AblyChat/Message.swift` + +- Add `public var userClaim: String?` property to `Message` struct +- Update the public memberwise initializer to include `userClaim: String? = nil` (default nil for backwards compatibility) +- Update `Message.init(jsonObject:)` (JSONObjectDecodable) to extract `userClaim` from the JSON response: `jsonObject.optionalStringValueForKey("userClaim")` +- Update `Message.copy(...)` — no change needed since `userClaim` is server-provided and shouldn't be modified by copy + +**File:** `Sources/AblyChat/DefaultMessages.swift` + +- In `subscribe(_:)`, extract `userClaim` from the `extras` dictionary (already parsed from `message.extras`): `let userClaim = extras.userClaim` +- Pass `userClaim` to the `Message(...)` initializer + +--- + +### Step 3: Add `userClaim` to `RoomReaction` + +**File:** `Sources/AblyChat/RoomReaction.swift` + +- Add `public var userClaim: String?` to `RoomReaction` +- Update the public memberwise initializer with `userClaim: String? = nil` + +**File:** `Sources/AblyChat/DefaultRoomReactions.swift` + +- In `subscribe(_:)`, extract `userClaim` from the `extras` dictionary (already parsed): `let userClaim = extras["userClaim"]?.stringValue` +- Pass to `RoomReaction(...)` initializer + +--- + +### Step 4: Add `userClaim` to `PresenceMember` + +**File:** `Sources/AblyChat/Presence.swift` + +- Add `public var userClaim: String?` to `PresenceMember` +- Update the public memberwise initializer with `userClaim: String? = nil` + +**File:** `Sources/AblyChat/DefaultPresence.swift` + +- In `processPresenceGet(members:)` and `processPresenceSubscribe(_:for:)`, extract `userClaim` from `member.extras`: `member.extras?.userClaim` +- Pass to `PresenceMember(...)` initializer + +The `PresenceMessage` internal struct already parses `extras` from `ARTPresenceMessage`, so the extras dictionary is already available. + +--- + +### Step 5: Add `userClaim` to `TypingSetEvent.Change` + +**File:** `Sources/AblyChat/Typing.swift` + +- Add `public var userClaim: String?` to `TypingSetEvent.Change` +- Update the public memberwise initializer with `userClaim: String? = nil` + +**File:** `Sources/AblyChat/TypingTimerManager.swift` + +- Add a `userClaim: String?` field to the per-client typing state. The typing timer manager needs to track the `userClaim` alongside each client's timer so it persists across heartbeats and inactivity timeouts. +- Update `TypingTimerManagerProtocol`: + - `startTypingTimer(for:userClaim:handler:)` — add `userClaim` parameter + - `userClaimForClient(_:) -> String?` — retrieve stored claim for a client +- In `TypingTimerManager`, store `userClaim` alongside the timer in `whoIsTypingTimers` (change value type from `TimerManager` to a struct holding both timer and optional userClaim) +- When a new typing.started event arrives with a `userClaim`, store/update it; when it arrives without one, preserve the existing stored claim (CHA-T13a1: "must persist across heartbeat events") + +**File:** `Sources/AblyChat/DefaultTyping.swift` + +- In the `typing.started` handler: + - Extract `userClaim` from the realtime message extras: `let extras = message.extras.flatMap { JSONValue.objectFromAblyCocoaExtras($0) } ?? [:]` then `extras.userClaim` + - Pass to `typingTimerManager.startTypingTimer(for:userClaim:handler:)` + - Include in the `TypingSetEvent.Change` for both the "new client" event and the timeout-driven synthetic stop event +- In the `typing.stopped` handler: + - Extract `userClaim` from the message extras (or fall back to the cached value from the timer manager) + - Include in the `TypingSetEvent.Change` + +--- + +### Step 6: Add `userClaim` to `MessageReactionRawEvent.Reaction` + +**File:** `Sources/AblyChat/MessageReaction.swift` + +- Add `public var userClaim: String?` to `MessageReactionRawEvent.Reaction` +- Update the public memberwise initializer with `userClaim: String? = nil` + +**File:** `Sources/AblyChat/DefaultMessageReactions.swift` + +- In `subscribeRaw(_:)`, the callback receives an `ARTAnnotation`. Extract extras: `let extras = annotation.extras.flatMap { JSONValue.objectFromAblyCocoaExtras($0) } ?? [:]` then `extras.userClaim` +- Pass to `MessageReactionRawEvent.Reaction(...)` initializer + +Note: `ARTAnnotation` has an `extras` property (it's an `ARTBaseMessage` subclass). The internal `Annotation` wrapper already handles extras extraction but isn't used in `subscribeRaw` — we can extract directly from `ARTAnnotation.extras`. + +--- + +### Step 7: Update Mock/Test Helpers + +**Files in `Tests/AblyChatTests/Mocks/`:** + +- Update any mock implementations that create `Message`, `PresenceMember`, `RoomReaction`, `TypingSetEvent.Change`, or `MessageReactionRawEvent.Reaction` to include the new `userClaim` parameter (default nil keeps them backwards compatible) + +--- + +### Step 8: Unit Tests + +**File:** `Tests/AblyChatTests/` (new or existing test files) + +For each type, test: + +1. **Message userClaim:** + - Realtime message with `extras.userClaim` → Message has correct `userClaim` + - Realtime message without `extras.userClaim` → Message has `nil` userClaim + - REST/JSON decoded message with `userClaim` → Message has correct `userClaim` + - REST/JSON decoded message without `userClaim` → Message has `nil` userClaim + +2. **RoomReaction userClaim:** + - Realtime reaction with `extras.userClaim` → RoomReaction has correct `userClaim` + - Realtime reaction without `extras.userClaim` → RoomReaction has `nil` userClaim + +3. **PresenceMember userClaim:** + - Presence message with `extras.userClaim` → PresenceMember has correct `userClaim` + - Presence message without extras → PresenceMember has `nil` userClaim + - Both `get()` and `subscribe()` paths + +4. **TypingSetEvent.Change userClaim:** + - Typing started with `userClaim` → change includes claim + - Heartbeat (repeated started) preserves existing claim when new event lacks one + - Typing stopped includes claim (from message or cached) + - Inactivity timeout synthetic stop includes cached claim + +5. **MessageReactionRawEvent.Reaction userClaim:** + - Annotation with `extras.userClaim` → Reaction has correct `userClaim` + - Annotation without extras → Reaction has `nil` userClaim + +6. **Helper extraction:** + - `extras.userClaim` returns string value correctly + - Non-string `userClaim` values return nil + - Missing `userClaim` key returns nil + +--- + +### Step 9: Integration Tests + +**File:** `Tests/AblyChatTests/IntegrationTests.swift` + +Integration tests require the server to actually populate `userClaim` from JWT tokens. This depends on the sandbox supporting `ably.room.` claims in JWTs. + +- If sandbox JWT support is available: Add integration tests that create a client with a JWT containing room claims, send messages/reactions/presence/typing, and verify `userClaim` is populated on received events. +- If not yet available: Add placeholder integration tests with `@Test(.disabled("Requires server-side userClaim support"))` or similar, documenting what should be tested once server support lands. + +At minimum, add integration tests that verify `userClaim` is `nil` when using standard API key auth (confirming no regression and that the field is properly exposed even when absent). + +--- + +## Key Files to Modify + +| File | Changes | +|------|---------| +| `Sources/AblyChat/Message.swift` | Add `userClaim` property, update init, update JSON decoding | +| `Sources/AblyChat/DefaultMessages.swift` | Extract `userClaim` from extras, pass to Message | +| `Sources/AblyChat/RoomReaction.swift` | Add `userClaim` property, update init | +| `Sources/AblyChat/DefaultRoomReactions.swift` | Extract `userClaim` from extras, pass to RoomReaction | +| `Sources/AblyChat/Presence.swift` | Add `userClaim` property to PresenceMember, update init | +| `Sources/AblyChat/DefaultPresence.swift` | Extract `userClaim` from member extras | +| `Sources/AblyChat/Typing.swift` | Add `userClaim` to TypingSetEvent.Change, update init | +| `Sources/AblyChat/DefaultTyping.swift` | Extract `userClaim` from message extras, pass through | +| `Sources/AblyChat/TypingTimerManager.swift` | Track `userClaim` per client alongside timers | +| `Sources/AblyChat/MessageReaction.swift` | Add `userClaim` to Reaction, update init | +| `Sources/AblyChat/DefaultMessageReactions.swift` | Extract `userClaim` from annotation extras | +| `Sources/AblyChat/JSONValue.swift` | Add `userClaim` extraction helper on extras dictionary | +| `Tests/AblyChatTests/` | Unit tests for all above | +| `Tests/AblyChatTests/IntegrationTests.swift` | Integration tests | + +--- + +## Verification + +1. **Build:** `swift build` — ensure no compilation errors +2. **Lint:** `swift run BuildTool lint` — ensure no lint warnings +3. **Unit tests:** `swift test` — all existing and new tests pass +4. **Spec coverage:** Add `@spec` tags to tests referencing CHA-M2h, CHA-MR7d, CHA-ER2a, CHA-PR6g, CHA-T13a1 +5. **Manual verification:** Confirm default `nil` values don't break existing consumers (all new parameters have defaults) From 9c1ec6db8aa907ee62ff75aea9eef9f23f69142f Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 25 Feb 2026 16:42:00 +0000 Subject: [PATCH 2/6] Add TypingMember type, currentTypers property, and bump ably-cocoa to 1.2.58 - Bump ably-cocoa dependency from 1.2.51 to 1.2.58 for userClaim support - Add TypingMember struct pairing clientID with userClaim metadata - Add currentTypers property to Typing protocol and TypingSetEvent - Add currentlyTypingMembers() to TypingTimerManager - Consolidate createSandboxChatClient/createSandboxChatClientWithJWT into a single helper with optional JWT parameters - Add JWT auth helper (Sandbox.createJWT) for integration tests - Add integration test for userClaim propagation across all event types - Stub new ARTChannelProtocol methods from ably-cocoa 1.2.58 in mock Co-Authored-By: Claude Opus 4.6 --- Package.resolved | 14 +- Package.swift | 2 +- Sources/AblyChat/DefaultTyping.swift | 8 + Sources/AblyChat/Message.swift | 2 +- Sources/AblyChat/MessageReaction.swift | 2 +- Sources/AblyChat/Presence.swift | 2 +- Sources/AblyChat/RoomReaction.swift | 2 +- Sources/AblyChat/Typing.swift | 41 +++- Sources/AblyChat/TypingTimerManager.swift | 9 + Tests/AblyChatTests/DefaultTypingTests.swift | 12 ++ Tests/AblyChatTests/Helpers/Sandbox.swift | 55 ++++++ Tests/AblyChatTests/IntegrationTests.swift | 187 +++++++++++++++++- .../Mocks/MockAblyCocoaRealtime.swift | 28 +++ Tests/AblyChatTests/UserClaimTests.swift | 6 +- 14 files changed, 347 insertions(+), 23 deletions(-) diff --git a/Package.resolved b/Package.resolved index e5131c5b..7d3b7025 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "c5a311ec99a465212cd75bae8db1ca4164d5636e38f0e159f7b51ee8b32c8c1b", + "originHash" : "83c3fe70b23fcba31ad555b2a50e59661b3d87df10dc0eca2b812fb2066e4ad9", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "4e23a5136ecaf40fa7bad9b0df6bb5be5bb2b0c7", - "version" : "1.2.51" + "revision" : "14d2661824652d0c6065dfe4dcf8c2e3781502fc", + "version" : "1.2.58" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "8db4632b0664b7272d54f7b612ddad0a18d1758f", - "version" : "0.2.0" + "revision" : "dd118432a6e023c3d2c8a051299e8081a06db036", + "version" : "1.0.0" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/delta-codec-cocoa", "state" : { - "revision" : "3ee62ea40a63996b55818d44b3f0e56d8753be88", - "version" : "1.3.3" + "revision" : "d53eec08f9443c6160d941327a6f9d8bbb93cea2", + "version" : "1.3.5" } }, { diff --git a/Package.swift b/Package.swift index ce395c2c..b41614af 100644 --- a/Package.swift +++ b/Package.swift @@ -26,7 +26,7 @@ let package = Package( // This is the SDK's only dependency. .package( url: "https://github.com/ably/ably-cocoa", - from: "1.2.51", + from: "1.2.58", ), // All of the following dependencies are only used for internal purposes (testing or build tooling). diff --git a/Sources/AblyChat/DefaultTyping.swift b/Sources/AblyChat/DefaultTyping.swift index dd9f1d94..82462840 100644 --- a/Sources/AblyChat/DefaultTyping.swift +++ b/Sources/AblyChat/DefaultTyping.swift @@ -66,6 +66,7 @@ internal final class DefaultTyping: Typing { TypingSetEvent( type: .setChanged, currentlyTyping: typingTimerManager.currentlyTypingClientIDs(), + currentTypers: typingTimerManager.currentlyTypingMembers(), change: .init(clientID: messageClientID, type: .stopped, userClaim: expiredUserClaim), ), ) @@ -78,6 +79,7 @@ internal final class DefaultTyping: Typing { TypingSetEvent( type: .setChanged, currentlyTyping: typingTimerManager.currentlyTypingClientIDs(), + currentTypers: typingTimerManager.currentlyTypingMembers(), change: .init(clientID: messageClientID, type: .started, userClaim: typingTimerManager.userClaimForClient(messageClientID)), ), ) @@ -109,6 +111,7 @@ internal final class DefaultTyping: Typing { TypingSetEvent( type: .setChanged, currentlyTyping: typingTimerManager.currentlyTypingClientIDs(), + currentTypers: typingTimerManager.currentlyTypingMembers(), change: .init(clientID: messageClientID, type: .stopped, userClaim: userClaim), ), ) @@ -131,6 +134,11 @@ internal final class DefaultTyping: Typing { typingTimerManager.currentlyTypingClientIDs() } + // (CHA-T18) + internal var currentTypers: [TypingMember] { + typingTimerManager.currentlyTypingMembers() + } + // (CHA-T4) Users may indicate that they have started typing using the keystroke method. internal func keystroke() async throws(ErrorInfo) { try await keyboardOperationQueue.enqueue { [weak self] () throws(ErrorInfo) in diff --git a/Sources/AblyChat/Message.swift b/Sources/AblyChat/Message.swift index 74c8bf22..d7720f96 100644 --- a/Sources/AblyChat/Message.swift +++ b/Sources/AblyChat/Message.swift @@ -84,13 +84,13 @@ public struct Message: Sendable, Equatable { */ public var reactions: MessageReactionSummary + // @spec CHA-M2h /** * The user claim attached to this message by the server. * * Set automatically by the Ably server when a JWT contains a matching * `ably.room.` claim. This is a read-only, server-provided value. */ - // @spec CHA-M2h public var userClaim: String? /// Memberwise initializer to create a `Message`. diff --git a/Sources/AblyChat/MessageReaction.swift b/Sources/AblyChat/MessageReaction.swift index e63b9871..a6f7fef3 100644 --- a/Sources/AblyChat/MessageReaction.swift +++ b/Sources/AblyChat/MessageReaction.swift @@ -280,13 +280,13 @@ public struct MessageReactionRawEvent: Sendable { */ public var clientID: String + // @spec CHA-MR7d /** * The user claim attached to this reaction event by the server. * * Set automatically by the Ably server when a JWT contains a matching * `ably.room.` claim. This is a read-only, server-provided value. */ - // @spec CHA-MR7d public var userClaim: String? /// Memberwise initializer to create a `Reaction`. diff --git a/Sources/AblyChat/Presence.swift b/Sources/AblyChat/Presence.swift index 19cbc9b0..ba819515 100644 --- a/Sources/AblyChat/Presence.swift +++ b/Sources/AblyChat/Presence.swift @@ -182,13 +182,13 @@ public struct PresenceMember: Sendable { // swiftlint:disable:next missing_docs public var updatedAt: Date + // @spec CHA-PR6g /** * The user claim attached to this presence event by the server. * * Set automatically by the Ably server when a JWT contains a matching * `ably.room.` claim. This is a read-only, server-provided value. */ - // @spec CHA-PR6g public var userClaim: String? } diff --git a/Sources/AblyChat/RoomReaction.swift b/Sources/AblyChat/RoomReaction.swift index cb0efcd3..f2215f53 100644 --- a/Sources/AblyChat/RoomReaction.swift +++ b/Sources/AblyChat/RoomReaction.swift @@ -46,13 +46,13 @@ public struct RoomReaction: Sendable { */ public var isSelf: Bool + // @spec CHA-ER2a /** * The user claim attached to this reaction by the server. * * Set automatically by the Ably server when a JWT contains a matching * `ably.room.` claim. This is a read-only, server-provided value. */ - // @spec CHA-ER2a public var userClaim: String? /// Memberwise initializer to create a `RoomReaction`. diff --git a/Sources/AblyChat/Typing.swift b/Sources/AblyChat/Typing.swift index 26c37d84..6f22d466 100644 --- a/Sources/AblyChat/Typing.swift +++ b/Sources/AblyChat/Typing.swift @@ -1,5 +1,24 @@ import Ably +/** + * Represents a user in the set of currently typing users, with associated metadata. + */ +public struct TypingMember: Sendable { + /// The client ID of the typing user. + public var clientID: String + + /// The user claim attached to this user's typing event, if any. + public var userClaim: String? + + /// Memberwise initializer to create a `TypingMember`. + /// + /// - Note: You should not need to use this initializer when using the Chat SDK. It is exposed only to allow users to create mock versions of the SDK's protocols. + public init(clientID: String, userClaim: String? = nil) { + self.clientID = clientID + self.userClaim = userClaim + } +} + /** * This interface is used to interact with typing in a chat room including subscribing to typing events and * fetching the current set of typing clients. @@ -22,13 +41,24 @@ public protocol Typing: AnyObject, Sendable { @discardableResult func subscribe(_ callback: @escaping @MainActor (TypingSetEvent) -> Void) -> Subscription + // @spec CHA-T16 /** * Get the current typers, a set of clientIds. * + * Deprecated per CHA-T16; use ``currentTypers`` (CHA-T18) instead. + * * - Returns: A set of clientIds that are currently typing. */ var current: Set { get } + // @spec CHA-T18 + /** + * Gets the current set of users who are typing, with associated metadata. + * + * - Returns: An array of ``TypingMember`` for users currently typing. + */ + var currentTypers: [TypingMember] { get } + /** * Keystroke indicates that the current user is typing. This will emit a ``TypingEvent`` event to inform listening clients and begin a timer, * once the timer expires, another ``TypingEvent`` event will be emitted. In both cases ``TypingEvent/currentlyTyping`` @@ -95,6 +125,12 @@ public struct TypingSetEvent: Sendable { */ public var currentlyTyping: Set + // @spec CHA-T6d + /** + * The set of users currently typing, with associated metadata. + */ + public var currentTypers: [TypingMember] + /** * Get the details of the operation that modified the typing event. */ @@ -103,9 +139,10 @@ public struct TypingSetEvent: Sendable { /// Memberwise initializer to create a `TypingSetEvent`. /// /// - Note: You should not need to use this initializer when using the Chat SDK. It is exposed only to allow users to create mock versions of the SDK's protocols. - public init(type: TypingSetEventType, currentlyTyping: Set, change: Change) { + public init(type: TypingSetEventType, currentlyTyping: Set, currentTypers: [TypingMember], change: Change) { self.type = type self.currentlyTyping = currentlyTyping + self.currentTypers = currentTypers self.change = change } @@ -116,6 +153,7 @@ public struct TypingSetEvent: Sendable { // swiftlint:disable:next missing_docs public var type: TypingEventType + // @spec CHA-T13a1 /** * The user claim attached to this typing event by the server. * @@ -125,7 +163,6 @@ public struct TypingSetEvent: Sendable { * The `userClaim` must persist across heartbeat events and inactivity timeouts * for a given `clientId`. */ - // @spec CHA-T13a1 public var userClaim: String? /// Memberwise initializer to create a `Change`. diff --git a/Sources/AblyChat/TypingTimerManager.swift b/Sources/AblyChat/TypingTimerManager.swift index cc64d61f..84b76c99 100644 --- a/Sources/AblyChat/TypingTimerManager.swift +++ b/Sources/AblyChat/TypingTimerManager.swift @@ -99,6 +99,13 @@ internal final class TypingTimerManager: TypingTimerMan Set(whoIsTypingState.keys) } + /// Returns the currently typing users with associated metadata. + internal func currentlyTypingMembers() -> [TypingMember] { + whoIsTypingState.map { clientID, state in + TypingMember(clientID: clientID, userClaim: state.userClaim) + } + } + /// Returns the stored `userClaim` for a given client, if any (CHA-T13a1). internal func userClaimForClient(_ clientID: String) -> String? { whoIsTypingState[clientID]?.userClaim @@ -126,6 +133,8 @@ internal protocol TypingTimerManagerProtocol { func isCurrentlyTyping(clientID: String) -> Bool /// Returns the set of client IDs that we consider to currently be typing (also referred to in the spec as the "typing set"). func currentlyTypingClientIDs() -> Set + /// Returns the currently typing users with associated metadata. + func currentlyTypingMembers() -> [TypingMember] /// Returns the stored `userClaim` for a given client, if any (CHA-T13a1). func userClaimForClient(_ clientID: String) -> String? } diff --git a/Tests/AblyChatTests/DefaultTypingTests.swift b/Tests/AblyChatTests/DefaultTypingTests.swift index 66ba4f22..47e5469d 100644 --- a/Tests/AblyChatTests/DefaultTypingTests.swift +++ b/Tests/AblyChatTests/DefaultTypingTests.swift @@ -81,6 +81,7 @@ struct DefaultTypingTests { // @specOneOf(1/2) CHA-T6a - Tests subscription receives started event // @specOneOf(1/2) CHA-T4a3 - Tests that publish has correct name and data + // @specOneOf(1/2) CHA-T6d - Tests that currentTypers is populated in started event @Test @available(iOS 16.0, tvOS 16.0, *) func subscribe_ReceivesStartedTypingEvent() async throws { @@ -94,6 +95,7 @@ struct DefaultTypingTests { TypingSetEvent( type: .setChanged, currentlyTyping: [clientId], + currentTypers: [.init(clientID: clientId)], change: .init(clientID: clientId, type: .started), ), ) @@ -103,9 +105,12 @@ struct DefaultTypingTests { #expect(typingEvent.change.type == .started) #expect(typingEvent.change.clientID == clientId) #expect(typingEvent.currentlyTyping == [clientId]) + #expect(typingEvent.currentTypers.count == 1) + #expect(typingEvent.currentTypers[0].clientID == clientId) } // @specOneOf(2/2) CHA-T6a - Tests subscription receives stopped event + // @specOneOf(2/2) CHA-T6d - Tests that currentTypers is populated in stopped event @Test @available(iOS 16.0, tvOS 16.0, *) func subscribe_ReceivesStoppedTypingEvent() async throws { @@ -119,6 +124,7 @@ struct DefaultTypingTests { TypingSetEvent( type: .setChanged, currentlyTyping: [], + currentTypers: [], change: .init(clientID: clientId, type: .stopped), ), ) @@ -128,9 +134,12 @@ struct DefaultTypingTests { #expect(typingEvent.change.type == .stopped) #expect(typingEvent.change.clientID == clientId) #expect(typingEvent.currentlyTyping.isEmpty) + #expect(typingEvent.currentTypers.isEmpty) } // @spec CHA-T9 - Tests retrieving currently typing clients + // @spec CHA-T16 - Tests current property returns typing client IDs + // @spec CHA-T18 - Tests currentTypers property returns typing members @Test @available(iOS 16.0, tvOS 16.0, *) func get_ReturnsCurrentlyTypingClients() async throws { @@ -147,9 +156,12 @@ struct DefaultTypingTests { // When let typingClients = typing.current + let typingMembers = typing.currentTypers // Then #expect(typingClients.contains("test-client")) + #expect(typingMembers.count == 1) + #expect(typingMembers[0].clientID == "test-client") } // @specOneOf(2/2) CHA-T4a3 - Tests that publish has ephemeral flag diff --git a/Tests/AblyChatTests/Helpers/Sandbox.swift b/Tests/AblyChatTests/Helpers/Sandbox.swift index 2d447ea5..beb030a1 100644 --- a/Tests/AblyChatTests/Helpers/Sandbox.swift +++ b/Tests/AblyChatTests/Helpers/Sandbox.swift @@ -1,7 +1,62 @@ +import CryptoKit import Foundation /// Provides the ``createAPIKey()`` function to create an API key for the Ably sandbox environment. enum Sandbox { + // MARK: - JWT + + /// Creates a signed Ably JWT (HS256) containing the given `userClaim` under the `ably.room.` key. + /// + /// The returned string can be passed to `ARTTokenDetails(token:)` and used via `authCallback`. + static func createJWT( + apiKey: String, + clientID: String, + roomName: String, + userClaim: String, + ttl: TimeInterval = 3600, + ) -> String { + let parts = apiKey.split(separator: ":", maxSplits: 1) + let keyName = String(parts[0]) + let keySecret = String(parts[1]) + + let header: [String: Any] = [ + "typ": "JWT", + "alg": "HS256", + "kid": keyName, + ] + + let now = Date() + let payload: [String: Any] = [ + "iat": Int(now.timeIntervalSince1970), + "exp": Int(now.timeIntervalSince1970 + ttl), + "x-ably-clientId": clientID, + "x-ably-capability": "{\"*\":[\"*\"]}", + "ably.room.\(roomName)": userClaim, + ] + + let headerData = try! JSONSerialization.data(withJSONObject: header) // swiftlint:disable:this force_try + let payloadData = try! JSONSerialization.data(withJSONObject: payload) // swiftlint:disable:this force_try + + let headerB64 = base64URLEncode(headerData) + let payloadB64 = base64URLEncode(payloadData) + + let signingInput = "\(headerB64).\(payloadB64)" + let key = SymmetricKey(data: Data(keySecret.utf8)) + let signature = HMAC.authenticationCode(for: Data(signingInput.utf8), using: key) + let signatureB64 = base64URLEncode(Data(signature)) + + return "\(headerB64).\(payloadB64).\(signatureB64)" + } + + private static func base64URLEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + // MARK: - Sandbox API key + private struct TestApp: Codable { var keys: [Key] diff --git a/Tests/AblyChatTests/IntegrationTests.swift b/Tests/AblyChatTests/IntegrationTests.swift index 876e56dd..63104b88 100644 --- a/Tests/AblyChatTests/IntegrationTests.swift +++ b/Tests/AblyChatTests/IntegrationTests.swift @@ -53,21 +53,45 @@ struct IntegrationTests { } } - private static func createSandboxRealtime(apiKey: String, loggingLabel: String) -> ARTRealtime { - let realtimeOptions = ARTClientOptions(key: apiKey) + /// Creates a sandbox `ChatClient`. When `userClaim` is provided, authenticates via a self-signed Ably JWT containing the claim (requires `roomName` and `clientID`). + private static func createSandboxChatClient( + apiKey: String, + loggingLabel: String, + clientID: String? = nil, + roomName: String? = nil, + userClaim: String? = nil, + ) -> ChatClient { + if userClaim != nil { + precondition(roomName != nil && clientID != nil, "roomName and clientID are required when userClaim is provided") + } + + let realtimeOptions: ARTClientOptions + + if let userClaim, let roomName, let clientID { + realtimeOptions = ARTClientOptions() + realtimeOptions.clientId = clientID + realtimeOptions.authCallback = { _, callback in + let jwt = Sandbox.createJWT( + apiKey: apiKey, + clientID: clientID, + roomName: roomName, + userClaim: userClaim, + ) + callback(ARTTokenDetails(token: jwt), nil) + } + } else { + realtimeOptions = ARTClientOptions(key: apiKey) + realtimeOptions.clientId = UUID().uuidString + } + realtimeOptions.environment = "sandbox" - realtimeOptions.clientId = UUID().uuidString if TestLogger.loggingEnabled { realtimeOptions.logLevel = .verbose realtimeOptions.logHandler = AblyCocoaLogger(label: loggingLabel) } - return ARTRealtime(options: realtimeOptions) - } - - private static func createSandboxChatClient(apiKey: String, loggingLabel: String) -> ChatClient { - let realtime = createSandboxRealtime(apiKey: apiKey, loggingLabel: loggingLabel) + let realtime = ARTRealtime(options: realtimeOptions) let clientOptions = TestLogger.loggingEnabled ? ChatClientOptions(logHandler: .simple(ChatLogger(label: loggingLabel)), logLevel: .trace) : nil return ChatClient(realtime: realtime, clientOptions: clientOptions) @@ -757,6 +781,153 @@ struct IntegrationTests { // (4) Check that the room was released #expect(rxRoom.status == .released) } + + /// Tests that `userClaim` is populated when using JWT auth with an `ably.room.` claim. + @Test + func userClaimFromJWT() async throws { + // MARK: - Setup + + Self.logAwait("BEFORE Sandbox.createAPIKey() (JWT test)") + let apiKey = try await Sandbox.createAPIKey() + Self.logAwait("AFTER Sandbox.createAPIKey() (JWT test)") + + let roomName = "jwt-test-room" + let expectedUserClaim = "admin" + let txClientID = UUID().uuidString + + // tx client uses JWT auth with userClaim + let txClient = Self.createSandboxChatClient( + apiKey: apiKey, + loggingLabel: "tx-jwt", + clientID: txClientID, + roomName: roomName, + userClaim: expectedUserClaim, + ) + + // rx client uses standard API key auth + let rxClient = Self.createSandboxChatClient(apiKey: apiKey, loggingLabel: "rx-jwt") + + // MARK: - Get rooms + + Self.logAwait("BEFORE txClient.rooms.get() (JWT)") + let txRoom = try await txClient.rooms.get( + named: roomName, + options: .init( + presence: .init(), + ), + ) + Self.logAwait("AFTER txClient.rooms.get() (JWT)") + + Self.logAwait("BEFORE rxClient.rooms.get() (JWT)") + let rxRoom = try await rxClient.rooms.get( + named: roomName, + options: .init( + messages: .init(rawMessageReactions: true), + presence: .init(), + ), + ) + Self.logAwait("AFTER rxClient.rooms.get() (JWT)") + + // MARK: - Attach rx room and subscribe + + Self.logAwait("BEFORE rxRoom.attach() (JWT)") + try await rxRoom.attach() + Self.logAwait("AFTER rxRoom.attach() (JWT)") + + let rxMessageSubscription = rxRoom.messages.subscribe() + let rxPresenceSubscription = rxRoom.presence.subscribe() + let rxReactionSubscription = rxRoom.reactions.subscribe() + + // MARK: - Messages: verify userClaim + + Self.logAwait("BEFORE txRoom.messages.send (JWT)") + _ = try await txRoom.messages.send(withParams: .init(text: "Hello with JWT")) + Self.logAwait("AFTER txRoom.messages.send (JWT)") + + Self.logAwait("BEFORE rxMessageSubscription.first (JWT)") + let rxMessageEvent = try #require(await rxMessageSubscription.first { @Sendable _ in true }) + Self.logAwait("AFTER rxMessageSubscription.first (JWT)") + + // @spec CHA-M2h — userClaim is populated from JWT's ably.room. claim + #expect(rxMessageEvent.message.userClaim == expectedUserClaim) + + // MARK: - Raw Message Reactions: verify userClaim + + let rxRawReactionSubscription = rxRoom.messages.reactions.subscribeRaw() + + Self.logAwait("BEFORE txRoom.messages.reactions.send (JWT)") + try await txRoom.messages.reactions.send(forMessageWithSerial: rxMessageEvent.message.serial, params: .init(name: "👍")) + Self.logAwait("AFTER txRoom.messages.reactions.send (JWT)") + + Self.logAwait("BEFORE rxRawReactionSubscription.first (JWT)") + let rxRawReactionEvent = try #require(await rxRawReactionSubscription.first { @Sendable _ in true }) + Self.logAwait("AFTER rxRawReactionSubscription.first (JWT)") + + // @spec CHA-MR7d — userClaim is populated from JWT's ably.room. claim + #expect(rxRawReactionEvent.reaction.userClaim == expectedUserClaim) + + // MARK: - Presence: verify userClaim + + Self.logAwait("BEFORE txRoom.attach() (JWT)") + try await txRoom.attach() + Self.logAwait("AFTER txRoom.attach() (JWT)") + + Self.logAwait("BEFORE txRoom.presence.enter (JWT)") + try await txRoom.presence.enter() + Self.logAwait("AFTER txRoom.presence.enter (JWT)") + + Self.logAwait("BEFORE rxPresenceSubscription.first (JWT)") + let rxPresenceEvent = try #require(await rxPresenceSubscription.first { @Sendable _ in true }) + Self.logAwait("AFTER rxPresenceSubscription.first (JWT)") + + // @spec CHA-PR6g — userClaim is populated from JWT's ably.room. claim + #expect(rxPresenceEvent.member.userClaim == expectedUserClaim) + + Self.logAwait("BEFORE txRoom.presence.leave (JWT)") + try await txRoom.presence.leave() + Self.logAwait("AFTER txRoom.presence.leave (JWT)") + + // MARK: - Room Reactions: verify userClaim + + Self.logAwait("BEFORE txRoom.reactions.send (JWT)") + try await txRoom.reactions.send(withParams: .init(name: "heart")) + Self.logAwait("AFTER txRoom.reactions.send (JWT)") + + Self.logAwait("BEFORE rxReactionSubscription.first (JWT)") + let rxReactionEvent = try #require(await rxReactionSubscription.first { @Sendable _ in true }) + Self.logAwait("AFTER rxReactionSubscription.first (JWT)") + + // @spec CHA-ER2a — userClaim is populated from JWT's ably.room. claim + #expect(rxReactionEvent.reaction.userClaim == expectedUserClaim) + + // MARK: - Typing: verify userClaim and currentTypers + + let rxTypingSubscription = rxRoom.typing.subscribe() + + Self.logAwait("BEFORE txRoom.typing.keystroke() (JWT)") + try await txRoom.typing.keystroke() + Self.logAwait("AFTER txRoom.typing.keystroke() (JWT)") + + Self.logAwait("BEFORE rxTypingSubscription.first (JWT)") + let rxTypingEvent = try #require(await rxTypingSubscription.first { @Sendable _ in true }) + Self.logAwait("AFTER rxTypingSubscription.first (JWT)") + + // @spec CHA-T13a1 — userClaim is populated from JWT's ably.room. claim + #expect(rxTypingEvent.change.userClaim == expectedUserClaim) + #expect(rxTypingEvent.currentTypers.count == 1) + #expect(rxTypingEvent.currentTypers[0].clientID == txClientID) + #expect(rxTypingEvent.currentTypers[0].userClaim == expectedUserClaim) + + // MARK: - Cleanup + + Self.logAwait("BEFORE rxRoom.detach() (JWT)") + try await rxRoom.detach() + Self.logAwait("AFTER rxRoom.detach() (JWT)") + + Self.logAwait("BEFORE rxClient.rooms.release (JWT)") + await rxClient.rooms.release(named: roomName) + Self.logAwait("AFTER rxClient.rooms.release (JWT)") + } } /// Compares two messages for equality, ignoring all properties of the messages' `version` except for `serial`. diff --git a/Tests/AblyChatTests/Mocks/MockAblyCocoaRealtime.swift b/Tests/AblyChatTests/Mocks/MockAblyCocoaRealtime.swift index 802eba78..db7e91cc 100644 --- a/Tests/AblyChatTests/Mocks/MockAblyCocoaRealtime.swift +++ b/Tests/AblyChatTests/Mocks/MockAblyCocoaRealtime.swift @@ -214,6 +214,34 @@ final class MockAblyCocoaRealtime: NSObject, RealtimeClientProtocol, @unchecked func history(_: @escaping ARTPaginatedMessagesCallback) { fatalError("Not implemented") } + + func publish(_: String?, data _: Any?, resultCallback _: ARTPublishResultCallback? = nil) { + fatalError("Not implemented") + } + + func publish(_: [ARTMessage], resultCallback _: ARTPublishResultCallback? = nil) { + fatalError("Not implemented") + } + + func update(_: ARTMessage, operation _: ARTMessageOperation?, params _: [String: ARTStringifiable]?, callback _: ARTEditResultCallback? = nil) { + fatalError("Not implemented") + } + + func delete(_: ARTMessage, operation _: ARTMessageOperation?, params _: [String: ARTStringifiable]?, callback _: ARTEditResultCallback? = nil) { + fatalError("Not implemented") + } + + func append(_: ARTMessage, operation _: ARTMessageOperation?, params _: [String: ARTStringifiable]?, callback _: ARTEditResultCallback? = nil) { + fatalError("Not implemented") + } + + func getMessageWithSerial(_: String, callback _: @escaping ARTMessageErrorCallback) { + fatalError("Not implemented") + } + + func getMessageVersions(withSerial _: String, callback _: @escaping ARTPaginatedMessagesCallback) { + fatalError("Not implemented") + } } final class Presence: RealtimePresenceProtocol { diff --git a/Tests/AblyChatTests/UserClaimTests.swift b/Tests/AblyChatTests/UserClaimTests.swift index 26f3d34c..0a351e47 100644 --- a/Tests/AblyChatTests/UserClaimTests.swift +++ b/Tests/AblyChatTests/UserClaimTests.swift @@ -36,7 +36,7 @@ struct UserClaimExtractionTests { @Test func userClaim_returnsEmptyStringForEmptyStringValue() { let extras: [String: JSONValue] = ["userClaim": .string("")] - #expect(extras.userClaim == "") + #expect(extras.userClaim?.isEmpty == true) } @Test @@ -349,6 +349,9 @@ struct TypingUserClaimTests { #expect(receivedEvents[0].change.type == .started) #expect(receivedEvents[0].change.clientID == "test-client") #expect(receivedEvents[0].change.userClaim == "admin") + #expect(receivedEvents[0].currentTypers.count == 1) + #expect(receivedEvents[0].currentTypers[0].clientID == "test-client") + #expect(receivedEvents[0].currentTypers[0].userClaim == "admin") } // @spec CHA-T13a1 - typing stopped event includes userClaim from message extras @@ -386,6 +389,7 @@ struct TypingUserClaimTests { #expect(receivedEvents[1].change.type == .stopped) #expect(receivedEvents[1].change.clientID == "test-client") #expect(receivedEvents[1].change.userClaim == "admin") + #expect(receivedEvents[1].currentTypers.isEmpty) } // @spec CHA-T13a1 - typing stopped event falls back to cached userClaim when message lacks it From 111acb452f8b5f9ef68cac62cd9fdf63147ba2d0 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 25 Feb 2026 16:56:33 +0000 Subject: [PATCH 3/6] Add .claude/settings.local.json to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 67d3f4a7..4e66902b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ DerivedData/ /node_modules /.mint + +.claude/settings.local.json \ No newline at end of file From 3cf1b65b116222006dbcfc18741c79b3c5db42b8 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 26 Feb 2026 14:50:58 +0000 Subject: [PATCH 4/6] Fix build errors: add currentTypers to MockTyping, handle .append action, fix formatting - Add missing `currentTypers` property to `MockTyping` for `Typing` protocol conformance - Add `currentTypers` parameter to all `TypingSetEvent` initializer calls in MockTyping - Handle new `.append` case in `ARTMessageAction` switch in Events.swift - Fix Prettier formatting in CHA-1243-user-claims.md Co-Authored-By: Claude Opus 4.6 --- .../AblyChatExample/Mocks/MockClients.swift | 10 ++++ Sources/AblyChat/Events.swift | 3 +- Tests/AblyChatTests/IntegrationTests.swift | 10 ++-- docs/plans/CHA-1243-user-claims.md | 52 +++++++++++-------- 4 files changed, 46 insertions(+), 29 deletions(-) diff --git a/Example/AblyChatExample/Mocks/MockClients.swift b/Example/AblyChatExample/Mocks/MockClients.swift index 02a0f381..9e87d8a9 100644 --- a/Example/AblyChatExample/Mocks/MockClients.swift +++ b/Example/AblyChatExample/Mocks/MockClients.swift @@ -404,6 +404,10 @@ class MockTyping: Typing { MockStrings.names.randomElement()!, MockStrings.names.randomElement()!, ], + currentTypers: [ + TypingMember(clientID: MockStrings.names.randomElement()!), + TypingMember(clientID: MockStrings.names.randomElement()!), + ], change: .init(clientID: MockStrings.names.randomElement()!, type: .started), ) }, @@ -416,11 +420,16 @@ class MockTyping: Typing { Set(MockStrings.names.shuffled().prefix(2)) } + var currentTypers: [TypingMember] { + MockStrings.names.shuffled().prefix(2).map { TypingMember(clientID: $0) } + } + func keystroke() async throws(ErrorInfo) { mockSubscriptions.emit( TypingSetEvent( type: .setChanged, currentlyTyping: [clientID], + currentTypers: [TypingMember(clientID: clientID)], change: .init(clientID: clientID, type: .started), ), ) @@ -431,6 +440,7 @@ class MockTyping: Typing { TypingSetEvent( type: .setChanged, currentlyTyping: [], + currentTypers: [], change: .init(clientID: clientID, type: .stopped), ), ) diff --git a/Sources/AblyChat/Events.swift b/Sources/AblyChat/Events.swift index 3fbf3843..0e704e86 100644 --- a/Sources/AblyChat/Events.swift +++ b/Sources/AblyChat/Events.swift @@ -22,7 +22,8 @@ public enum ChatMessageAction: Sendable { case .delete: .messageDelete // ignore any other actions for now (CHA-M4k11) - case .meta, + case .append, + .meta, .messageSummary: nil @unknown default: diff --git a/Tests/AblyChatTests/IntegrationTests.swift b/Tests/AblyChatTests/IntegrationTests.swift index 63104b88..7fac796a 100644 --- a/Tests/AblyChatTests/IntegrationTests.swift +++ b/Tests/AblyChatTests/IntegrationTests.swift @@ -848,7 +848,7 @@ struct IntegrationTests { let rxMessageEvent = try #require(await rxMessageSubscription.first { @Sendable _ in true }) Self.logAwait("AFTER rxMessageSubscription.first (JWT)") - // @spec CHA-M2h — userClaim is populated from JWT's ably.room. claim + // @spec CHA-M2h - userClaim is populated from JWT's ably.room. claim #expect(rxMessageEvent.message.userClaim == expectedUserClaim) // MARK: - Raw Message Reactions: verify userClaim @@ -863,7 +863,7 @@ struct IntegrationTests { let rxRawReactionEvent = try #require(await rxRawReactionSubscription.first { @Sendable _ in true }) Self.logAwait("AFTER rxRawReactionSubscription.first (JWT)") - // @spec CHA-MR7d — userClaim is populated from JWT's ably.room. claim + // @spec CHA-MR7d - userClaim is populated from JWT's ably.room. claim #expect(rxRawReactionEvent.reaction.userClaim == expectedUserClaim) // MARK: - Presence: verify userClaim @@ -880,7 +880,7 @@ struct IntegrationTests { let rxPresenceEvent = try #require(await rxPresenceSubscription.first { @Sendable _ in true }) Self.logAwait("AFTER rxPresenceSubscription.first (JWT)") - // @spec CHA-PR6g — userClaim is populated from JWT's ably.room. claim + // @spec CHA-PR6g - userClaim is populated from JWT's ably.room. claim #expect(rxPresenceEvent.member.userClaim == expectedUserClaim) Self.logAwait("BEFORE txRoom.presence.leave (JWT)") @@ -897,7 +897,7 @@ struct IntegrationTests { let rxReactionEvent = try #require(await rxReactionSubscription.first { @Sendable _ in true }) Self.logAwait("AFTER rxReactionSubscription.first (JWT)") - // @spec CHA-ER2a — userClaim is populated from JWT's ably.room. claim + // @spec CHA-ER2a - userClaim is populated from JWT's ably.room. claim #expect(rxReactionEvent.reaction.userClaim == expectedUserClaim) // MARK: - Typing: verify userClaim and currentTypers @@ -912,7 +912,7 @@ struct IntegrationTests { let rxTypingEvent = try #require(await rxTypingSubscription.first { @Sendable _ in true }) Self.logAwait("AFTER rxTypingSubscription.first (JWT)") - // @spec CHA-T13a1 — userClaim is populated from JWT's ably.room. claim + // @spec CHA-T13a1 - userClaim is populated from JWT's ably.room. claim #expect(rxTypingEvent.change.userClaim == expectedUserClaim) #expect(rxTypingEvent.currentTypers.count == 1) #expect(rxTypingEvent.currentTypers[0].clientID == txClientID) diff --git a/docs/plans/CHA-1243-user-claims.md b/docs/plans/CHA-1243-user-claims.md index a7bb6548..9dc4f031 100644 --- a/docs/plans/CHA-1243-user-claims.md +++ b/docs/plans/CHA-1243-user-claims.md @@ -6,15 +6,16 @@ The Chat specification (PR #423) adds an optional `userClaim` field across all c ### Spec Items -| Spec Item | Entity | Summary | -|-----------|--------|---------| -| CHA-M2h | Message | Optional `userClaim` on Message | -| CHA-MR7d | MessageReactionRawEvent.Reaction | Optional `userClaim` on raw reaction events | -| CHA-ER2a | RoomReaction | Optional `userClaim` on ephemeral room reactions | -| CHA-PR6g | PresenceMember | Optional `userClaim` on presence members | -| CHA-T13a1 | TypingSetEvent.Change | Optional `userClaim` on typing event changes; must persist across heartbeats and inactivity timeouts | +| Spec Item | Entity | Summary | +| --------- | -------------------------------- | ---------------------------------------------------------------------------------------------------- | +| CHA-M2h | Message | Optional `userClaim` on Message | +| CHA-MR7d | MessageReactionRawEvent.Reaction | Optional `userClaim` on raw reaction events | +| CHA-ER2a | RoomReaction | Optional `userClaim` on ephemeral room reactions | +| CHA-PR6g | PresenceMember | Optional `userClaim` on presence members | +| CHA-T13a1 | TypingSetEvent.Change | Optional `userClaim` on typing event changes; must persist across heartbeats and inactivity timeouts | All fields share these characteristics: + - Optional `String?`, read-only, server-provided - Extracted from `extras.userClaim` on the underlying Ably realtime message/annotation/presence message - Clients cannot send this field @@ -146,27 +147,32 @@ Note: `ARTAnnotation` has an `extras` property (it's an `ARTBaseMessage` subclas For each type, test: 1. **Message userClaim:** + - Realtime message with `extras.userClaim` → Message has correct `userClaim` - Realtime message without `extras.userClaim` → Message has `nil` userClaim - REST/JSON decoded message with `userClaim` → Message has correct `userClaim` - REST/JSON decoded message without `userClaim` → Message has `nil` userClaim 2. **RoomReaction userClaim:** + - Realtime reaction with `extras.userClaim` → RoomReaction has correct `userClaim` - Realtime reaction without `extras.userClaim` → RoomReaction has `nil` userClaim 3. **PresenceMember userClaim:** + - Presence message with `extras.userClaim` → PresenceMember has correct `userClaim` - Presence message without extras → PresenceMember has `nil` userClaim - Both `get()` and `subscribe()` paths 4. **TypingSetEvent.Change userClaim:** + - Typing started with `userClaim` → change includes claim - Heartbeat (repeated started) preserves existing claim when new event lacks one - Typing stopped includes claim (from message or cached) - Inactivity timeout synthetic stop includes cached claim 5. **MessageReactionRawEvent.Reaction userClaim:** + - Annotation with `extras.userClaim` → Reaction has correct `userClaim` - Annotation without extras → Reaction has `nil` userClaim @@ -192,22 +198,22 @@ At minimum, add integration tests that verify `userClaim` is `nil` when using st ## Key Files to Modify -| File | Changes | -|------|---------| -| `Sources/AblyChat/Message.swift` | Add `userClaim` property, update init, update JSON decoding | -| `Sources/AblyChat/DefaultMessages.swift` | Extract `userClaim` from extras, pass to Message | -| `Sources/AblyChat/RoomReaction.swift` | Add `userClaim` property, update init | -| `Sources/AblyChat/DefaultRoomReactions.swift` | Extract `userClaim` from extras, pass to RoomReaction | -| `Sources/AblyChat/Presence.swift` | Add `userClaim` property to PresenceMember, update init | -| `Sources/AblyChat/DefaultPresence.swift` | Extract `userClaim` from member extras | -| `Sources/AblyChat/Typing.swift` | Add `userClaim` to TypingSetEvent.Change, update init | -| `Sources/AblyChat/DefaultTyping.swift` | Extract `userClaim` from message extras, pass through | -| `Sources/AblyChat/TypingTimerManager.swift` | Track `userClaim` per client alongside timers | -| `Sources/AblyChat/MessageReaction.swift` | Add `userClaim` to Reaction, update init | -| `Sources/AblyChat/DefaultMessageReactions.swift` | Extract `userClaim` from annotation extras | -| `Sources/AblyChat/JSONValue.swift` | Add `userClaim` extraction helper on extras dictionary | -| `Tests/AblyChatTests/` | Unit tests for all above | -| `Tests/AblyChatTests/IntegrationTests.swift` | Integration tests | +| File | Changes | +| ------------------------------------------------ | ----------------------------------------------------------- | +| `Sources/AblyChat/Message.swift` | Add `userClaim` property, update init, update JSON decoding | +| `Sources/AblyChat/DefaultMessages.swift` | Extract `userClaim` from extras, pass to Message | +| `Sources/AblyChat/RoomReaction.swift` | Add `userClaim` property, update init | +| `Sources/AblyChat/DefaultRoomReactions.swift` | Extract `userClaim` from extras, pass to RoomReaction | +| `Sources/AblyChat/Presence.swift` | Add `userClaim` property to PresenceMember, update init | +| `Sources/AblyChat/DefaultPresence.swift` | Extract `userClaim` from member extras | +| `Sources/AblyChat/Typing.swift` | Add `userClaim` to TypingSetEvent.Change, update init | +| `Sources/AblyChat/DefaultTyping.swift` | Extract `userClaim` from message extras, pass through | +| `Sources/AblyChat/TypingTimerManager.swift` | Track `userClaim` per client alongside timers | +| `Sources/AblyChat/MessageReaction.swift` | Add `userClaim` to Reaction, update init | +| `Sources/AblyChat/DefaultMessageReactions.swift` | Extract `userClaim` from annotation extras | +| `Sources/AblyChat/JSONValue.swift` | Add `userClaim` extraction helper on extras dictionary | +| `Tests/AblyChatTests/` | Unit tests for all above | +| `Tests/AblyChatTests/IntegrationTests.swift` | Integration tests | --- From 1486dfcf4c9c1c8bbc62c3ed878b26a1e21373f0 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 2 Mar 2026 10:42:49 +0000 Subject: [PATCH 5/6] Fix CI: sync workspace Package.resolved and resolve spec tag conflict - Sync xcworkspace Package.resolved with root (was stale at ably-cocoa 1.2.51) - Change @specPartial to @spec for CHA-M2h to resolve conflicting tag types Co-Authored-By: Claude Opus 4.6 --- .../xcshareddata/swiftpm/Package.resolved | 14 +++++++------- Tests/AblyChatTests/UserClaimTests.swift | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved index c27d43c5..7d3b7025 100644 --- a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "82bda97c4f4daad8b8e2d9624cfc24da13604afa4325528017e4068ead79b258", + "originHash" : "83c3fe70b23fcba31ad555b2a50e59661b3d87df10dc0eca2b812fb2066e4ad9", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "4e23a5136ecaf40fa7bad9b0df6bb5be5bb2b0c7", - "version" : "1.2.51" + "revision" : "14d2661824652d0c6065dfe4dcf8c2e3781502fc", + "version" : "1.2.58" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "8db4632b0664b7272d54f7b612ddad0a18d1758f", - "version" : "0.2.0" + "revision" : "dd118432a6e023c3d2c8a051299e8081a06db036", + "version" : "1.0.0" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/delta-codec-cocoa", "state" : { - "revision" : "3ee62ea40a63996b55818d44b3f0e56d8753be88", - "version" : "1.3.3" + "revision" : "d53eec08f9443c6160d941327a6f9d8bbb93cea2", + "version" : "1.3.5" } }, { diff --git a/Tests/AblyChatTests/UserClaimTests.swift b/Tests/AblyChatTests/UserClaimTests.swift index 0a351e47..4b734a99 100644 --- a/Tests/AblyChatTests/UserClaimTests.swift +++ b/Tests/AblyChatTests/UserClaimTests.swift @@ -8,7 +8,7 @@ import Testing @MainActor struct UserClaimExtractionTests { - // @specPartial CHA-M2h - Tests extraction helper for userClaim from extras + // @spec CHA-M2h - Tests extraction helper for userClaim from extras @Test func userClaim_extractsStringValue() { let extras: [String: JSONValue] = ["userClaim": .string("admin")] From ff6b4da26efa4a54904f79e232385c797a2b113b Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 2 Mar 2026 11:21:54 +0000 Subject: [PATCH 6/6] Fix typing userClaim to match spec: always use incoming event's value Per CHA-T13a1, when a heartbeat arrives without a userClaim, the stored value must be cleared (not preserved). This matches the JS SDK behavior where the internal state is always overwritten with the incoming event's claim value. Co-Authored-By: Claude Opus 4.6 --- Sources/AblyChat/TypingTimerManager.swift | 9 ++++----- Tests/AblyChatTests/UserClaimTests.swift | 8 ++++---- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Sources/AblyChat/TypingTimerManager.swift b/Sources/AblyChat/TypingTimerManager.swift index 84b76c99..55cc8771 100644 --- a/Sources/AblyChat/TypingTimerManager.swift +++ b/Sources/AblyChat/TypingTimerManager.swift @@ -48,14 +48,13 @@ internal final class TypingTimerManager: TypingTimerMan /// Starts a CHA-T13b1 "is this person typing" timer, thus adding this clientID to the typing set. /// If the clientID is already in the typing set, this will reset the timer (CHA-T13b2). - /// (CHA-T13a1) The `userClaim` is stored and persists across heartbeat events. If a new event lacks a `userClaim`, the previously stored value is preserved. + /// (CHA-T13a1) The `userClaim` is always set to the value from the incoming event. If the event lacks a `userClaim`, the stored value is cleared to `nil`. /// The `handler` receives the userClaim that was stored for the client at the time of timer expiry. internal func startTypingTimer(for clientID: String, userClaim: String? = nil, handler: (@MainActor (_ userClaim: String?) -> Void)? = nil) { let existingState = whoIsTypingState[clientID] let timerManager = existingState?.timer ?? TimerManager(clock: clock) - // (CHA-T13a1) Preserve existing userClaim across heartbeats if new event doesn't provide one - let resolvedUserClaim = userClaim ?? existingState?.userClaim - whoIsTypingState[clientID] = TypingClientState(timer: timerManager, userClaim: resolvedUserClaim) + // (CHA-T13a1) Always use the incoming event's userClaim, even if nil — the spec requires the entry to be updated to reflect the incoming event. + whoIsTypingState[clientID] = TypingClientState(timer: timerManager, userClaim: userClaim) // (CHA-T10a1) This grace period is used to determine how long to wait, beyond the heartbeat interval, before removing a client from the typing set. This is used to prevent flickering when a user is typing and stops typing for a short period of time. See CHA-T13b1 for a detailed description of how this is used. let timerDuration = heartbeatThrottle + gracePeriod @@ -124,7 +123,7 @@ internal protocol TypingTimerManagerProtocol { func cancelHeartbeatTimer() /// Starts a CHA-T13b1 "is this person typing" timer, thus adding this clientID to the typing set. /// If the clientID is already in the typing set, this will reset the timer (CHA-T13b2). - /// (CHA-T13a1) The `userClaim` is stored and persists across heartbeat events. + /// (CHA-T13a1) The `userClaim` is always set to the incoming event's value. /// The `handler` receives the userClaim that was stored for the client at the time of timer expiry. func startTypingTimer(for clientID: String, userClaim: String?, handler: (@MainActor (_ userClaim: String?) -> Void)?) /// Per CHA-T13b4, cancels the CHA-T13b1 "is this person typing" timer, thus removing this clientID from the typing set. diff --git a/Tests/AblyChatTests/UserClaimTests.swift b/Tests/AblyChatTests/UserClaimTests.swift index 4b734a99..1f6c898b 100644 --- a/Tests/AblyChatTests/UserClaimTests.swift +++ b/Tests/AblyChatTests/UserClaimTests.swift @@ -265,10 +265,10 @@ struct TypingUserClaimTests { #expect(timerManager.userClaimForClient("client1") == "admin") } - // @spec CHA-T13a1 - userClaim persists across heartbeat events + // @spec CHA-T13a1 - userClaim is cleared when heartbeat arrives without one @Test @available(iOS 16.0, tvOS 16, *) - func typingTimerManager_preservesUserClaimOnHeartbeat() { + func typingTimerManager_clearsUserClaimOnHeartbeatWithoutClaim() { let mockClock = MockTestClock() let timerManager = createTypingTimerManager(with: mockClock) @@ -276,9 +276,9 @@ struct TypingUserClaimTests { timerManager.startTypingTimer(for: "client1", userClaim: "admin") #expect(timerManager.userClaimForClient("client1") == "admin") - // Heartbeat event without userClaim - should preserve the existing one + // Heartbeat event without userClaim - should clear the existing one per CHA-T13a1 timerManager.startTypingTimer(for: "client1", userClaim: nil) - #expect(timerManager.userClaimForClient("client1") == "admin") + #expect(timerManager.userClaimForClient("client1") == nil) } // @spec CHA-T13a1 - userClaim is updated when a new one is provided