From c89d7d8ae58d21cca943d7336db3d08a189eeb1b Mon Sep 17 00:00:00 2001 From: Umair Date: Fri, 15 Nov 2024 02:04:27 +0000 Subject: [PATCH] Spec complete for Presence in line with [1]. [1] - https://github.com/ably/specification/pull/200 --- .../xcshareddata/swiftpm/Package.resolved | 4 +- Example/AblyChatExample/ContentView.swift | 23 +- .../AblyChatExample/Mocks/MockClients.swift | 37 +-- .../AblyChatExample/Mocks/MockRealtime.swift | 4 + Package.resolved | 4 +- Sources/AblyChat/DefaultOccupancy.swift | 56 +++++ Sources/AblyChat/DefaultPresence.swift | 234 ++++++++++++++++++ Sources/AblyChat/Dependencies.swift | 15 +- Sources/AblyChat/Events.swift | 4 + Sources/AblyChat/Occupancy.swift | 1 + Sources/AblyChat/Presence.swift | 127 +++++++++- Sources/AblyChat/Room.swift | 58 ++++- Sources/AblyChat/RoomFeature.swift | 6 +- Sources/AblyChat/RoomOptions.swift | 3 + Tests/AblyChatTests/IntegrationTests.swift | 102 +++++++- .../Mocks/MockRealtimeChannel.swift | 4 + 16 files changed, 616 insertions(+), 66 deletions(-) create mode 100644 Sources/AblyChat/DefaultOccupancy.swift create mode 100644 Sources/AblyChat/DefaultPresence.swift diff --git a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9bf42bea..536e2116 100644 --- a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a296396707b7685153f4cf548f6281f483d562002fe11235f1fc3bb053be91d7", + "originHash" : "1ad2d7338668d15feccbf564582941161acd47349bfca8f34374e11c69677ae8", "pins" : [ { "identity" : "ably-cocoa", @@ -7,7 +7,7 @@ "location" : "https://github.com/ably/ably-cocoa", "state" : { "branch" : "main", - "revision" : "4856ba6a423788902a6ef680793e7f404ceb4a51" + "revision" : "f7bff4b1c941b4c7b952b9224a33674e2302e19f" } }, { diff --git a/Example/AblyChatExample/ContentView.swift b/Example/AblyChatExample/ContentView.swift index 3f32f1c5..30036b35 100644 --- a/Example/AblyChatExample/ContentView.swift +++ b/Example/AblyChatExample/ContentView.swift @@ -50,7 +50,14 @@ struct ContentView: View { private func room() async throws -> Room { let chosenChatClient: ChatClient = (mode == .mock) ? mockChatClient : liveChatClient - return try await chosenChatClient.rooms.get(roomID: roomID, options: .init(reactions: .init())) + return try await chosenChatClient.rooms.get( + roomID: roomID, + options: .init( + presence: .init(), + reactions: .init(), + occupancy: .init() + ) + ) } private var sendTitle: String { @@ -127,12 +134,12 @@ struct ContentView: View { .tryTask { try await attachRoom() } .tryTask { try await showMessages() } .tryTask { try await showReactions() } + .tryTask { try await showPresence() } + .tryTask { try await showOccupancy() } .tryTask { // NOTE: As we implement more features, move them out of the `if mode == .mock` block and into the main block just above. if mode == .mock { - try await showPresence() try await showTypings() - try await showOccupancy() try await showRoomStatus() } } @@ -185,9 +192,15 @@ struct ContentView: View { } func showPresence() async throws { - for await event in try await room().presence.subscribe(events: [.enter, .leave]) { + try await room().presence.enter(data: .init(userCustomData: ["status": .string("📱 Online")])) + + for await event in try await room().presence.subscribe(events: [.enter, .leave, .update]) { withAnimation { - messages.insert(BasicListItem(id: UUID().uuidString, title: "System", text: event.clientID + " \(event.action.displayedText)"), at: 0) + let status = event.data?.userCustomData?["status"]?.value as? String + let clientPresenceChangeMessage = "\(event.clientID) \(event.action.displayedText)" + let presenceMessage = status != nil ? "\(clientPresenceChangeMessage) with status: \(status!)" : clientPresenceChangeMessage + + messages.insert(BasicListItem(id: UUID().uuidString, title: "System", text: presenceMessage), at: 0) } } } diff --git a/Example/AblyChatExample/Mocks/MockClients.swift b/Example/AblyChatExample/Mocks/MockClients.swift index f3e374ae..9c356e54 100644 --- a/Example/AblyChatExample/Mocks/MockClients.swift +++ b/Example/AblyChatExample/Mocks/MockClients.swift @@ -277,7 +277,7 @@ actor MockPresence: Presence { MockStrings.names.shuffled().map { name in PresenceMember( clientID: name, - data: ["foo": "bar"], + data: PresenceData(userCustomData: nil), action: .present, extras: nil, updatedAt: Date() @@ -285,11 +285,11 @@ actor MockPresence: Presence { } } - func get(params _: PresenceQuery?) async throws -> [PresenceMember] { + func get(params _: PresenceQuery) async throws -> [PresenceMember] { MockStrings.names.shuffled().map { name in PresenceMember( clientID: name, - data: ["foo": "bar"], + data: PresenceData(userCustomData: nil), action: .present, extras: nil, updatedAt: Date() @@ -301,20 +301,7 @@ actor MockPresence: Presence { fatalError("Not yet implemented") } - func enter() async throws { - for subscription in mockSubscriptions { - subscription.emit( - PresenceEvent( - action: .enter, - clientID: clientID, - timestamp: Date(), - data: nil - ) - ) - } - } - - func enter(data: PresenceData) async throws { + func enter(data: PresenceData? = nil) async throws { for subscription in mockSubscriptions { subscription.emit( PresenceEvent( @@ -327,28 +314,20 @@ actor MockPresence: Presence { } } - func update() async throws { - fatalError("Not yet implemented") - } - - func update(data _: PresenceData) async throws { - fatalError("Not yet implemented") - } - - func leave() async throws { + func update(data: PresenceData? = nil) async throws { for subscription in mockSubscriptions { subscription.emit( PresenceEvent( - action: .leave, + action: .update, clientID: clientID, timestamp: Date(), - data: nil + data: data ) ) } } - func leave(data: PresenceData) async throws { + func leave(data: PresenceData? = nil) async throws { for subscription in mockSubscriptions { subscription.emit( PresenceEvent( diff --git a/Example/AblyChatExample/Mocks/MockRealtime.swift b/Example/AblyChatExample/Mocks/MockRealtime.swift index e505a0b7..a5416b67 100644 --- a/Example/AblyChatExample/Mocks/MockRealtime.swift +++ b/Example/AblyChatExample/Mocks/MockRealtime.swift @@ -36,6 +36,10 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { fatalError("Not implemented") } + var presence: ARTRealtimePresenceProtocol { + fatalError("Not implemented") + } + var errorReason: ARTErrorInfo? { fatalError("Not implemented") } diff --git a/Package.resolved b/Package.resolved index 7926dd68..8ac8a046 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f00ee2e8c80adfe8d72deb089738cdb967aeae43e71837f90d99fc602728fe45", + "originHash" : "b6d25f160b01b473629481d68d4fe734b3981fcd87079531f784c2ade3afdc4d", "pins" : [ { "identity" : "ably-cocoa", @@ -7,7 +7,7 @@ "location" : "https://github.com/ably/ably-cocoa", "state" : { "branch" : "main", - "revision" : "4856ba6a423788902a6ef680793e7f404ceb4a51" + "revision" : "f7bff4b1c941b4c7b952b9224a33674e2302e19f" } }, { diff --git a/Sources/AblyChat/DefaultOccupancy.swift b/Sources/AblyChat/DefaultOccupancy.swift new file mode 100644 index 00000000..380b6029 --- /dev/null +++ b/Sources/AblyChat/DefaultOccupancy.swift @@ -0,0 +1,56 @@ +import Ably + +internal final class DefaultOccupancy: Occupancy, EmitsDiscontinuities { + private let chatAPI: ChatAPI + private let roomID: String + private let logger: InternalLogger + public nonisolated let featureChannel: FeatureChannel + + internal nonisolated var channel: any RealtimeChannelProtocol { + featureChannel.channel + } + + internal init(featureChannel: FeatureChannel, chatAPI: ChatAPI, roomID: String, logger: InternalLogger) { + self.featureChannel = featureChannel + self.chatAPI = chatAPI + self.roomID = roomID + self.logger = logger + } + + // (CHA-04a) Users may register a listener that receives occupancy events in realtime. + // (CHA-04c) When a regular occupancy event is received on the channel (a standard PubSub occupancy event per the docs), the SDK will convert it into occupancy event format and broadcast it to subscribers. + // (CHA-04d) If an invalid occupancy event is received on the channel, it shall be dropped. + internal func subscribe(bufferingPolicy: BufferingPolicy) async -> Subscription { + logger.log(message: "Subscribing to occupancy events", level: .debug) + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + channel.subscribe(OccupancyEvents.meta.rawValue) { [logger] message in + logger.log(message: "Received occupancy message: \(message)", level: .debug) + guard let data = message.data as? [String: Any], + let metrics = data["metrics"] as? [String: Any] + else { + let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or metrics") + logger.log(message: "Error parsing occupancy message: \(error)", level: .error) + return // (CHA-04d) implies we don't throw an error + } + + let connections = metrics["connections"] as? Int ?? 0 + let presenceMembers = metrics["presenceMembers"] as? Int ?? 0 + + let occupancyEvent = OccupancyEvent(connections: connections, presenceMembers: presenceMembers) + logger.log(message: "Emitting occupancy event: \(occupancyEvent)", level: .debug) + subscription.emit(occupancyEvent) + } + return subscription + } + + // (CHA-O3) Users can request an instantaneous occupancy check via the REST API. The request is detailed here (https://sdk.ably.com/builds/ably/specification/main/chat-features/#rest-occupancy-request), with the response format being a simple occupancy event + internal func get() async throws -> OccupancyEvent { + logger.log(message: "Getting occupancy for room: \(roomID)", level: .debug) + return try await chatAPI.getOccupancy(roomId: roomID) + } + + // (CHA-O5) Users may subscribe to discontinuity events to know when there’s been a break in occupancy. Their listener will be called when a discontinuity event is triggered from the room lifecycle. For occupancy, there shouldn’t need to be user action as most channels will send occupancy updates regularly as clients churn. + internal func subscribeToDiscontinuities() async -> Subscription { + await featureChannel.subscribeToDiscontinuities() + } +} diff --git a/Sources/AblyChat/DefaultPresence.swift b/Sources/AblyChat/DefaultPresence.swift new file mode 100644 index 00000000..789c2a16 --- /dev/null +++ b/Sources/AblyChat/DefaultPresence.swift @@ -0,0 +1,234 @@ +import Ably + +@MainActor +internal final class DefaultPresence: Presence, EmitsDiscontinuities { + private let featureChannel: FeatureChannel + private let roomID: String + private let clientID: String + private let logger: InternalLogger + + internal init(featureChannel: FeatureChannel, roomID: String, clientID: String, logger: InternalLogger) { + self.roomID = roomID + self.featureChannel = featureChannel + self.clientID = clientID + self.logger = logger + } + + internal nonisolated var channel: any RealtimeChannelProtocol { + featureChannel.channel + } + + // (CHA-PR6) It must be possible to retrieve all the @Members of the presence set. The behaviour depends on the current room status, as presence operations in a Realtime Client cause implicit attaches. + internal func get() async throws -> [PresenceMember] { + logger.log(message: "Getting presence", level: .debug) + return try await withCheckedThrowingContinuation { continuation in + channel.presence.get { [processPresenceGet] members, error in + do { + let presenceMembers = try processPresenceGet(members, error) + continuation.resume(returning: presenceMembers) + } catch { + continuation.resume(throwing: error) + // processPresenceGet will log any errors + } + } + } + } + + internal func get(params: PresenceQuery) async throws -> [PresenceMember] { + logger.log(message: "Getting presence with params: \(params)", level: .debug) + return try await withCheckedThrowingContinuation { continuation in + channel.presence.get(params.asARTRealtimePresenceQuery()) { [processPresenceGet] members, error in + do { + let presenceMembers = try processPresenceGet(members, error) + continuation.resume(returning: presenceMembers) + } catch { + continuation.resume(throwing: error) + // processPresenceGet will log any errors + } + } + } + } + + // (CHA-PR5) It must be possible to query if a given clientId is in the presence set. + internal func isUserPresent(clientID: String) async throws -> Bool { + logger.log(message: "Checking if user is present with clientID: \(clientID)", level: .debug) + return try await withCheckedThrowingContinuation { continuation in + channel.presence.get(ARTRealtimePresenceQuery(clientId: clientID, connectionId: nil)) { [logger] members, error in + guard let members else { + let error = error ?? ARTErrorInfo.createUnknownError() + logger.log(message: error.message, level: .error) + continuation.resume(throwing: error) + return + } + continuation.resume(returning: !members.isEmpty) + } + } + } + + // (CHA-PR3a) Users may choose to enter presence, optionally providing custom data to enter with. The overall presence data must retain the format specified in CHA-PR2. + internal func enter(data: PresenceData? = nil) async throws { + logger.log(message: "Entering presence", level: .debug) + return try await withCheckedThrowingContinuation { continuation in + channel.presence.enterClient(clientID, data: data?.asQueryItems()) { [logger] error in + if let error { + logger.log(message: "Error entering presence: \(error)", level: .error) + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + + // (CHA-PR10a) Users may choose to update their presence data, optionally providing custom data to update with. The overall presence data must retain the format specified in CHA-PR2. + internal func update(data: PresenceData? = nil) async throws { + logger.log(message: "Updating presence", level: .debug) + return try await withCheckedThrowingContinuation { continuation in + channel.presence.update(data?.asQueryItems()) { [logger] error in + if let error { + logger.log(message: "Error updating presence: \(error)", level: .error) + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + + // (CHA-PR4a) Users may choose to leave presence, which results in them being removed from the Realtime presence set. + internal func leave(data: PresenceData? = nil) async throws { + logger.log(message: "Leaving presence", level: .debug) + return try await withCheckedThrowingContinuation { continuation in + channel.presence.leave(data?.asQueryItems()) { [logger] error in + if let error { + logger.log(message: "Error leaving presence: \(error)", level: .error) + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + + // (CHA-PR7a) Users may provide a listener to subscribe to all presence events in a room. + // (CHA-PR7b) Users may provide a listener and a list of selected presence events, to subscribe to just those events in a room. + internal func subscribe(event: PresenceEventType) async -> Subscription { + logger.log(message: "Subscribing to presence events", level: .debug) + let subscription = Subscription(bufferingPolicy: .unbounded) + channel.presence.subscribe(event.toARTPresenceAction()) { [processPresenceSubscribe, logger] message in + logger.log(message: "Received presence message: \(message)", level: .debug) + Task { + // processPresenceSubscribe is logging so we don't need to log here + let presenceEvent = try processPresenceSubscribe(message, event) + subscription.emit(presenceEvent) + } + } + return subscription + } + + internal func subscribe(events: [PresenceEventType]) async -> Subscription { + logger.log(message: "Subscribing to presence events", level: .debug) + let subscription = Subscription(bufferingPolicy: .unbounded) + for event in events { + channel.presence.subscribe(event.toARTPresenceAction()) { [processPresenceSubscribe, logger] message in + logger.log(message: "Received presence message: \(message)", level: .debug) + Task { + let presenceEvent = try processPresenceSubscribe(message, event) + subscription.emit(presenceEvent) + } + } + } + return subscription + } + + // (CHA-PR8) Users may subscribe to discontinuity events to know when there’s been a break in presence. Their listener will be called when a discontinuity event is triggered from the room lifecycle. For presence, there shouldn’t need to be user action as the underlying core SDK will heal the presence set. + internal func subscribeToDiscontinuities() async -> Subscription { + await featureChannel.subscribeToDiscontinuities() + } + + private func decodePresenceData(from data: Any?) -> PresenceData? { + guard let userData = data as? [String: Any] else { + return nil + } + + do { + let jsonData = try JSONSerialization.data(withJSONObject: userData, options: []) + let presenceData = try JSONDecoder().decode(PresenceData.self, from: jsonData) + return presenceData + } catch { + print("Failed to decode PresenceData: \(error)") + logger.log(message: "Failed to decode PresenceData: \(error)", level: .error) + return nil + } + } + + private func processPresenceGet(members: [ARTPresenceMessage]?, error: ARTErrorInfo?) throws -> [PresenceMember] { + guard let members else { + let error = error ?? ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or text") + logger.log(message: error.message, level: .error) + throw error + } + let presenceMembers = try members.map { member in + guard let data = member.data as? [String: Any] else { + let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data") + logger.log(message: error.message, level: .error) + throw error + } + + guard let clientID = member.clientId else { + let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId") + logger.log(message: error.message, level: .error) + throw error + } + + guard let timestamp = member.timestamp else { + let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without timestamp") + logger.log(message: error.message, level: .error) + throw error + } + + let userCustomData = decodePresenceData(from: data) + + // Seems like we want to just forward on `extras` from the cocoa SDK but that is an `ARTJsonCompatible` type which is not `Sendable`... currently just converting this to a `Sendable` type (`String`) until we know what to do with this. + let extras = member.extras?.toJSONString() + + let presenceMember = PresenceMember( + clientID: clientID, + data: userCustomData ?? .init(), + action: PresenceMember.Action(from: member.action), + extras: extras, + updatedAt: timestamp + ) + + logger.log(message: "Returning presence member: \(presenceMember)", level: .debug) + return presenceMember + } + return presenceMembers + } + + private func processPresenceSubscribe(_ message: ARTPresenceMessage, for event: PresenceEventType) throws -> PresenceEvent { + guard let clientID = message.clientId else { + let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId") + logger.log(message: error.message, level: .error) + throw error + } + + guard let timestamp = message.timestamp else { + let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without timestamp") + logger.log(message: error.message, level: .error) + throw error + } + + let userCustomDataDecoded = decodePresenceData(from: message.data) + + let presenceEvent = PresenceEvent( + action: event, + clientID: clientID, + timestamp: timestamp, + data: userCustomDataDecoded ?? .init() + ) + + logger.log(message: "Returning presence event: \(presenceEvent)", level: .debug) + return presenceEvent + } +} diff --git a/Sources/AblyChat/Dependencies.swift b/Sources/AblyChat/Dependencies.swift index 980a0fcd..38533818 100644 --- a/Sources/AblyChat/Dependencies.swift +++ b/Sources/AblyChat/Dependencies.swift @@ -24,14 +24,21 @@ public protocol RealtimeChannelProtocol: ARTRealtimeChannelProtocol, Sendable {} internal extension RealtimeClientProtocol { // Function to get the channel with merged options func getChannel(_ name: String, opts: ARTRealtimeChannelOptions? = nil) -> any RealtimeChannelProtocol { - // Merge opts and defaultChannelOptions - let resolvedOptions = opts ?? ARTRealtimeChannelOptions() + // Create a new instance of ARTRealtimeChannelOptions if opts is nil + let resolvedOptions = ARTRealtimeChannelOptions() - // Merge params if available, using defaultChannelOptions as fallback - resolvedOptions.params = opts?.params?.merging( + // Merge params if available, using opts first, then defaultChannelOptions as fallback + resolvedOptions.params = (opts?.params ?? [:]).merging( defaultChannelOptions.params ?? [:] ) { _, new in new } + // Apply other options from `opts` if necessary + if let customOpts = opts { + resolvedOptions.modes = customOpts.modes + resolvedOptions.cipher = customOpts.cipher + resolvedOptions.attachOnSubscribe = customOpts.attachOnSubscribe + } + // Return the resolved channel return channels.get(name, options: resolvedOptions) } diff --git a/Sources/AblyChat/Events.swift b/Sources/AblyChat/Events.swift index 73141dee..31f27c29 100644 --- a/Sources/AblyChat/Events.swift +++ b/Sources/AblyChat/Events.swift @@ -5,3 +5,7 @@ internal enum MessageEvent: String { internal enum RoomReactionEvents: String { case reaction = "roomReaction" } + +internal enum OccupancyEvents: String { + case meta = "[meta]occupancy" +} diff --git a/Sources/AblyChat/Occupancy.swift b/Sources/AblyChat/Occupancy.swift index 53d0d192..7ed17f13 100644 --- a/Sources/AblyChat/Occupancy.swift +++ b/Sources/AblyChat/Occupancy.swift @@ -6,6 +6,7 @@ public protocol Occupancy: AnyObject, Sendable, EmitsDiscontinuities { var channel: RealtimeChannelProtocol { get } } +// (CHA-O2) The occupancy event format is shown here (https://sdk.ably.com/builds/ably/specification/main/chat-features/#chat-structs-occupancy-event) public struct OccupancyEvent: Sendable, Encodable, Decodable { public var connections: Int public var presenceMembers: Int diff --git a/Sources/AblyChat/Presence.swift b/Sources/AblyChat/Presence.swift index d83a07c5..7c58ba88 100644 --- a/Sources/AblyChat/Presence.swift +++ b/Sources/AblyChat/Presence.swift @@ -1,18 +1,87 @@ import Ably // TODO: (https://github.com/ably-labs/ably-chat-swift/issues/13): try to improve this type -public typealias PresenceData = any Sendable +public enum PresenceCustomData: Sendable, Codable, Equatable { + case string(String) + case number(Int) // Changed from NSNumber to Int to conform to Codable. Address in linked issue above. + case bool(Bool) + case null + + public var value: Any? { + switch self { + case let .string(value): + value + case let .number(value): + value + case let .bool(value): + value + case .null: + nil + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Int.self) { + self = .number(value) + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else { + self = .null + } + } +} + +public typealias UserCustomData = [String: PresenceCustomData] + +// (CHA-PR2a) The presence data format is a JSON object as described below. Customers may specify content of an arbitrary type to be placed in the userCustomData field. +public struct PresenceData: Codable, Sendable { + public var userCustomData: UserCustomData? + + public init(userCustomData: UserCustomData? = nil) { + self.userCustomData = userCustomData + } +} + +internal extension PresenceData { + func asQueryItems() -> [String: Any] { + // Return an empty userCustomData string if no custom data is available + guard let userCustomData else { + return ["userCustomData": ""] + } + + // Create a dictionary for userCustomData + var userCustomDataDict: [String: Any] = [:] + + // Iterate over the custom data and handle different PresenceCustomData cases + for (key, value) in userCustomData { + switch value { + case let .string(stringValue): + userCustomDataDict[key] = stringValue + case let .number(numberValue): + userCustomDataDict[key] = numberValue + case let .bool(boolValue): + userCustomDataDict[key] = boolValue + case .null: + userCustomDataDict[key] = NSNull() // Use NSNull to represent null in the dictionary + } + } + + // Return the final dictionary + return ["userCustomData": userCustomDataDict] + } +} public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { func get() async throws -> [PresenceMember] - func get(params: PresenceQuery?) async throws -> [PresenceMember] + func get(params: PresenceQuery) async throws -> [PresenceMember] func isUserPresent(clientID: String) async throws -> Bool - func enter() async throws - func enter(data: PresenceData) async throws - func update() async throws - func update(data: PresenceData) async throws - func leave() async throws - func leave(data: PresenceData) async throws + func enter(data: PresenceData?) async throws + func update(data: PresenceData?) async throws + func leave(data: PresenceData?) async throws func subscribe(event: PresenceEventType) async -> Subscription func subscribe(events: [PresenceEventType]) async -> Subscription } @@ -23,6 +92,26 @@ public struct PresenceMember: Sendable { case enter case leave case update + case absent + case unknown + + internal init(from action: ARTPresenceAction) { + switch action { + case .present: + self = .present + case .enter: + self = .enter + case .leave: + self = .leave + case .update: + self = .update + case .absent: + self = .absent + @unknown default: + self = .unknown + print("Unknown presence action encountered: \(action)") + } + } } public init(clientID: String, data: PresenceData, action: PresenceMember.Action, extras: (any Sendable)?, updatedAt: Date) { @@ -46,6 +135,19 @@ public enum PresenceEventType: Sendable { case leave case update case present + + internal func toARTPresenceAction() -> ARTPresenceAction { + switch self { + case .present: + .present + case .enter: + .enter + case .leave: + .leave + case .update: + .update + } + } } public struct PresenceEvent: Sendable { @@ -81,4 +183,13 @@ public struct PresenceQuery: Sendable { self.connectionID = connectionID self.waitForSync = waitForSync } + + internal func asARTRealtimePresenceQuery() -> ARTRealtimePresenceQuery { + let query = ARTRealtimePresenceQuery() + query.limit = UInt(limit) + query.clientId = clientID + query.connectionId = connectionID + query.waitForSync = waitForSync + return query + } } diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index 99653346..b0c6b6f5 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -62,6 +62,8 @@ internal actor DefaultRoom public nonisolated let messages: any Messages private let _reactions: (any RoomReactions)? + private let _presence: (any Presence)? + private let _occupancy: (any Occupancy)? // Exposed for testing. private nonisolated let realtime: RealtimeClient @@ -82,7 +84,7 @@ internal actor DefaultRoom throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.") } - let featureChannels = Self.createFeatureChannels(roomID: roomID, realtime: realtime) + let featureChannels = Self.createFeatureChannels(roomID: roomID, roomOptions: options, realtime: realtime) channels = featureChannels.mapValues(\.channel) let contributors = featureChannels.values.map(\.contributor) @@ -106,11 +108,48 @@ internal actor DefaultRoom roomID: roomID, logger: logger ) : nil + + _presence = options.presence != nil ? await DefaultPresence( + featureChannel: featureChannels[.presence]!, + roomID: roomID, + clientID: clientId, + logger: logger + ) : nil + + _occupancy = options.occupancy != nil ? DefaultOccupancy( + featureChannel: featureChannels[.occupancy]!, + chatAPI: chatAPI, + roomID: roomID, + logger: logger + ) : nil } - private static func createFeatureChannels(roomID: String, realtime: RealtimeClient) -> [RoomFeature: DefaultFeatureChannel] { - .init(uniqueKeysWithValues: [RoomFeature.messages, RoomFeature.reactions].map { feature in - let channel = realtime.getChannel(feature.channelNameForRoomID(roomID)) + private static func createFeatureChannels(roomID: String, roomOptions: RoomOptions, realtime: RealtimeClient) -> [RoomFeature: DefaultFeatureChannel] { + .init(uniqueKeysWithValues: [ + RoomFeature.messages, + RoomFeature.reactions, + RoomFeature.presence, + RoomFeature.occupancy, + ].map { feature in + let channelOptions = ARTRealtimeChannelOptions() + + // channel setup for presence and occupancy + if feature == .presence { + let channelOptions = ARTRealtimeChannelOptions() + let presenceOptions = roomOptions.presence + + if presenceOptions?.enter ?? false { + channelOptions.modes.insert(.presence) + } + + if presenceOptions?.subscribe ?? false { + channelOptions.modes.insert(.presenceSubscribe) + } + } else if feature == .occupancy { + channelOptions.params = ["occupancy": "metrics"] + } + + let channel = realtime.getChannel(feature.channelNameForRoomID(roomID), opts: channelOptions) let contributor = DefaultRoomLifecycleContributor(channel: .init(underlyingChannel: channel), feature: feature) return (feature, .init(channel: channel, contributor: contributor)) @@ -118,14 +157,16 @@ internal actor DefaultRoom } public nonisolated var presence: any Presence { - fatalError("Not yet implemented") + guard let _presence else { + fatalError("Presence is not enabled for this room") + } + return _presence } public nonisolated var reactions: any RoomReactions { guard let _reactions else { fatalError("Reactions are not enabled for this room") } - return _reactions } @@ -134,7 +175,10 @@ internal actor DefaultRoom } public nonisolated var occupancy: any Occupancy { - fatalError("Not yet implemented") + guard let _occupancy else { + fatalError("Occupancy is not enabled for this room") + } + return _occupancy } public func attach() async throws { diff --git a/Sources/AblyChat/RoomFeature.swift b/Sources/AblyChat/RoomFeature.swift index d630c3e2..6e58769d 100644 --- a/Sources/AblyChat/RoomFeature.swift +++ b/Sources/AblyChat/RoomFeature.swift @@ -14,13 +14,15 @@ internal enum RoomFeature { private var channelNameSuffix: String { switch self { - case .messages: + case .messages, .presence, .occupancy: // (CHA-M1) Chat messages for a Room are sent on a corresponding realtime channel ::$chat::$chatMessages. For example, if your room id is my-room then the messages channel will be my-room::$chat::$chatMessages. + // (CHA-PR1) Presence for a Room is exposed on the realtime channel used for chat messages, in the format ::$chat::$chatMessages. For example, if your room id is my-room then the presence channel will be my-room::$chat::$chatMessages. + // (CHA-O1) Occupancy for a room is exposed on the realtime channel used for chat messages, in the format ::$chat::$chatMessages. For example, if your room id is my-room then the presence channel will be my-room::$chat::$chatMessages. "chatMessages" case .reactions: // (CHA-ER1) Reactions for a Room are sent on a corresponding realtime channel ::$chat::$reactions. For example, if your room id is my-room then the reactions channel will be my-room::$chat::$reactions. "reactions" - case .typing, .presence, .occupancy: + case .typing: // We’ll add these, with reference to the relevant spec points, as we implement these features fatalError("Don’t know channel name suffix for room feature \(self)") } diff --git a/Sources/AblyChat/RoomOptions.swift b/Sources/AblyChat/RoomOptions.swift index ca7b5973..75ece998 100644 --- a/Sources/AblyChat/RoomOptions.swift +++ b/Sources/AblyChat/RoomOptions.swift @@ -14,6 +14,9 @@ public struct RoomOptions: Sendable, Equatable { } } +// (CHA-PR9) Users may configure their presence options via the RoomOptions provided at room configuration time. +// (CHA-PR9a) Setting enter to false prevents the user from entering presence by means of the ChannelMode on the underlying realtime channel. Entering presence will result in an error. The default is true. +// (CHA-PR9b) Setting subscribe to false prevents the user from subscribing to presence by means of the ChannelMode on the underlying realtime channel. This does not prevent them from receiving their own presence messages, but they will not receive them from others. The default is true. public struct PresenceOptions: Sendable, Equatable { public var enter = true public var subscribe = true diff --git a/Tests/AblyChatTests/IntegrationTests.swift b/Tests/AblyChatTests/IntegrationTests.swift index 3fffbfc1..436d3791 100644 --- a/Tests/AblyChatTests/IntegrationTests.swift +++ b/Tests/AblyChatTests/IntegrationTests.swift @@ -32,8 +32,22 @@ struct IntegrationTests { // (2) Fetch a room let roomID = "basketball" - let txRoom = try await txClient.rooms.get(roomID: roomID, options: .init(reactions: .init())) - let rxRoom = try await rxClient.rooms.get(roomID: roomID, options: .init(reactions: .init())) + let txRoom = try await txClient.rooms.get( + roomID: roomID, + options: .init( + presence: .init(), + reactions: .init(), + occupancy: .init() + ) + ) + let rxRoom = try await rxClient.rooms.get( + roomID: roomID, + options: .init( + presence: .init(), + reactions: .init(), + occupancy: .init() + ) + ) // (3) Subscribe to room status let rxRoomStatusSubscription = await rxRoom.onStatusChange(bufferingPolicy: .unbounded) @@ -82,25 +96,99 @@ struct IntegrationTests { let rxReactionFromSubscription = try #require(await rxReactionSubscription.first { _ in true }) #expect(rxReactionFromSubscription.type == "heart") + // 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. + try await Task.sleep(nanoseconds: 2_000_000_000) + + // (12) Get current occupancy + let currentOccupancy = try await rxRoom.occupancy.get() + #expect(currentOccupancy.connections != 0) // this flucuates dependant on the number of clients connected e.g. simulators running the test, hence why checking for non-zero + #expect(currentOccupancy.presenceMembers == 0) // not yet entered presence + + // (13) Subscribe to occupancy + let rxOccupancySubscription = await rxRoom.occupancy.subscribe(bufferingPolicy: .unbounded) + + // (14) Enter presence on the other client and check that we receive the updated occupancy on the subscription + try await txRoom.presence.enter(data: nil) + + // It can take a moment for the occupancy to update from the clients entering presence above, so we’ll wait 2 seconds here. + try await Task.sleep(nanoseconds: 2_000_000_000) + + // (15) Check that we received an updated presence count when getting the occupancy + let updatedCurrentOccupancy = try await rxRoom.occupancy.get() + #expect(updatedCurrentOccupancy.presenceMembers == 1) // 1 for txClient entering presence + + // (16) Check that we received an updated presence count on the subscription + let rxOccupancyEventFromSubscription = try #require(await rxOccupancySubscription.first { _ in true }) + + #expect(rxOccupancyEventFromSubscription.presenceMembers == 1) // 1 for txClient entering presence + + try await txRoom.presence.leave(data: nil) + + // It can take a moment for the occupancy to update from the clients leaving presence above, so we’ll wait 2 seconds here. Important for the occupancy tests below. + try await Task.sleep(nanoseconds: 2_000_000_000) + + // MARK: - Presence + + // (17) Subscribe to presence + let rxPresenceSubscription = await rxRoom.presence.subscribe(events: [.enter, .leave, .update]) + + // (18) Send `.enter` presence event with custom data on the other client and check that we receive it on the subscription + try await txRoom.presence.enter(data: .init(userCustomData: ["randomData": .string("randomValue")])) + let rxPresenceEnterTxEvent = try #require(await rxPresenceSubscription.first { _ in true }) + #expect(rxPresenceEnterTxEvent.action == .enter) + #expect(rxPresenceEnterTxEvent.data?.userCustomData?["randomData"]?.value as? String == "randomValue") + + // (19) Send `.update` presence event with custom data on the other client and check that we receive it on the subscription + try await txRoom.presence.update(data: .init(userCustomData: ["randomData": .string("randomValue")])) + let rxPresenceUpdateTxEvent = try #require(await rxPresenceSubscription.first { _ in true }) + #expect(rxPresenceUpdateTxEvent.action == .update) + #expect(rxPresenceUpdateTxEvent.data?.userCustomData?["randomData"]?.value as? String == "randomValue") + + // (20) Send `.leave` presence event with custom data on the other client and check that we receive it on the subscription + try await txRoom.presence.leave(data: .init(userCustomData: ["randomData": .string("randomValue")])) + let rxPresenceLeaveTxEvent = try #require(await rxPresenceSubscription.first { _ in true }) + #expect(rxPresenceLeaveTxEvent.action == .leave) + #expect(rxPresenceLeaveTxEvent.data?.userCustomData?["randomData"]?.value as? String == "randomValue") + + // (21) Send `.enter` presence event with custom data on our client and check that we receive it on the subscription + try await txRoom.presence.enter(data: .init(userCustomData: ["randomData": .string("randomValue")])) + let rxPresenceEnterRxEvent = try #require(await rxPresenceSubscription.first { _ in true }) + #expect(rxPresenceEnterRxEvent.action == .enter) + #expect(rxPresenceEnterRxEvent.data?.userCustomData?["randomData"]?.value as? String == "randomValue") + + // (22) Send `.update` presence event with custom data on our client and check that we receive it on the subscription + try await txRoom.presence.update(data: .init(userCustomData: ["randomData": .string("randomValue")])) + let rxPresenceUpdateRxEvent = try #require(await rxPresenceSubscription.first { _ in true }) + #expect(rxPresenceUpdateRxEvent.action == .update) + #expect(rxPresenceUpdateRxEvent.data?.userCustomData?["randomData"]?.value as? String == "randomValue") + + // (23) Send `.leave` presence event with custom data on our client and check that we receive it on the subscription + try await txRoom.presence.leave(data: .init(userCustomData: ["randomData": .string("randomValue")])) + let rxPresenceLeaveRxEvent = try #require(await rxPresenceSubscription.first { _ in true }) + #expect(rxPresenceLeaveRxEvent.action == .leave) + #expect(rxPresenceLeaveRxEvent.data?.userCustomData?["randomData"]?.value as? String == "randomValue") + // MARK: - Detach - // (12) Detach the room + // (24) Detach the room try await rxRoom.detach() - // (13) Check that we received a DETACHED status change as a result of detaching the room + // (25) Check that we received a DETACHED status change as a result of detaching the room _ = try #require(await rxRoomStatusSubscription.first { $0.current == .detached }) #expect(await rxRoom.status == .detached) // MARK: - Release - // (14) Release the room + // (26) Release the room try await rxClient.rooms.release(roomID: roomID) - // (15) Check that we received a RELEASED status change as a result of releasing the room + // (27) Check that we received a RELEASED status change as a result of releasing the room _ = try #require(await rxRoomStatusSubscription.first { $0.current == .released }) #expect(await rxRoom.status == .released) - // (16) Fetch the room we just released and check it’s a new object + // (28) Fetch the room we just released and check it’s a new object let postReleaseRxRoom = try await rxClient.rooms.get(roomID: roomID, options: .init()) #expect(postReleaseRxRoom !== rxRoom) } diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift index 2d2c3f3c..d84900dd 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift @@ -2,6 +2,10 @@ import Ably import AblyChat final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { + var presence: ARTRealtimePresenceProtocol { + fatalError("Not implemented") + } + private let attachSerial: String? private let channelSerial: String? private let _name: String?