Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ DerivedData/

/node_modules
/.mint

.claude/settings.local.json
14 changes: 7 additions & 7 deletions AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions Example/AblyChatExample/Mocks/MockClients.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
},
Expand All @@ -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),
),
)
Expand All @@ -431,6 +440,7 @@ class MockTyping: Typing {
TypingSetEvent(
type: .setChanged,
currentlyTyping: [],
currentTypers: [],
change: .init(clientID: clientID, type: .stopped),
),
)
Expand Down
14 changes: 7 additions & 7 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 8 additions & 0 deletions Sources/AblyChat/DefaultMessageReactions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ internal final class DefaultMessageReactions<Realtime: InternalRealtimeClientPro

let annotationClientID = annotation.clientId ?? "" // CHA-MR7b3

// CHA-MR7d: Extract userClaim from annotation extras
let extras = if let ablyCocoaExtras = annotation.extras {
JSONValue.objectFromAblyCocoaExtras(ablyCocoaExtras)
} else {
[String: JSONValue]()
}

let reactionEvent = MessageReactionRawEvent(
type: reactionEventType,
// TODO: This is just a fallback value until ably-cocoa fixes the nullability of ARTAnnotation.timestamp. Remove in https://github.com/ably/ably-chat-swift/issues/395
Expand All @@ -127,6 +134,7 @@ internal final class DefaultMessageReactions<Realtime: InternalRealtimeClientPro
messageSerial: annotation.messageSerial,
count: annotation.count?.intValue ?? (annotation.action == .create && reactionType == .multiple ? 1 : nil),
clientID: annotationClientID,
userClaim: extras.userClaim, // CHA-MR7d
),
)

Expand Down
2 changes: 2 additions & 0 deletions Sources/AblyChat/DefaultMessages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ internal final class DefaultMessages<Realtime: InternalRealtimeClientProtocol>:
}

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
Expand All @@ -82,6 +83,7 @@ internal final class DefaultMessages<Realtime: InternalRealtimeClientProtocol>:
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)
Expand Down
2 changes: 2 additions & 0 deletions Sources/AblyChat/DefaultPresence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions Sources/AblyChat/DefaultRoomReactions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,16 @@ 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 ?? [:],
headers: dto?.headers ?? [:],
createdAt: message.timestamp ?? Date(), // CHA-ER4e4
clientID: messageClientID,
isSelf: messageClientID == realtime.clientId,
userClaim: userClaim, // CHA-ER2a
)

let event = RoomReactionEvent(type: .reaction, reaction: reaction)
Expand Down
34 changes: 30 additions & 4 deletions Sources/AblyChat/DefaultTyping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,32 @@ 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),
currentTypers: typingTimerManager.currentlyTypingMembers(),
change: .init(clientID: messageClientID, type: .stopped, userClaim: expiredUserClaim),
),
)
}
Expand All @@ -68,7 +79,8 @@ internal final class DefaultTyping: Typing {
TypingSetEvent(
type: .setChanged,
currentlyTyping: typingTimerManager.currentlyTypingClientIDs(),
change: .init(clientID: messageClientID, type: .started),
currentTypers: typingTimerManager.currentlyTypingMembers(),
change: .init(clientID: messageClientID, type: .started, userClaim: typingTimerManager.userClaimForClient(messageClientID)),
),
)
}
Expand All @@ -81,6 +93,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.
Expand All @@ -91,7 +111,8 @@ internal final class DefaultTyping: Typing {
TypingSetEvent(
type: .setChanged,
currentlyTyping: typingTimerManager.currentlyTypingClientIDs(),
change: .init(clientID: messageClientID, type: .stopped),
currentTypers: typingTimerManager.currentlyTypingMembers(),
change: .init(clientID: messageClientID, type: .stopped, userClaim: userClaim),
),
)
}
Expand All @@ -113,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
Expand Down
3 changes: 2 additions & 1 deletion Sources/AblyChat/Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions Sources/AblyChat/JSONValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
13 changes: 12 additions & 1 deletion Sources/AblyChat/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,19 @@ 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.<roomName>` claim. This is a read-only, server-provided value.
*/
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
Expand All @@ -97,6 +106,7 @@ public struct Message: Sendable, Equatable {
self.version = version
self.timestamp = timestamp
self.reactions = reactions
self.userClaim = userClaim
}

/**
Expand Down Expand Up @@ -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
)
}
}
Expand Down
12 changes: 11 additions & 1 deletion Sources/AblyChat/MessageReaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -280,15 +280,25 @@ 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.<roomName>` claim. This is a read-only, server-provided value.
*/
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
}
}

Expand Down
12 changes: 11 additions & 1 deletion Sources/AblyChat/Presence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand All @@ -180,6 +181,15 @@ public struct PresenceMember: Sendable {
public var extras: [String: JSONValue]?
// 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.<roomName>` claim. This is a read-only, server-provided value.
*/
public var userClaim: String?
}

/**
Expand Down
Loading
Loading