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..0be6f349 100644 --- a/Example/AblyChatExample/ContentView.swift +++ b/Example/AblyChatExample/ContentView.swift @@ -50,7 +50,13 @@ 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() + ) + ) } private var sendTitle: String { @@ -127,10 +133,10 @@ struct ContentView: View { .tryTask { try await attachRoom() } .tryTask { try await showMessages() } .tryTask { try await showReactions() } + .tryTask { try await showPresence() } .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 +191,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/DefaultPresence.swift b/Sources/AblyChat/DefaultPresence.swift new file mode 100644 index 00000000..febf452f --- /dev/null +++ b/Sources/AblyChat/DefaultPresence.swift @@ -0,0 +1,178 @@ +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 + } + + internal func get() async throws -> [PresenceMember] { + try await withCheckedThrowingContinuation { continuation in + channel.presence.get { [processPresenceGet] members, error in + Task { + try processPresenceGet(continuation, members, error) + } + } + } + } + + internal func get(params: PresenceQuery) async throws -> [PresenceMember] { + try await withCheckedThrowingContinuation { continuation in + channel.presence.get(params.asARTRealtimePresenceQuery()) { [processPresenceGet] members, error in + Task { + try processPresenceGet(continuation, members, error) + } + } + } + } + + internal func isUserPresent(clientID: String) async throws -> Bool { + try await withCheckedThrowingContinuation { continuation in + channel.presence.get(ARTRealtimePresenceQuery(clientId: clientID, connectionId: nil)) { members, error in + Task { + guard let members else { + throw error ?? ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or text") + } + continuation.resume(returning: !members.isEmpty) + } + } + } + } + + internal func enter(data: PresenceData? = nil) async throws { + channel.presence.enterClient(clientID, data: data?.asQueryItems()) { error in + Task { + if let error { + throw error + } + } + } + } + + internal func update(data: PresenceData? = nil) async throws { + channel.presence.update(data?.asQueryItems()) { error in + Task { + if let error { + throw error + } + } + } + } + + internal func leave(data: PresenceData? = nil) async throws { + channel.presence.leave(data?.asQueryItems()) { error in + Task { + if let error { + throw error + } + } + } + } + + internal func subscribe(event: PresenceEventType) async -> Subscription { + let subscription = Subscription(bufferingPolicy: .unbounded) + channel.presence.subscribe(event.toARTPresenceAction()) { [processPresenceSubscribe] message in + Task { + let presenceEvent = try processPresenceSubscribe(message, event) + subscription.emit(presenceEvent) + } + } + return subscription + } + + internal func subscribe(events: [PresenceEventType]) async -> Subscription { + let subscription = Subscription(bufferingPolicy: .unbounded) + for event in events { + channel.presence.subscribe(event.toARTPresenceAction()) { [processPresenceSubscribe] message in + Task { + let presenceEvent = try processPresenceSubscribe(message, event) + subscription.emit(presenceEvent) + } + } + } + return subscription + } + + 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)") + return nil + } + } + + private func processPresenceGet(continuation: CheckedContinuation<[PresenceMember], any Error>, members: [ARTPresenceMessage]?, error: ARTErrorInfo?) throws { + guard let members else { + throw error ?? ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or text") + } + let presenceMembers = try members.map { member in + guard let data = member.data as? [String: Any] else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data") + } + + guard let clientID = member.clientId else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId") + } + + guard let timestamp = member.timestamp else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without timestamp") + } + + let userCustomData = data["userCustomData"] as? PresenceData + + // 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() + + return PresenceMember( + clientID: clientID, + data: userCustomData ?? .init(), + action: PresenceMember.Action(from: member.action), + extras: extras, + updatedAt: timestamp + ) + } + continuation.resume(returning: presenceMembers) + } + + private func processPresenceSubscribe(_ message: ARTPresenceMessage, for event: PresenceEventType) throws -> PresenceEvent { + guard let clientID = message.clientId else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId") + } + + guard let timestamp = message.timestamp else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without timestamp") + } + + let userCustomDataDecoded = decodePresenceData(from: message.data) + + return PresenceEvent( + action: event, + clientID: clientID, + timestamp: timestamp, + data: userCustomDataDecoded ?? .init() + ) + } +} 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/Presence.swift b/Sources/AblyChat/Presence.swift index d83a07c5..c487fff1 100644 --- a/Sources/AblyChat/Presence.swift +++ b/Sources/AblyChat/Presence.swift @@ -1,18 +1,85 @@ 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] + +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 +90,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 +133,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 +181,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..2d908edf 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -62,6 +62,7 @@ internal actor DefaultRoom public nonisolated let messages: any Messages private let _reactions: (any RoomReactions)? + private let _presence: (any Presence)? // Exposed for testing. private nonisolated let realtime: RealtimeClient @@ -82,7 +83,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,26 +107,54 @@ internal actor DefaultRoom roomID: roomID, logger: logger ) : nil + + _presence = options.presence != nil ? await DefaultPresence( + featureChannel: featureChannels[.presence]!, + roomID: roomID, + clientID: clientId, + 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)) - let contributor = DefaultRoomLifecycleContributor(channel: .init(underlyingChannel: channel), feature: feature) + private static func createFeatureChannels(roomID: String, roomOptions: RoomOptions, realtime: RealtimeClient) -> [RoomFeature: DefaultFeatureChannel] { + .init(uniqueKeysWithValues: [RoomFeature.messages, RoomFeature.reactions, RoomFeature.presence].map { feature in + switch feature { + case .presence: + let channelOptions = ARTRealtimeChannelOptions() + let presenceOptions = roomOptions.presence + + if presenceOptions?.enter ?? false { + channelOptions.modes.insert(.presence) + } - return (feature, .init(channel: channel, contributor: contributor)) + if presenceOptions?.subscribe ?? false { + channelOptions.modes.insert(.presenceSubscribe) + } + + let channel = realtime.getChannel(feature.channelNameForRoomID(roomID)) + let contributor = DefaultRoomLifecycleContributor(channel: .init(underlyingChannel: channel), feature: feature) + + return (feature, .init(channel: channel, contributor: contributor)) + default: + let channel = realtime.getChannel(feature.channelNameForRoomID(roomID)) + let contributor = DefaultRoomLifecycleContributor(channel: .init(underlyingChannel: channel), feature: feature) + + return (feature, .init(channel: channel, contributor: contributor)) + } }) } 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 } diff --git a/Sources/AblyChat/RoomFeature.swift b/Sources/AblyChat/RoomFeature.swift index d630c3e2..64a40705 100644 --- a/Sources/AblyChat/RoomFeature.swift +++ b/Sources/AblyChat/RoomFeature.swift @@ -14,13 +14,13 @@ internal enum RoomFeature { private var channelNameSuffix: String { switch self { - case .messages: + case .messages, .presence: // (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. "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, .occupancy: // 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/Tests/AblyChatTests/IntegrationTests.swift b/Tests/AblyChatTests/IntegrationTests.swift index 3fffbfc1..0a9646d3 100644 --- a/Tests/AblyChatTests/IntegrationTests.swift +++ b/Tests/AblyChatTests/IntegrationTests.swift @@ -32,8 +32,8 @@ 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())) + let rxRoom = try await rxClient.rooms.get(roomID: roomID, options: .init(presence: .init(), reactions: .init())) // (3) Subscribe to room status let rxRoomStatusSubscription = await rxRoom.onStatusChange(bufferingPolicy: .unbounded) @@ -82,25 +82,66 @@ struct IntegrationTests { let rxReactionFromSubscription = try #require(await rxReactionSubscription.first { _ in true }) #expect(rxReactionFromSubscription.type == "heart") + // MARK: - Presence + + // (12) Subscribe to presence + let rxPresenceSubscription = await rxRoom.presence.subscribe(events: [.enter, .leave, .update]) + + // (13) 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") + + // (14) 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") + + // (15) 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") + + // (16) 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") + + // (17) 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") + + // (18) 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 + // (19) Detach the room try await rxRoom.detach() - // (13) Check that we received a DETACHED status change as a result of detaching the room + // (20) 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 + // (21) 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 + // (22) 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 + // (23) 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?