diff --git a/Example/AblyChatExample/ContentView.swift b/Example/AblyChatExample/ContentView.swift index f3a54f14..3f32f1c5 100644 --- a/Example/AblyChatExample/ContentView.swift +++ b/Example/AblyChatExample/ContentView.swift @@ -1,3 +1,4 @@ +import Ably import AblyChat import SwiftUI @@ -11,11 +12,24 @@ struct ContentView: View { let screenHeight = UIScreen.main.bounds.height #endif - @State private var chatClient = MockChatClient( + // Can be replaced with your own room ID + private let roomID = "DemoRoomID" + + // Set mode to `.live` if you wish to connect to actual instances of the Chat client in either Prod or Sandbox environments. Setting the mode to `.mock` will use the `MockChatClient`, and therefore simulate all features of the Chat app. + private let mode = Environment.mock + private enum Environment { + case mock + case live + } + + @State private var mockChatClient = MockChatClient( realtime: MockRealtime.create(), clientOptions: ClientOptions() ) + private let liveRealtime: ARTRealtime + @State private var liveChatClient: DefaultChatClient + @State private var title = "Room" @State private var messages = [BasicListItem]() @State private var reactions: [Reaction] = [] @@ -24,8 +38,19 @@ struct ContentView: View { @State private var occupancyInfo = "Connections: 0" @State private var statusInfo = "" + // You only need to set `options.key` and `options.clientId` if your mode is set to `.live`. Otherwise, you can ignore this. + init() { + let options = ARTClientOptions() + options.key = "" + options.clientId = "" + liveRealtime = ARTRealtime(options: options) + + _liveChatClient = State(initialValue: DefaultChatClient(realtime: liveRealtime, clientOptions: .init())) + } + private func room() async throws -> Room { - try await chatClient.rooms.get(roomID: "Demo", options: .init()) + let chosenChatClient: ChatClient = (mode == .mock) ? mockChatClient : liveChatClient + return try await chosenChatClient.rooms.get(roomID: roomID, options: .init(reactions: .init())) } private var sendTitle: String { @@ -99,18 +124,24 @@ struct ContentView: View { } } .tryTask { try await setDefaultTitle() } + .tryTask { try await attachRoom() } .tryTask { try await showMessages() } .tryTask { try await showReactions() } - .tryTask { try await showPresence() } - .tryTask { try await showTypings() } - .tryTask { try await showOccupancy() } - .tryTask { try await showRoomStatus() } + .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() + } + } } func sendButtonAction() { if newMessage.isEmpty { Task { - try await sendReaction(type: ReactionType.like.rawValue) + try await sendReaction(type: ReactionType.like.emoji) } } else { Task { @@ -123,8 +154,21 @@ struct ContentView: View { title = try await "\(room().roomID)" } + func attachRoom() async throws { + try await room().attach() + } + func showMessages() async throws { - for await message in try await room().messages.subscribe(bufferingPolicy: .unbounded) { + let messagesSubscription = try await room().messages.subscribe(bufferingPolicy: .unbounded) + let previousMessages = try await messagesSubscription.getPreviousMessages(params: .init()) + + for message in previousMessages.items { + withAnimation { + messages.append(BasicListItem(id: message.timeserial, title: message.clientID, text: message.text)) + } + } + + for await message in messagesSubscription { withAnimation { messages.insert(BasicListItem(id: message.timeserial, title: message.clientID, text: message.text), at: 0) } @@ -132,7 +176,8 @@ struct ContentView: View { } func showReactions() async throws { - for await reaction in try await room().reactions.subscribe(bufferingPolicy: .unbounded) { + let reactionSubscription = try await room().reactions.subscribe(bufferingPolicy: .unbounded) + for await reaction in reactionSubscription { withAnimation { showReaction(reaction.displayedText) } diff --git a/Example/AblyChatExample/Mocks/Misc.swift b/Example/AblyChatExample/Mocks/Misc.swift index 9a6b362c..cf6b9578 100644 --- a/Example/AblyChatExample/Mocks/Misc.swift +++ b/Example/AblyChatExample/Mocks/Misc.swift @@ -88,6 +88,6 @@ enum ReactionType: String, CaseIterable { extension Reaction { var displayedText: String { - ReactionType(rawValue: type)?.emoji ?? ReactionType.idk.emoji + type } } diff --git a/Example/AblyChatExample/Mocks/MockClients.swift b/Example/AblyChatExample/Mocks/MockClients.swift index a373aa4c..f3e374ae 100644 --- a/Example/AblyChatExample/Mocks/MockClients.swift +++ b/Example/AblyChatExample/Mocks/MockClients.swift @@ -69,7 +69,7 @@ actor MockRoom: Room { private var mockSubscriptions: [MockSubscription] = [] func attach() async throws { - fatalError("Not yet implemented") + print("Mock client attached to room with roomID: \(roomID)") } func detach() async throws { @@ -165,7 +165,7 @@ actor MockRoomReactions: RoomReactions { private func createSubscription() -> MockSubscription { let subscription = MockSubscription(randomElement: { Reaction( - type: ReactionType.allCases.randomElement()!.rawValue, + type: ReactionType.allCases.randomElement()!.emoji, metadata: [:], headers: [:], createdAt: Date(), diff --git a/Sources/AblyChat/DefaultMessages.swift b/Sources/AblyChat/DefaultMessages.swift index 0f5d6e66..23074557 100644 --- a/Sources/AblyChat/DefaultMessages.swift +++ b/Sources/AblyChat/DefaultMessages.swift @@ -75,7 +75,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { } let metadata = data["metadata"] as? Metadata - let headers = try message.extras?.toJSON()["headers"] as? Headers + let headers = extras["headers"] as? Headers let message = Message( timeserial: timeserial, @@ -205,27 +205,33 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { // (CHA-M5b) If a subscription is added when the underlying realtime channel is in any other state, then its subscription point becomes the attachSerial at the the point of channel attachment. return try await withCheckedThrowingContinuation { continuation in + // avoids multiple invocations of the continuation + var nillableContinuation: CheckedContinuation? = continuation + channel.on { [weak self] stateChange in guard let self else { return } + switch stateChange.current { case .attached: // Handle successful attachment if let attachSerial = channel.properties.attachSerial { - continuation.resume(returning: attachSerial) + nillableContinuation?.resume(returning: attachSerial) } else { - continuation.resume(throwing: ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined")) + nillableContinuation?.resume(throwing: ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined")) } + nillableContinuation = nil case .failed, .suspended: // TODO: Revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/32 - continuation.resume( + nillableContinuation?.resume( throwing: ARTErrorInfo.create( withCode: ErrorCode.messagesAttachmentFailed.rawValue, status: ErrorCode.messagesAttachmentFailed.statusCode, message: "Channel failed to attach" ) ) + nillableContinuation = nil default: break } diff --git a/Sources/AblyChat/DefaultRoomReactions.swift b/Sources/AblyChat/DefaultRoomReactions.swift new file mode 100644 index 00000000..9924e27d --- /dev/null +++ b/Sources/AblyChat/DefaultRoomReactions.swift @@ -0,0 +1,87 @@ +import Ably + +// TODO: This class errors with "Task-isolated value of type '() async throws -> ()' passed as a strongly transferred parameter; later accesses could race". Adding @MainActor fixes this, revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/83 +@MainActor +internal final class DefaultRoomReactions: RoomReactions, EmitsDiscontinuities { + private let roomID: String + public let featureChannel: FeatureChannel + private let logger: InternalLogger + private let clientID: String + + internal nonisolated var channel: any RealtimeChannelProtocol { + featureChannel.channel + } + + internal init(featureChannel: FeatureChannel, clientID: String, roomID: String, logger: InternalLogger) { + self.roomID = roomID + self.featureChannel = featureChannel + self.logger = logger + self.clientID = clientID + } + + // (CHA-ER3) Ephemeral room reactions are sent to Ably via the Realtime connection via a send method. + // (CHA-ER3a) Reactions are sent on the channel using a message in a particular format - see spec for format. + internal func send(params: SendReactionParams) async throws { + let extras = ["headers": params.headers ?? [:]] as ARTJsonCompatible + channel.publish(RoomReactionEvents.reaction.rawValue, data: params.asQueryItems(), extras: extras) + } + + // (CHA-ER4) A user may subscribe to reaction events in Realtime. + // (CHA-ER4a) A user may provide a listener to subscribe to reaction events. This operation must have no side-effects in relation to room or underlying status. When a realtime message with name roomReaction is received, this message is converted into a reaction object and emitted to subscribers. + internal func subscribe(bufferingPolicy: BufferingPolicy) async -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + + // (CHA-ER4c) Realtime events with an unknown name shall be silently discarded. + channel.subscribe(RoomReactionEvents.reaction.rawValue) { [clientID, logger] message in + Task { + do { + guard let data = message.data as? [String: Any], + let reactionType = data["type"] as? String + else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or text") + } + + guard let messageClientID = 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") + } + + guard let extras = try message.extras?.toJSON() else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without extras") + } + + let metadata = data["metadata"] as? Metadata + let headers = extras["headers"] as? Headers + + // (CHA-ER4d) Realtime events that are malformed (unknown fields should be ignored) shall not be emitted to listeners. + let reaction = Reaction( + type: reactionType, + metadata: metadata ?? .init(), + headers: headers ?? .init(), + createdAt: timestamp, + clientID: messageClientID, + isSelf: messageClientID == clientID + ) + + subscription.emit(reaction) + } catch { + logger.log(message: "Error processing incoming reaction message: \(error)", level: .error) + } + } + } + + return subscription + } + + // (CHA-ER5) Users may subscribe to discontinuity events to know when there’s been a break in reactions that they need to resolve. Their listener will be called when a discontinuity event is triggered from the room lifecycle. + internal func subscribeToDiscontinuities() async -> Subscription { + await featureChannel.subscribeToDiscontinuities() + } + + private enum RoomReactionsError: Error { + case noReferenceToSelf + } +} diff --git a/Sources/AblyChat/Events.swift b/Sources/AblyChat/Events.swift index cd2d5fb0..73141dee 100644 --- a/Sources/AblyChat/Events.swift +++ b/Sources/AblyChat/Events.swift @@ -1,3 +1,7 @@ internal enum MessageEvent: String { case created = "message.created" } + +internal enum RoomReactionEvents: String { + case reaction = "roomReaction" +} diff --git a/Sources/AblyChat/Reaction.swift b/Sources/AblyChat/Reaction.swift index 7b23fb5c..9f6bd720 100644 --- a/Sources/AblyChat/Reaction.swift +++ b/Sources/AblyChat/Reaction.swift @@ -3,6 +3,7 @@ import Foundation public typealias ReactionHeaders = Headers public typealias ReactionMetadata = Metadata +// (CHA-ER2) A Reaction corresponds to a single reaction in a chat room. This is analogous to a single user-specified message on an Ably channel (NOTE: not a ProtocolMessage). public struct Reaction: Sendable { public var type: String public var metadata: ReactionMetadata diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index 076bc0fe..99653346 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -61,6 +61,7 @@ internal actor DefaultRoom private let chatAPI: ChatAPI public nonisolated let messages: any Messages + private let _reactions: (any RoomReactions)? // Exposed for testing. private nonisolated let realtime: RealtimeClient @@ -90,16 +91,25 @@ internal actor DefaultRoom logger: logger ) + // TODO: Address force unwrapping of `channels` within feature initialisation below: https://github.com/ably-labs/ably-chat-swift/issues/105 + messages = await DefaultMessages( featureChannel: featureChannels[.messages]!, chatAPI: chatAPI, roomID: roomID, clientID: clientId ) + + _reactions = options.reactions != nil ? await DefaultRoomReactions( + featureChannel: featureChannels[.reactions]!, + clientID: clientId, + roomID: roomID, + logger: logger + ) : nil } private static func createFeatureChannels(roomID: String, realtime: RealtimeClient) -> [RoomFeature: DefaultFeatureChannel] { - .init(uniqueKeysWithValues: [RoomFeature.messages].map { feature in + .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) @@ -112,7 +122,11 @@ internal actor DefaultRoom } public nonisolated var reactions: any RoomReactions { - fatalError("Not yet implemented") + guard let _reactions else { + fatalError("Reactions are not enabled for this room") + } + + return _reactions } public nonisolated var typing: any Typing { diff --git a/Sources/AblyChat/RoomFeature.swift b/Sources/AblyChat/RoomFeature.swift index a006472b..d630c3e2 100644 --- a/Sources/AblyChat/RoomFeature.swift +++ b/Sources/AblyChat/RoomFeature.swift @@ -17,7 +17,10 @@ internal enum RoomFeature { case .messages: // (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 .typing, .reactions, .presence, .occupancy: + 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: // 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/RoomReactions.swift b/Sources/AblyChat/RoomReactions.swift index b02c1800..b50b9212 100644 --- a/Sources/AblyChat/RoomReactions.swift +++ b/Sources/AblyChat/RoomReactions.swift @@ -17,3 +17,13 @@ public struct SendReactionParams: Sendable { self.headers = headers } } + +internal extension SendReactionParams { + // Same as `ARTDataQuery.asQueryItems` from ably-cocoa. + func asQueryItems() -> [String: String] { + var dict: [String: String] = [:] + dict["type"] = "\(type)" + dict["metadata"] = "\(metadata ?? [:])" + return dict + } +} diff --git a/Sources/AblyChat/Subscription.swift b/Sources/AblyChat/Subscription.swift index 2866eab0..8c46fc4d 100644 --- a/Sources/AblyChat/Subscription.swift +++ b/Sources/AblyChat/Subscription.swift @@ -71,6 +71,16 @@ public struct Subscription: Sendable, AsyncSequence { } } + // TODO: https://github.com/ably-labs/ably-chat-swift/issues/36 Revisit how we want to unsubscribe to fulfil CHA-M4b & CHA-ER4b. I think exposing this publicly for all Subscription types is suitable. + public func finish() { + switch mode { + case let .default(_, continuation): + continuation.finish() + case .mockAsyncSequence: + fatalError("`finish` cannot be called on a Subscription that was created using init(mockAsyncSequence:)") + } + } + public struct AsyncIterator: AsyncIteratorProtocol { fileprivate enum Mode { case `default`(iterator: AsyncStream.AsyncIterator) diff --git a/Tests/AblyChatTests/DefaultRoomReactionsTests.swift b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift new file mode 100644 index 00000000..c0940795 --- /dev/null +++ b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift @@ -0,0 +1,82 @@ +import Ably +@testable import AblyChat +import Testing + +struct DefaultRoomReactionsTests { + // @spec CHA-ER1 + @Test + func init_channelNameIsSetAsReactionsChannelName() async throws { + // Given + let channel = MockRealtimeChannel(name: "basketball::$chat::$reactions") + let featureChannel = MockFeatureChannel(channel: channel) + + // When + let defaultRoomReactions = await DefaultRoomReactions(featureChannel: featureChannel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) + + // Then + #expect(defaultRoomReactions.channel.name == "basketball::$chat::$reactions") + } + + // @spec CHA-ER3a + @Test + func reactionsAreSentInTheCorrectFormat() async throws { + // channel name and roomID values are arbitrary + // Given + let channel = MockRealtimeChannel(name: "basketball::$chat::$reactions") + let featureChannel = MockFeatureChannel(channel: channel) + + // When + let defaultRoomReactions = await DefaultRoomReactions(featureChannel: featureChannel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) + + let sendReactionParams = SendReactionParams( + type: "like", + metadata: ["test": MetadataValue.string("test")], + headers: ["test": HeadersValue.string("test")] + ) + + // When + try await defaultRoomReactions.send(params: sendReactionParams) + + // Then + #expect(channel.lastMessagePublishedName == RoomReactionEvents.reaction.rawValue) + #expect(channel.lastMessagePublishedData as? [String: String] == sendReactionParams.asQueryItems()) + #expect(channel.lastMessagePublishedExtras as? Dictionary == ["headers": sendReactionParams.headers]) + } + + // @spec CHA-ER4 + @Test + func subscribe_returnsSubscription() async throws { + // all setup values here are arbitrary + // Given + let channel = MockRealtimeChannel(name: "basketball::$chat::$reactions") + let featureChannel = MockFeatureChannel(channel: channel) + + // When + let defaultRoomReactions = await DefaultRoomReactions(featureChannel: featureChannel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) + + // When + let subscription: Subscription? = await defaultRoomReactions.subscribe(bufferingPolicy: .unbounded) + + // Then + #expect(subscription != nil) + } + + // @spec CHA-ER5 + @Test + func subscribeToDiscontinuities() async throws { + // all setup values here are arbitrary + // Given: A DefaultRoomReactions instance + let channel = MockRealtimeChannel() + let featureChannel = MockFeatureChannel(channel: channel) + let roomReactions = await DefaultRoomReactions(featureChannel: featureChannel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) + + // When: The feature channel emits a discontinuity through `subscribeToDiscontinuities` + let featureChannelDiscontinuity = ARTErrorInfo.createUnknownError() // arbitrary + let messagesDiscontinuitySubscription = await roomReactions.subscribeToDiscontinuities() + await featureChannel.emitDiscontinuity(featureChannelDiscontinuity) + + // Then: The DefaultRoomReactions instance emits this discontinuity through `subscribeToDiscontinuities` + let messagesDiscontinuity = try #require(await messagesDiscontinuitySubscription.first { _ in true }) + #expect(messagesDiscontinuity === featureChannelDiscontinuity) + } +} diff --git a/Tests/AblyChatTests/DefaultRoomTests.swift b/Tests/AblyChatTests/DefaultRoomTests.swift index eec8e94b..36fa48a2 100644 --- a/Tests/AblyChatTests/DefaultRoomTests.swift +++ b/Tests/AblyChatTests/DefaultRoomTests.swift @@ -11,6 +11,7 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), // required as DefaultRoom attaches reactions implicitly for now ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) @@ -32,6 +33,7 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), // required as DefaultRoom attaches reactions implicitly for now ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) @@ -68,6 +70,7 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), // required as DefaultRoom attaches reactions implicitly for now ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) @@ -100,6 +103,7 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), // required as DefaultRoom attaches reactions implicitly for now ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) @@ -126,6 +130,7 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), // required as DefaultRoom attaches reactions implicitly for now ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) @@ -146,6 +151,7 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), // required as DefaultRoom attaches reactions implicitly for now ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) diff --git a/Tests/AblyChatTests/IntegrationTests.swift b/Tests/AblyChatTests/IntegrationTests.swift index fa975d70..3fffbfc1 100644 --- a/Tests/AblyChatTests/IntegrationTests.swift +++ b/Tests/AblyChatTests/IntegrationTests.swift @@ -22,6 +22,8 @@ struct IntegrationTests { @Test func basicIntegrationTest() async throws { + // MARK: - Setup + Attach + let apiKey = try await Sandbox.createAPIKey() // (1) Create a couple of chat clients — one for sending and one for receiving @@ -30,8 +32,8 @@ struct IntegrationTests { // (2) Fetch a room let roomID = "basketball" - let txRoom = try await txClient.rooms.get(roomID: roomID, options: .init()) - let rxRoom = try await rxClient.rooms.get(roomID: roomID, options: .init()) + 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())) // (3) Subscribe to room status let rxRoomStatusSubscription = await rxRoom.onStatusChange(bufferingPolicy: .unbounded) @@ -43,6 +45,8 @@ struct IntegrationTests { _ = try #require(await rxRoomStatusSubscription.first { $0.current == .attached }) #expect(await rxRoom.status == .attached) + // MARK: - Send and receive messages + // (6) Send a message before subscribing to messages, so that later on we can check history works. // Create a throwaway subscription and wait for it to receive a message. This is to make sure that rxRoom has seen the message that we send here, so that the first message we receive on the subscription created in (7) is that which we’ll send in (8), and not that which we send here. @@ -68,21 +72,35 @@ struct IntegrationTests { try #require(rxMessagesBeforeSubscribing.items.count == 1) #expect(rxMessagesBeforeSubscribing.items[0] == txMessageBeforeRxSubscribe) - // (10) Detach the room + // MARK: - Reactions + + // (10) Subscribe to reactions + let rxReactionSubscription = await rxRoom.reactions.subscribe(bufferingPolicy: .unbounded) + + // (11) Now that we’re subscribed to reactions, send a reaction on the other client and check that we receive it on the subscription + try await txRoom.reactions.send(params: .init(type: "heart")) + let rxReactionFromSubscription = try #require(await rxReactionSubscription.first { _ in true }) + #expect(rxReactionFromSubscription.type == "heart") + + // MARK: - Detach + + // (12) Detach the room try await rxRoom.detach() - // (11) Check that we received a DETACHED status change as a result of detaching the room + // (13) 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) - // (12) Release the room + // MARK: - Release + + // (14) Release the room try await rxClient.rooms.release(roomID: roomID) - // (13) Check that we received a RELEASED status change as a result of releasing the room + // (15) 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) - // (14) Fetch the room we just released and check it’s a new object + // (16) 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 67213d10..2d2c3f3c 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift @@ -8,6 +8,11 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { var properties: ARTChannelProperties { .init(attachSerial: attachSerial, channelSerial: channelSerial) } + // I don't see why the nonisolated(unsafe) keyword would cause a problem when used for tests in this context. + nonisolated(unsafe) var lastMessagePublishedName: String? + nonisolated(unsafe) var lastMessagePublishedData: Any? + nonisolated(unsafe) var lastMessagePublishedExtras: (any ARTJsonCompatible)? + init( name: String? = nil, properties: ARTChannelProperties = .init(), @@ -199,8 +204,10 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { fatalError("Not implemented") } - func publish(_: String?, data _: Any?, extras _: (any ARTJsonCompatible)?) { - fatalError("Not implemented") + func publish(_ name: String?, data: Any?, extras: (any ARTJsonCompatible)?) { + lastMessagePublishedName = name + lastMessagePublishedExtras = extras + lastMessagePublishedData = data } func publish(_: String?, data _: Any?, extras _: (any ARTJsonCompatible)?, callback _: ARTCallback? = nil) {