diff --git a/Example/AblyChatExample/ContentView.swift b/Example/AblyChatExample/ContentView.swift index 4690b868..f869d45f 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 { @@ -33,7 +58,7 @@ struct ContentView: View { } var body: some View { - ZStack { + let zStack = ZStack { VStack { Text(title) .font(.headline) @@ -99,18 +124,23 @@ 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() } + if mode == .mock { + zStack.tryTask { try await showPresence() } + .tryTask { try await showTypings() } + .tryTask { try await showOccupancy() } + .tryTask { try await showRoomStatus() } + } + + // NOTE: As we implement more features, move them out of the `if mode == .mock` block and into the main `zStack` block just above. } func sendButtonAction() { if newMessage.isEmpty { Task { - try await sendReaction(type: ReactionType.like.rawValue) + try await sendReaction(type: ReactionType.like.emoji) } } else { Task { @@ -123,8 +153,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 +175,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 14a31d71..36e4d6e1 100644 --- a/Example/AblyChatExample/Mocks/MockClients.swift +++ b/Example/AblyChatExample/Mocks/MockClients.swift @@ -67,7 +67,7 @@ actor MockRoom: Room { nonisolated lazy var status: any RoomStatus = MockRoomStatus(clientID: clientID, roomID: roomID) func attach() async throws { - fatalError("Not yet implemented") + print("Mock client attached to room with roomID: \(roomID)") } func detach() async throws { @@ -151,7 +151,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 3329f8b1..e469d122 100644 --- a/Sources/AblyChat/DefaultMessages.swift +++ b/Sources/AblyChat/DefaultMessages.swift @@ -74,7 +74,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, diff --git a/Sources/AblyChat/DefaultRoomReactions.swift b/Sources/AblyChat/DefaultRoomReactions.swift new file mode 100644 index 00000000..f3c69603 --- /dev/null +++ b/Sources/AblyChat/DefaultRoomReactions.swift @@ -0,0 +1,86 @@ +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 channel: RealtimeChannelProtocol + private let realtime: any RealtimeClientProtocol + private let logger: InternalLogger + + internal init(realtime: any RealtimeClientProtocol, roomID: String, logger: InternalLogger) { + self.roomID = roomID + self.realtime = realtime + self.logger = logger + + // (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. + let reactionsChannelName = "\(roomID)::$chat::$reactions" + channel = realtime.getChannel(reactionsChannelName) + } + + // (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: NSDictionary = ["headers": params.headers ?? [:]] + 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) { [realtime, 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 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") + } + + 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: clientID, + isSelf: message.clientId == realtime.clientId + ) + + subscription.emit(reaction) + } catch { + logger.log(message: "Error processing incoming reaction message: \(error)", level: .error) + } + } + } + + return subscription + } + + // TODO: (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. https://github.com/ably-labs/ably-chat-swift/issues/47 + internal func subscribeToDiscontinuities() async -> Subscription { + fatalError("Not implemented") + } + + 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 72352e22..957854b4 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -23,6 +23,7 @@ internal actor DefaultRoom: Room { private let chatAPI: ChatAPI public nonisolated let messages: any Messages + private let _reactions: (any RoomReactions)? // Exposed for testing. private nonisolated let realtime: RealtimeClient @@ -53,6 +54,12 @@ internal actor DefaultRoom: Room { roomID: roomID, clientID: clientId ) + + _reactions = options.reactions != nil ? await DefaultRoomReactions( + realtime: realtime, + roomID: roomID, + logger: logger + ) : nil } public nonisolated var presence: any Presence { @@ -60,7 +67,11 @@ internal actor DefaultRoom: Room { } 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/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..c5d4b945 --- /dev/null +++ b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift @@ -0,0 +1,56 @@ +@testable import AblyChat +import Testing + +struct DefaultRoomReactionsTests { + // @spec CHA-ER1 + @Test + func init_channelNameIsSetAsReactionsChannelName() async throws { + // Given + let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$reactions")])) + + // When + let defaultRoomReactions = await DefaultRoomReactions(realtime: realtime, roomID: "basketball") + + // Then + await #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 realtime = MockRealtime.create(channels: .init(channels: [channel])) + let defaultRoomReactions = await DefaultRoomReactions(realtime: realtime, roomID: "basketball") + + 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 realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$reactions")])) + let defaultRoomReactions = await DefaultRoomReactions(realtime: realtime, roomID: "basketball") + + // When + let subscription: Subscription? = await defaultRoomReactions.subscribe(bufferingPolicy: .unbounded) + + // Then + #expect(subscription != nil) + } +} 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) {