From 924e103f15a05547b60fa24f5063937554fc22d9 Mon Sep 17 00:00:00 2001 From: Umair Date: Thu, 3 Oct 2024 14:18:15 +0100 Subject: [PATCH] Spec complete for sending and receiving messages --- .../xcshareddata/swiftpm/Package.resolved | 6 +- .../AblyChatExample.xcodeproj/project.pbxproj | 2 + .../AblyChatExample/AblyChatExampleApp.swift | 2 +- Example/AblyChatExample/ContentView.swift | 24 -- Example/AblyChatExample/MessageDemoView.swift | 150 +++++++++++ .../AblyChatExample/Mocks/MockRealtime.swift | 18 +- Package.resolved | 6 +- Package.swift | 4 +- Package@swift-6.swift | 68 +++++ Sources/AblyChat/ChatAPI.swift | 139 +++++++++++ Sources/AblyChat/DefaultMessages.swift | 235 ++++++++++++++++++ Sources/AblyChat/Dependencies.swift | 17 ++ Sources/AblyChat/EmitsDiscontinuities.swift | 15 ++ Sources/AblyChat/Events.swift | 3 + Sources/AblyChat/Headers.swift | 4 +- Sources/AblyChat/Message.swift | 38 ++- Sources/AblyChat/Messages.swift | 72 ++++-- Sources/AblyChat/Metadata.swift | 11 +- Sources/AblyChat/Occupancy.swift | 2 +- Sources/AblyChat/PaginatedResult.swift | 84 +++++++ Sources/AblyChat/Room.swift | 14 +- Sources/AblyChat/Rooms.swift | 2 +- Sources/AblyChat/Timeserial.swift | 94 +++++++ Sources/AblyChat/Version.swift | 18 ++ Tests/AblyChatTests/DefaultRoomTests.swift | 8 +- Tests/AblyChatTests/DefaultRoomsTests.swift | 6 +- .../MessageSubscriptionTests.swift | 2 +- Tests/AblyChatTests/Mocks/MockChannels.swift | 4 + Tests/AblyChatTests/Mocks/MockRealtime.swift | 6 +- .../Mocks/MockRealtimeChannel.swift | 7 +- 30 files changed, 978 insertions(+), 83 deletions(-) delete mode 100644 Example/AblyChatExample/ContentView.swift create mode 100644 Example/AblyChatExample/MessageDemoView.swift create mode 100644 Package@swift-6.swift create mode 100644 Sources/AblyChat/ChatAPI.swift create mode 100644 Sources/AblyChat/DefaultMessages.swift create mode 100644 Sources/AblyChat/Events.swift create mode 100644 Sources/AblyChat/Timeserial.swift create mode 100644 Sources/AblyChat/Version.swift diff --git a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9cffb1ec..e7572cf8 100644 --- a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "fcc346d6fe86e610ac200cdbbf91c56204df67286546d5079bd9c610ee65953b", + "originHash" : "d5cd1b39ed966b59fccd3f0d3d46bcf897088e975a6b8a3622235a7adfacaba6", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "7f639c609e50053abd4590f34333f9472645558a", - "version" : "1.2.33" + "branch" : "main", + "revision" : "63e6f001d06cb7defb6be92f87a831f920eaf8c1" } }, { diff --git a/Example/AblyChatExample.xcodeproj/project.pbxproj b/Example/AblyChatExample.xcodeproj/project.pbxproj index ba328f7d..bfc10bdb 100644 --- a/Example/AblyChatExample.xcodeproj/project.pbxproj +++ b/Example/AblyChatExample.xcodeproj/project.pbxproj @@ -104,6 +104,8 @@ Base, ); mainGroup = 21F09A932C60CAF00025AF73; + packageReferences = ( + ); productRefGroup = 21F09A9D2C60CAF00025AF73 /* Products */; projectDirPath = ""; projectRoot = ""; diff --git a/Example/AblyChatExample/AblyChatExampleApp.swift b/Example/AblyChatExample/AblyChatExampleApp.swift index b7bada32..e476b3f6 100644 --- a/Example/AblyChatExample/AblyChatExampleApp.swift +++ b/Example/AblyChatExample/AblyChatExampleApp.swift @@ -4,7 +4,7 @@ import SwiftUI struct AblyChatExampleApp: App { var body: some Scene { WindowGroup { - ContentView() + MessageDemoView() } } } diff --git a/Example/AblyChatExample/ContentView.swift b/Example/AblyChatExample/ContentView.swift deleted file mode 100644 index a0de7edf..00000000 --- a/Example/AblyChatExample/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -import AblyChat -import SwiftUI - -struct ContentView: View { - /// Just used to check that we can successfully import and use the AblyChat library. TODO remove this once we start building the library - @State private var ablyChatClient = DefaultChatClient( - realtime: MockRealtime.create(), - clientOptions: ClientOptions() - ) - - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/Example/AblyChatExample/MessageDemoView.swift b/Example/AblyChatExample/MessageDemoView.swift new file mode 100644 index 00000000..75a0e907 --- /dev/null +++ b/Example/AblyChatExample/MessageDemoView.swift @@ -0,0 +1,150 @@ +import Ably +import AblyChat +import SwiftUI + +// TODO: This entire file can be removed and replaced with the actual example app we're going with. Leaving it here as a reference to something that is currently working. + +let clientId = "" // Set any string as a ClientID here e.g. "John" +let apiKey = "" // Set your Ably API Key here + +struct MessageCell: View { + var contentMessage: String + var isCurrentUser: Bool + + var body: some View { + Text(contentMessage) + .padding(12) + .foregroundColor(isCurrentUser ? Color.white : Color.black) + .background(isCurrentUser ? Color.blue : Color.gray) + .cornerRadius(12) + } +} + +struct MessageView: View { + var currentMessage: Message + + var body: some View { + HStack(alignment: .bottom) { + if let messageClientId = currentMessage.clientID { + if messageClientId == clientId { + Spacer() + } else {} + MessageCell( + contentMessage: currentMessage.text, + isCurrentUser: messageClientId == clientId + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 4) + } +} + +struct MessageDemoView: View { + @State private var messages: [Message] = [] // Store the chat messages + @State private var newMessage: String = "" // Store the message user is typing + @State private var room: Room? // Keep track of the chat room + + var clientOptions: ARTClientOptions { + let options = ARTClientOptions() + options.clientId = clientId + options.key = apiKey + return options + } + + var body: some View { + VStack { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(messages, id: \.self) { message in + MessageView(currentMessage: message) + .id(message) + } + } + .onChange(of: messages.count) { + withAnimation { + proxy.scrollTo(messages.last, anchor: .bottom) + } + } + .onAppear { + withAnimation { + proxy.scrollTo(messages.last, anchor: .bottom) + } + } + } + + // send new message + HStack { + TextField("Send a message", text: $newMessage) + #if !os(tvOS) + .textFieldStyle(.roundedBorder) + #endif + Button(action: sendMessage) { + Image(systemName: "paperplane") + } + } + .padding() + } + .task { + await startChat() + } + } + } + + func startChat() async { + let realtime = ARTRealtime(options: clientOptions) + + let chatClient = DefaultChatClient( + realtime: realtime, + clientOptions: nil + ) + + do { + // Get the chat room + room = try await chatClient.rooms.get(roomID: "umairsDemoRoom1", options: .init()) + + // attach to room + try await room?.attach() + + // subscribe to messages + let subscription = try await room?.messages.subscribe(bufferingPolicy: .unbounded) + + // use subscription to get previous messages + let prevMessages = try await subscription?.getPreviousMessages(params: .init(orderBy: .oldestFirst)) + + // init local messages array with previous messages + messages = .init(prevMessages?.items ?? []) + + // append new messages to local messages array as they are emitted + if let subscription { + for await message in subscription { + messages.append(message) + } + } + } catch { + print("Error starting chat: \(error)") + } + } + + func sendMessage() { + guard !newMessage.isEmpty else { + return + } + Task { + do { + _ = try await room?.messages.send(params: .init(text: newMessage)) + + // Clear the text field after sending + newMessage = "" + } catch { + print("Error sending message: \(error)") + } + } + } +} + +#Preview { + MessageDemoView() +} diff --git a/Example/AblyChatExample/Mocks/MockRealtime.swift b/Example/AblyChatExample/Mocks/MockRealtime.swift index 067e8f69..b78a3f74 100644 --- a/Example/AblyChatExample/Mocks/MockRealtime.swift +++ b/Example/AblyChatExample/Mocks/MockRealtime.swift @@ -1,8 +1,13 @@ -import Ably +@preconcurrency import Ably import AblyChat +// swiftlint:disable:next line_length /// A mock implementation of `RealtimeClientProtocol`. It only exists so that we can construct an instance of `DefaultChatClient` without needing to create a proper `ARTRealtime` instance (which we can’t yet do because we don’t have a method for inserting an API key into the example app). TODO remove this once we start building the example app final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { + func request(_: String, path _: String, params _: [String: String]?, body _: Any?, headers _: [String: String]?, callback _: @escaping ARTHTTPPaginatedCallback) throws { + fatalError("not implemented") + } + var device: ARTLocalDevice { fatalError("Not implemented") } @@ -14,6 +19,10 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { let channels = Channels() final class Channels: RealtimeChannelsProtocol { + func get(_: String, options _: ARTRealtimeChannelOptions) -> MockRealtime.Channel { + fatalError("Not implemented") + } + func get(_: String) -> Channel { fatalError("Not implemented") } @@ -32,6 +41,13 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { } final class Channel: RealtimeChannelProtocol { + // Let 'defaultChannelOptions' is not concurrency-safe because non-'Sendable' type 'ARTRealtimeChannelOptions' may have shared mutable state - marked Ably import with @preconcurrency for now. + let properties: ARTChannelProperties + + init(properties: ARTChannelProperties) { + self.properties = properties + } + var state: ARTRealtimeChannelState { fatalError("Not implemented") } diff --git a/Package.resolved b/Package.resolved index 9cffb1ec..e7572cf8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "fcc346d6fe86e610ac200cdbbf91c56204df67286546d5079bd9c610ee65953b", + "originHash" : "d5cd1b39ed966b59fccd3f0d3d46bcf897088e975a6b8a3622235a7adfacaba6", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "7f639c609e50053abd4590f34333f9472645558a", - "version" : "1.2.33" + "branch" : "main", + "revision" : "63e6f001d06cb7defb6be92f87a831f920eaf8c1" } }, { diff --git a/Package.swift b/Package.swift index fc233cd2..78236db6 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "AblyChat", platforms: [ - .macOS(.v11), + .macOS(.v12), .iOS(.v14), .tvOS(.v14), ], @@ -20,7 +20,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/ably/ably-cocoa", - from: "1.2.0" + branch: "main" ), .package( url: "https://github.com/apple/swift-argument-parser", diff --git a/Package@swift-6.swift b/Package@swift-6.swift new file mode 100644 index 00000000..78236db6 --- /dev/null +++ b/Package@swift-6.swift @@ -0,0 +1,68 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "AblyChat", + platforms: [ + .macOS(.v12), + .iOS(.v14), + .tvOS(.v14), + ], + products: [ + .library( + name: "AblyChat", + targets: [ + "AblyChat", + ] + ), + ], + dependencies: [ + .package( + url: "https://github.com/ably/ably-cocoa", + branch: "main" + ), + .package( + url: "https://github.com/apple/swift-argument-parser", + from: "1.5.0" + ), + .package( + url: "https://github.com/apple/swift-async-algorithms", + from: "1.0.1" + ), + ], + targets: [ + .target( + name: "AblyChat", + dependencies: [ + .product( + name: "Ably", + package: "ably-cocoa" + ), + ] + ), + .testTarget( + name: "AblyChatTests", + dependencies: [ + "AblyChat", + .product( + name: "AsyncAlgorithms", + package: "swift-async-algorithms" + ), + ] + ), + .executableTarget( + name: "BuildTool", + dependencies: [ + .product( + name: "ArgumentParser", + package: "swift-argument-parser" + ), + .product( + name: "AsyncAlgorithms", + package: "swift-async-algorithms" + ), + ] + ), + ] +) diff --git a/Sources/AblyChat/ChatAPI.swift b/Sources/AblyChat/ChatAPI.swift new file mode 100644 index 00000000..76ce0af5 --- /dev/null +++ b/Sources/AblyChat/ChatAPI.swift @@ -0,0 +1,139 @@ +import Ably + +public final class ChatAPI: Sendable { + private let realtime: RealtimeClient + private let apiProtocolVersion: Int = 3 + + public init(realtime: RealtimeClient) { + self.realtime = realtime + } + + internal func getChannel(_ messagesChannelName: String) -> any RealtimeChannelProtocol { + realtime.getChannel(messagesChannelName) + } + + // (CHA-M6) Messages should be queryable from a paginated REST API. + public func getMessages(roomId: String, params: QueryOptions) async throws -> any PaginatedResult { + let endpoint = "/chat/v1/rooms/\(roomId)/messages" + return try await makeAuthorizedPaginatedRequest(endpoint, params: params.toDictionary()) + } + + internal struct SendMessageResponse: Codable { + internal let timeserial: String + internal let createdAt: Int64 + } + + // (CHA-M3) Messages are sent to Ably via the Chat REST API, using the send method. + // (CHA-M3a) When a message is sent successfully, the caller shall receive a struct representing the Message in response (as if it were received via Realtime event). + public func sendMessage(roomId: String, params: SendMessageParams) async throws -> Message { + let endpoint = "/chat/v1/rooms/\(roomId)/messages" + var body: [String: Any] = ["text": params.text] + + // (CHA-M3b) A message may be sent without metadata or headers. When these are not specified by the user, they must be omitted from the REST payload. + if let metadata = params.metadata { + body["metadata"] = metadata + + // (CHA-M3c) metadata must not contain the key ably-chat. This is reserved for future internal use. If this key is present, the send call shall terminate by throwing an ErrorInfo with code 40001. + if metadata.contains(where: { $0.key == "ably-chat" }) { + throw ARTErrorInfo.create(withCode: 40001, message: "metadata must not contain the key `ably-chat`") + } + } + + if let headers = params.headers { + body["headers"] = headers + + // (CHA-M3d) headers must not contain a key prefixed with ably-chat. This is reserved for future internal use. If this key is present, the send call shall terminate by throwing an ErrorInfo with code 40001. + if headers.keys.contains(where: { keyString in + keyString.hasPrefix("ably-chat") + }) { + throw ARTErrorInfo.create(withCode: 40001, message: "headers must not contain any key with a prefix of `ably-chat`") + } + } + + let response: SendMessageResponse = try await makeAuthorizedRequest(endpoint, method: "POST", body: body) + + // response.createdAt is in milliseconds, convert it to seconds + let createdAtInSeconds = TimeInterval(integerLiteral: response.createdAt) / 1000 + + let message = Message( + timeserial: response.timeserial, + clientID: realtime.clientId ?? "", + roomID: roomId, + text: params.text, + createdAt: Date(timeIntervalSince1970: createdAtInSeconds), + metadata: params.metadata ?? [:], + headers: params.headers ?? [:] + ) + return message + } + + public func getOccupancy(roomId: String) async throws -> OccupancyEvent { + let endpoint = "/chat/v1/rooms/\(roomId)/occupancy" + return try await makeAuthorizedRequest(endpoint, method: "GET") + } + + // TODO: Improve as part of occupancy/presence + private func makeAuthorizedRequest(_ url: String, method: String, body: [String: Any]? = nil) async throws -> Response { + try await withCheckedThrowingContinuation { continuation in + do { + try realtime.request(method, path: url, params: [:], body: body, headers: [:]) { paginatedResponse, error in + if let error { + // (CHA-M3e) If an error is returned from the REST API, its ErrorInfo representation shall be thrown as the result of the send call. + continuation.resume(throwing: ARTErrorInfo.create(from: error)) + return + } + + guard let firstItem = paginatedResponse?.items.first else { + continuation.resume(throwing: ChatError.noItemInResponse) + return + } + + do { + let decodedResponse = try DictionaryDecoder().decode(Response.self, from: firstItem) + continuation.resume(returning: decodedResponse) + } catch { + continuation.resume(throwing: error) + } + } + } catch { + continuation.resume(throwing: error) + } + } + } + + private func makeAuthorizedPaginatedRequest( + _ url: String, + params: [String: String]? = nil, + body: [String: Any]? = nil + ) async throws -> any PaginatedResult { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation, _>) in + do { + try realtime.request("GET", path: url, params: params, body: nil, headers: [:]) { paginatedResponse, error in + ARTHTTPPaginatedCallbackWrapper(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation) + } + } catch { + continuation.resume(throwing: error) + } + } + } + + internal enum ChatError: Error { + case noItemInResponse + } +} + +internal struct DictionaryDecoder { + private let decoder = JSONDecoder() + + // Function to decode from a dictionary + internal func decode(_: T.Type, from dictionary: NSDictionary) throws -> T { + let data = try JSONSerialization.data(withJSONObject: dictionary) + return try decoder.decode(T.self, from: data) + } + + // Function to decode from a dictionary array + internal func decode(_: T.Type, from dictionary: [NSDictionary]) throws -> T { + let data = try JSONSerialization.data(withJSONObject: dictionary) + return try decoder.decode(T.self, from: data) + } +} diff --git a/Sources/AblyChat/DefaultMessages.swift b/Sources/AblyChat/DefaultMessages.swift new file mode 100644 index 00000000..adf62bc8 --- /dev/null +++ b/Sources/AblyChat/DefaultMessages.swift @@ -0,0 +1,235 @@ +import Ably + +// Typealias for the timeserial used to sync message subscriptions with. This is a string representation of a timestamp. +private typealias FromSerial = String + +// Wraps the MessageSubscription with the timeserial of when the subscription was attached or resumed. +private struct MessageSubscriptionWrapper { + let subscription: MessageSubscription + var timeserial: FromSerial +} + +public final class DefaultMessages: Messages, HandlesDiscontinuity { + private let roomID: String + public let channel: RealtimeChannelProtocol + private let chatAPI: ChatAPI + private let clientID: String + private var subscriptionPoints: [UUID: MessageSubscriptionWrapper] = [:] + + public nonisolated init(chatAPI: ChatAPI, roomID: String, clientID: String) { + self.chatAPI = chatAPI + self.roomID = roomID + self.clientID = clientID + + // (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. + let messagesChannelName = "\(roomID)::$chat::$chatMessages" + channel = chatAPI.getChannel(messagesChannelName) + + // Implicitly handles channel events and therefore listners within this class. Alternative is to explicitly call something like `DefaultMessages.start()` which makes the SDK more cumbersome to interact with. This class is useless without kicking off this flow so I think leaving it here is suitable. + Task { + await handleChannelEvents(roomId: roomID) + } + } + + // (CHA-M4) Messages can be received via a subscription in realtime. + public func subscribe(bufferingPolicy: BufferingPolicy) async throws -> MessageSubscription { + let uuid = UUID() + let timeserial = try await resolveSubscriptionStart() + let messageSubscription = MessageSubscription( + bufferingPolicy: bufferingPolicy + ) { [weak self] queryOptions in + guard let self else { throw MessagesError.noReferenceToSelf } + return try await getBeforeSubscriptionStart(uuid, params: queryOptions) + } + + subscriptionPoints[uuid] = .init(subscription: messageSubscription, timeserial: timeserial) + + // (CHA-M4c) When a realtime message with name set to message.created is received, it is translated into a message event, which contains a type field with the event type as well as a message field containing the Message Struct. This event is then broadcast to all subscribers. + // (CHA-M4d) If a realtime message with an unknown name is received, the SDK shall silently discard the message, though it may log at DEBUG or TRACE level. + // (CHA-M5d) Incoming realtime events that are malformed (unknown field should be ignored) shall not be emitted to subscribers. + channel.subscribe(MessageEvent.created.rawValue) { message in + Task { + guard let data = message.data as? [String: Any], + let text = data["text"] as? String + else { + return + } + + guard let timeserial = try message.extras?.toJSON()["timeserial"] as? String else { + return + } + + let message = Message( + timeserial: timeserial, + clientID: message.clientId, + roomID: self.roomID, + text: text, + createdAt: message.timestamp, + metadata: .init(), + headers: .init() + ) + + messageSubscription.emit(message) + } + } + + return messageSubscription + } + + // (CHA-M6a) A method must be exposed that accepts the standard Ably REST API query parameters. It shall call the “REST API”#rest-fetching-messages and return a PaginatedResult containing messages, which can then be paginated through. + public func get(options: QueryOptions) async throws -> any PaginatedResult { + try await chatAPI.getMessages(roomId: roomID, params: options) + } + + public func send(params: SendMessageParams) async throws -> Message { + try await chatAPI.sendMessage(roomId: roomID, params: params) + } + + // TODO: (CHA-M7) Users may subscribe to discontinuity events to know when there’s been a break in messages 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 + public nonisolated func subscribeToDiscontinuities() -> Subscription { + fatalError("not implemented") + } + + public nonisolated func discontinuityDetected(reason: ARTErrorInfo?) { + print("Discontinuity detected: \(reason ?? .createUnknownError())") + } + + private func getBeforeSubscriptionStart(_ uuid: UUID, params: QueryOptions) async throws -> any PaginatedResult { + guard let subscriptionPoint = subscriptionPoints[uuid]?.timeserial else { + throw ARTErrorInfo.create( + withCode: 40000, + status: 400, + message: "cannot query history; listener has not been subscribed yet" + ) + } + + // (CHA-M5j) If the end parameter is specified and is more recent than the subscription point timeserial, the method must throw an ErrorInfo with code 40000. + let parseSerial = try? DefaultTimeserial.calculateTimeserial(from: subscriptionPoint) + if let end = params.end, end > parseSerial?.timestamp ?? 0 { + throw ARTErrorInfo.create( + withCode: 40000, + status: 400, + message: "cannot query history; end time is after the subscription point of the listener" + ) + } + + // (CHA-M5f) This method must accept any of the standard history query options, except for direction, which must always be backwards. + var queryOptions = params + queryOptions.orderBy = .newestFirst // newestFirst is equivalent to backwards + + // (CHA-M5g) The subscribers subscription point must be additionally specified (internally, by us) in the fromSerial query parameter. + queryOptions.timeSerial = subscriptionPoint + + return try await chatAPI.getMessages(roomId: roomID, params: queryOptions) + } + + private func handleChannelEvents(roomId _: String) { + // (CHA-M5c) If a channel leaves the ATTACHED state and then re-enters ATTACHED with resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial. + channel.on(.attached) { [weak self] stateChange in + Task { + do { + try await self?.handleAttach(fromResume: stateChange.resumed) + return + } catch { + throw ARTErrorInfo.create(from: error) + } + } + } + + // (CHA-M4d) If a channel UPDATE event is received and resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial. + channel.on(.update) { [weak self] stateChange in + if stateChange.current == .attached, stateChange.previous == .attached { + Task { + do { + try await self?.handleAttach(fromResume: stateChange.resumed) + return + } catch { + throw ARTErrorInfo.create(from: error) + } + } + } + } + } + + // (CHA-M4a) A subscription can be registered to receive incoming messages. Adding a subscription has no side effects on the status of the room or the underlying realtime channel. + private func handleAttach(fromResume: Bool) async throws { + // Do nothing if we have resumed as there is no discontinuity in the message stream + if fromResume { + return + } + + do { + let newSubscriptionStartResolver = try await subscribeAtChannelAttach() + + for uuid in subscriptionPoints.keys { + subscriptionPoints[uuid]?.timeserial = newSubscriptionStartResolver + } + } catch { + throw ARTErrorInfo.create(from: error) + } + } + + private func resolveSubscriptionStart() async throws -> FromSerial { + // (CHA-M5a) If a subscription is added when the underlying realtime channel is ATTACHED, then the subscription point is the current channelSerial of the realtime channel. + if channel.state == .attached { + if let channelSerial = channel.properties.channelSerial { + return channelSerial + } else { + throw ARTErrorInfo.create(withCode: 40000, status: 400, message: "channel is attached, but channelSerial is not defined") + } + } + + // (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 subscribeAtChannelAttach() + } + + // (CHA-M4b) A subscription can de-registered from incoming messages. Removing a subscription has no side effects on the status of the room or the underlying realtime channel. + private func removeSubscriptionPoint(_ uuid: UUID) { + subscriptionPoints.removeValue(forKey: uuid) + } + + // Always returns the attachSerial and not the channelSerial to also serve (CHA-M5c) - If a channel leaves the ATTACHED state and then re-enters ATTACHED with resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial. + private func subscribeAtChannelAttach() async throws -> String { + // If the state is already 'attached', return the attachSerial immediately + if channel.state == .attached { + if let attachSerial = channel.properties.attachSerial { + return attachSerial + } else { + throw ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined") + } + } + + // (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 + 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) + } else { + continuation.resume(throwing: ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined")) + } + case .failed, .suspended: + // TODO: Revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/32 + continuation.resume( + throwing: ARTErrorInfo.create( + withCode: ErrorCode.messagesAttachmentFailed.rawValue, + status: ErrorCode.messagesAttachmentFailed.statusCode, + message: "Channel failed to attach" + ) + ) + default: + break + } + } + } + } + + internal enum MessagesError: Error { + case noReferenceToSelf + } +} diff --git a/Sources/AblyChat/Dependencies.swift b/Sources/AblyChat/Dependencies.swift index e0d1e184..ecf5edd9 100644 --- a/Sources/AblyChat/Dependencies.swift +++ b/Sources/AblyChat/Dependencies.swift @@ -15,8 +15,25 @@ public protocol RealtimeChannelsProtocol: ARTRealtimeChannelsProtocol, Sendable associatedtype Channel: RealtimeChannelProtocol // It’s not clear to me why ARTRealtimeChannelsProtocol doesn’t include this property (https://github.com/ably/ably-cocoa/issues/1968). + func get(_ name: String, options: ARTRealtimeChannelOptions) -> Channel func get(_ name: String) -> Channel } /// Expresses the requirements of the object returned by ``RealtimeChannelsProtocol.get(_:)``. 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() + + // Merge params if available, using defaultChannelOptions as fallback + resolvedOptions.params = opts?.params?.merging( + defaultChannelOptions.params ?? [:] + ) { _, new in new } + + // Return the resolved channel + return channels.get(name, options: resolvedOptions) + } +} diff --git a/Sources/AblyChat/EmitsDiscontinuities.swift b/Sources/AblyChat/EmitsDiscontinuities.swift index ed3119f7..e9a0d5e5 100644 --- a/Sources/AblyChat/EmitsDiscontinuities.swift +++ b/Sources/AblyChat/EmitsDiscontinuities.swift @@ -3,3 +3,18 @@ import Ably public protocol EmitsDiscontinuities { func subscribeToDiscontinuities() -> Subscription } + +/** + * Represents an object that has a channel and therefore may care about discontinuities. + */ +@MainActor +internal protocol HandlesDiscontinuity { + var channel: RealtimeChannelProtocol { get } + +// var channel: RealtimeChannelProtocol? { get } + /** + * Called when a discontinuity is detected on the channel. + * @param reason The error that caused the discontinuity. + */ + func discontinuityDetected(reason: ARTErrorInfo?) +} diff --git a/Sources/AblyChat/Events.swift b/Sources/AblyChat/Events.swift new file mode 100644 index 00000000..cd2d5fb0 --- /dev/null +++ b/Sources/AblyChat/Events.swift @@ -0,0 +1,3 @@ +internal enum MessageEvent: String { + case created = "message.created" +} diff --git a/Sources/AblyChat/Headers.swift b/Sources/AblyChat/Headers.swift index 9735a7fe..febf6be8 100644 --- a/Sources/AblyChat/Headers.swift +++ b/Sources/AblyChat/Headers.swift @@ -1,8 +1,8 @@ import Foundation -public enum HeadersValue: Sendable { +public enum HeadersValue: Sendable, Codable, Hashable { case string(String) - case number(NSNumber) + case number(Int) case bool(Bool) case null } diff --git a/Sources/AblyChat/Message.swift b/Sources/AblyChat/Message.swift index 92ce94f4..ff2991b3 100644 --- a/Sources/AblyChat/Message.swift +++ b/Sources/AblyChat/Message.swift @@ -3,16 +3,18 @@ import Foundation public typealias MessageHeaders = Headers public typealias MessageMetadata = Metadata -public struct Message: Sendable { +// (CHA-M2) A Message corresponds to a single message in a chat room. This is analogous to a single user-specified message on an Ably channel (NOTE: not a ProtocolMessage). +// Must conform to Hashable to use some SwiftUI niceties e.g. 'ForEach'. +public struct Message: Sendable, Codable, Hashable { public var timeserial: String - public var clientID: String + public var clientID: String? public var roomID: String public var text: String - public var createdAt: Date + public var createdAt: Date? public var metadata: MessageMetadata public var headers: MessageHeaders - public init(timeserial: String, clientID: String, roomID: String, text: String, createdAt: Date, metadata: MessageMetadata, headers: MessageHeaders) { + public init(timeserial: String, clientID: String?, roomID: String, text: String, createdAt: Date?, metadata: MessageMetadata, headers: MessageHeaders) { self.timeserial = timeserial self.clientID = clientID self.roomID = roomID @@ -22,15 +24,31 @@ public struct Message: Sendable { self.headers = headers } - public func isBefore(_: Message) -> Bool { - fatalError("Not yet implemented") + internal enum CodingKeys: String, CodingKey { + case timeserial + case clientID = "clientId" + case roomID = "roomId" + case text + case createdAt + case metadata + case headers } - public func isAfter(_: Message) -> Bool { - fatalError("Not yet implemented") + // (CHA-M2a) A Message is considered before another Message in the global order if the timeserial of the corresponding realtime channel message comes first. + public func isBefore(_ otherMessage: Message) throws -> Bool { + let otherMessageTimeserial = try DefaultTimeserial.calculateTimeserial(from: otherMessage.timeserial) + return try DefaultTimeserial.calculateTimeserial(from: timeserial).before(otherMessageTimeserial) } - public func isEqual(_: Message) -> Bool { - fatalError("Not yet implemented") + // CHA-M2b) A Message is considered after another Message in the global order if the timeserial of the corresponding realtime channel message comes second. + public func isAfter(_ otherMessage: Message) throws -> Bool { + let otherMessageTimeserial = try DefaultTimeserial.calculateTimeserial(from: otherMessage.timeserial) + return try DefaultTimeserial.calculateTimeserial(from: timeserial).after(otherMessageTimeserial) + } + + // (CHA-M2c) A Message is considered to be equal to another Message if they have the same timeserial. + public func isEqual(_ otherMessage: Message) throws -> Bool { + let otherMessageTimeserial = try DefaultTimeserial.calculateTimeserial(from: otherMessage.timeserial) + return try DefaultTimeserial.calculateTimeserial(from: timeserial).equal(otherMessageTimeserial) } } diff --git a/Sources/AblyChat/Messages.swift b/Sources/AblyChat/Messages.swift index 26a484d9..fc05c23f 100644 --- a/Sources/AblyChat/Messages.swift +++ b/Sources/AblyChat/Messages.swift @@ -1,10 +1,12 @@ import Ably +// MainActor is required to resolve a Swift 6.0 error "Incorrect actor executor assumption", whilst also allowing for mutations on listeners within the concrete class. +@MainActor public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities { - func subscribe(bufferingPolicy: BufferingPolicy) -> MessageSubscription + func subscribe(bufferingPolicy: BufferingPolicy) async throws -> MessageSubscription func get(options: QueryOptions) async throws -> any PaginatedResult func send(params: SendMessageParams) async throws -> Message - var channel: ARTRealtimeChannelProtocol { get } + var channel: RealtimeChannelProtocol { get } } public struct SendMessageParams: Sendable { @@ -19,6 +21,7 @@ public struct SendMessageParams: Sendable { } } +// TODO: Start and End can be Dates in Swift so should be... will revisit this to properly convert from a timeserial represented as an Int to a Date and back. https://github.com/ably-labs/ably-chat-swift/issues/78 public struct QueryOptions: Sendable { public enum ResultOrder: Sendable { case oldestFirst @@ -26,11 +29,14 @@ public struct QueryOptions: Sendable { } public var start: Date? - public var end: Date? + public var end: Int? // represented as timeserial, represented as a number in JS. public var limit: Int? public var orderBy: ResultOrder? - public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil, orderBy: QueryOptions.ResultOrder? = nil) { + // (CHA-M5g) The subscribers subscription point must be additionally specified (internally, by us) in the fromSerial query parameter. + internal var timeSerial: String? + + public init(start: Date? = nil, end: Int? = nil, limit: Int? = nil, orderBy: QueryOptions.ResultOrder? = nil) { self.start = start self.end = end self.limit = limit @@ -38,15 +44,35 @@ public struct QueryOptions: Sendable { } } -public struct QueryOptionsWithoutDirection: Sendable { - public var start: Date? - public var end: Date? - public var limit: Int? +public extension QueryOptions { + func toDictionary() -> [String: String] { + var dict: [String: String] = [:] + if let start { + dict["start"] = "\(start)" + } - public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil) { - self.start = start - self.end = end - self.limit = limit + if let end { + dict["end"] = "\(end)" + } + + if let limit { + dict["limit"] = "\(limit)" + } + + if let orderBy { + switch orderBy { + case .oldestFirst: + dict["direction"] = "forwards" + case .newestFirst: + dict["direction"] = "backwards" + } + } + + if let timeSerial { + dict["fromSerial"] = timeSerial + } + + return dict } } @@ -56,26 +82,30 @@ public struct MessageSubscription: Sendable, AsyncSequence { private var subscription: Subscription - private var mockGetPreviousMessages: (@Sendable (QueryOptionsWithoutDirection) async throws -> any PaginatedResult)? + // can be set by either initialiser + private let getPreviousMessages: @Sendable (QueryOptions) async throws -> any PaginatedResult - internal init(bufferingPolicy: BufferingPolicy) { + // used internally + internal init( + bufferingPolicy: BufferingPolicy, + getPreviousMessages: @escaping @Sendable (QueryOptions) async throws -> any PaginatedResult + ) { subscription = .init(bufferingPolicy: bufferingPolicy) + self.getPreviousMessages = getPreviousMessages } - public init(mockAsyncSequence: T, mockGetPreviousMessages: @escaping @Sendable (QueryOptionsWithoutDirection) async throws -> any PaginatedResult) where T.Element == Element { + // used for testing + public init(mockAsyncSequence: T, mockGetPreviousMessages: @escaping @Sendable (QueryOptions) async throws -> any PaginatedResult) where T.Element == Element { subscription = .init(mockAsyncSequence: mockAsyncSequence) - self.mockGetPreviousMessages = mockGetPreviousMessages + getPreviousMessages = mockGetPreviousMessages } internal func emit(_ element: Element) { subscription.emit(element) } - public func getPreviousMessages(params: QueryOptionsWithoutDirection) async throws -> any PaginatedResult { - guard let mockImplementation = mockGetPreviousMessages else { - fatalError("Not yet implemented") - } - return try await mockImplementation(params) + public func getPreviousMessages(params: QueryOptions) async throws -> any PaginatedResult { + try await getPreviousMessages(params) } public struct AsyncIterator: AsyncIteratorProtocol { diff --git a/Sources/AblyChat/Metadata.swift b/Sources/AblyChat/Metadata.swift index e6f94f01..adf00eb1 100644 --- a/Sources/AblyChat/Metadata.swift +++ b/Sources/AblyChat/Metadata.swift @@ -1,2 +1,11 @@ // TODO: (https://github.com/ably-labs/ably-chat-swift/issues/13): try to improve this type -public typealias Metadata = [String: (any Sendable)?] +// I attempted to address this issue by making a struct conforming to Codable which would at least give us some safety in knowing items can be encoded and decoded. However, the requirement for `Messages` to be Hashable made this difficult. Gone for the same approach as Headers for now, we can investigate whether we need to be open to more types than this later. + +public enum MetadataValue: Sendable, Codable, Hashable { + case string(String) + case number(Int) + case bool(Bool) + case null +} + +public typealias Metadata = [String: MetadataValue?] diff --git a/Sources/AblyChat/Occupancy.swift b/Sources/AblyChat/Occupancy.swift index 58c68078..550f11dc 100644 --- a/Sources/AblyChat/Occupancy.swift +++ b/Sources/AblyChat/Occupancy.swift @@ -6,7 +6,7 @@ public protocol Occupancy: AnyObject, Sendable, EmitsDiscontinuities { var channel: ARTRealtimeChannelProtocol { get } } -public struct OccupancyEvent: Sendable { +public struct OccupancyEvent: Sendable, Encodable, Decodable { public var connections: Int public var presenceMembers: Int diff --git a/Sources/AblyChat/PaginatedResult.swift b/Sources/AblyChat/PaginatedResult.swift index da3142d1..6d2680c0 100644 --- a/Sources/AblyChat/PaginatedResult.swift +++ b/Sources/AblyChat/PaginatedResult.swift @@ -1,3 +1,5 @@ +import Ably + public protocol PaginatedResult: AnyObject, Sendable { associatedtype T @@ -9,3 +11,85 @@ public protocol PaginatedResult: AnyObject, Sendable { var first: any PaginatedResult { get async throws } var current: any PaginatedResult { get async throws } } + +/// Used internally to reduce the amount of duplicate code when interacting with `ARTHTTPPaginatedCallback`'s. The wrapper takes in the callback result from the caller e.g. `realtime.request` and either throws the appropriate error, or decodes and returns the response. +internal struct ARTHTTPPaginatedCallbackWrapper { + internal let callbackResult: (ARTHTTPPaginatedResponse?, ARTErrorInfo?) + + internal func handleResponse(continuation: CheckedContinuation, any Error>) { + let (paginatedResponse, error) = callbackResult + + // (CHA-M5i) If the REST API returns an error, then the method must throw its ErrorInfo representation. + // (CHA-M6b) If the REST API returns an error, then the method must throw its ErrorInfo representation. + if let error { + continuation.resume(throwing: ARTErrorInfo.create(from: error)) + return + } + + guard let paginatedResponse, paginatedResponse.statusCode == 200 else { + continuation.resume(throwing: PaginatedResultError.noErrorWithInvalidResponse) + return + } + + do { + let decodedResponse = try DictionaryDecoder().decode([Response].self, from: paginatedResponse.items) + let result = paginatedResponse.toPaginatedResult(items: decodedResponse) + continuation.resume(returning: result) + } catch { + continuation.resume(throwing: error) + } + } + + internal enum PaginatedResultError: Error { + case noErrorWithInvalidResponse + } +} + +/// `PaginatedResult` protocol implementation allowing access to the underlying items from a lower level paginated response object e.g. `ARTHTTPPaginatedResponse`, whilst succinctly handling errors through the use of `ARTHTTPPaginatedCallbackWrapper`. +internal final class PaginatedResultWrapper: PaginatedResult { + internal let items: [T] + internal let hasNext: Bool + internal let isLast: Bool + internal let paginatedResponse: ARTHTTPPaginatedResponse + + internal init(paginatedResponse: ARTHTTPPaginatedResponse, items: [T]) { + self.items = items + hasNext = paginatedResponse.hasNext + isLast = paginatedResponse.isLast + self.paginatedResponse = paginatedResponse + } + + /// Asynchronously fetch the next page if available + internal var next: (any PaginatedResult)? { + get async throws { + try await withCheckedThrowingContinuation { continuation in + paginatedResponse.next { paginatedResponse, error in + ARTHTTPPaginatedCallbackWrapper(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation) + } + } + } + } + + /// Asynchronously fetch the first page + internal var first: any PaginatedResult { + get async throws { + try await withCheckedThrowingContinuation { continuation in + paginatedResponse.first { paginatedResponse, error in + ARTHTTPPaginatedCallbackWrapper(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation) + } + } + } + } + + /// Asynchronously fetch the current page + internal var current: any PaginatedResult { + self + } +} + +private extension ARTHTTPPaginatedResponse { + /// Converts an `ARTHTTPPaginatedResponse` to a `PaginatedResultWrapper` allowing for access to operations as per conformance to `PaginatedResult`. + func toPaginatedResult(items: [T]) -> PaginatedResultWrapper { + PaginatedResultWrapper(paginatedResponse: self, items: items) + } +} diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index e4660c8d..047504e8 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -20,6 +20,9 @@ public protocol Room: AnyObject, Sendable { internal actor DefaultRoom: Room { internal nonisolated let roomID: String internal nonisolated let options: RoomOptions + private let chatAPI: ChatAPI + + private let _messages: any Messages // Exposed for testing. private nonisolated let realtime: RealtimeClient @@ -33,16 +36,23 @@ internal actor DefaultRoom: Room { private let _status: DefaultRoomStatus private let logger: InternalLogger - internal init(realtime: RealtimeClient, roomID: String, options: RoomOptions, logger: InternalLogger) { + internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) { self.realtime = realtime self.roomID = roomID self.options = options self.logger = logger _status = .init(logger: logger) + self.chatAPI = chatAPI + + _messages = DefaultMessages( + chatAPI: chatAPI, + roomID: roomID, + clientID: realtime.clientId ?? "" + ) } public nonisolated var messages: any Messages { - fatalError("Not yet implemented") + _messages } public nonisolated var presence: any Presence { diff --git a/Sources/AblyChat/Rooms.swift b/Sources/AblyChat/Rooms.swift index 87f1d7c6..7a9df076 100644 --- a/Sources/AblyChat/Rooms.swift +++ b/Sources/AblyChat/Rooms.swift @@ -39,7 +39,7 @@ internal actor DefaultRooms: Rooms { return existingRoom } else { - let room = DefaultRoom(realtime: realtime, roomID: roomID, options: options, logger: logger) + let room = DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: roomID, options: options, logger: logger) rooms[roomID] = room return room } diff --git a/Sources/AblyChat/Timeserial.swift b/Sources/AblyChat/Timeserial.swift new file mode 100644 index 00000000..97aadb71 --- /dev/null +++ b/Sources/AblyChat/Timeserial.swift @@ -0,0 +1,94 @@ +import Foundation + +internal protocol Timeserial: Sendable { + var seriesId: String { get } + var timestamp: Int { get } + var counter: Int { get } + var index: Int? { get } + + func before(_ timeserial: Timeserial) -> Bool + func after(_ timeserial: Timeserial) -> Bool + func equal(_ timeserial: Timeserial) -> Bool +} + +internal struct DefaultTimeserial: Timeserial { + internal let seriesId: String + internal let timestamp: Int + internal let counter: Int + internal let index: Int? + + private init(seriesId: String, timestamp: Int, counter: Int, index: Int?) { + self.seriesId = seriesId + self.timestamp = timestamp + self.counter = counter + self.index = index + } + + // Static method to parse a timeserial string + internal static func calculateTimeserial(from timeserial: String) throws -> DefaultTimeserial { + let components = timeserial.split(separator: "@") + guard components.count == 2, let rest = components.last else { + throw TimeserialError.invalidFormat + } + + let seriesId = String(components[0]) + let parts = rest.split(separator: "-") + guard parts.count == 2 else { + throw TimeserialError.invalidFormat + } + + let timestamp = Int(parts[0]) ?? 0 + let counterAndIndex = parts[1].split(separator: ":") + let counter = Int(counterAndIndex[0]) ?? 0 + let index = counterAndIndex.count > 1 ? Int(counterAndIndex[1]) : nil + + return DefaultTimeserial(seriesId: seriesId, timestamp: timestamp, counter: counter, index: index) + } + + // Compare timeserials + private func timeserialCompare(_ other: Timeserial) -> Int { + // Compare timestamps + let timestampDiff = timestamp - other.timestamp + if timestampDiff != 0 { + return timestampDiff + } + + // Compare counters + let counterDiff = counter - other.counter + if counterDiff != 0 { + return counterDiff + } + + // Compare seriesId lexicographically + if seriesId != other.seriesId { + return seriesId < other.seriesId ? -1 : 1 + } + + // Compare index if present + if let idx1 = index, let idx2 = other.index { + return idx1 - idx2 + } + + return 0 + } + + // Check if this timeserial is before the given timeserial + internal func before(_ timeserial: Timeserial) -> Bool { + timeserialCompare(timeserial) < 0 + } + + // Check if this timeserial is after the given timeserial + internal func after(_ timeserial: Timeserial) -> Bool { + timeserialCompare(timeserial) > 0 + } + + // Check if this timeserial is equal to the given timeserial + internal func equal(_ timeserial: Timeserial) -> Bool { + timeserialCompare(timeserial) == 0 + } + + // TODO: Revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/32 (should we only throw ARTErrors?) + internal enum TimeserialError: Error { + case invalidFormat + } +} diff --git a/Sources/AblyChat/Version.swift b/Sources/AblyChat/Version.swift new file mode 100644 index 00000000..d7786daa --- /dev/null +++ b/Sources/AblyChat/Version.swift @@ -0,0 +1,18 @@ +@preconcurrency import Ably + +// TODO: Just copied chat-js implementation for now to send up agent info. https://github.com/ably-labs/ably-chat-swift/issues/76 + +// Update this when you release a new version +// Version information +public let version = "0.1.0" + +// Channel options agent string +public let channelOptionsAgentString = "chat-ios/\(version)" + +// Default channel options +// Let 'defaultChannelOptions' is not concurrency-safe because non-'Sendable' type 'ARTRealtimeChannelOptions' may have shared mutable state - marked Ably import with @preconcurrency for now. +public let defaultChannelOptions: ARTRealtimeChannelOptions = { + let options = ARTRealtimeChannelOptions() + options.params = ["agent": channelOptionsAgentString] + return options +}() diff --git a/Tests/AblyChatTests/DefaultRoomTests.swift b/Tests/AblyChatTests/DefaultRoomTests.swift index 8956a7e0..5969896f 100644 --- a/Tests/AblyChatTests/DefaultRoomTests.swift +++ b/Tests/AblyChatTests/DefaultRoomTests.swift @@ -17,7 +17,7 @@ struct DefaultRoomTests { ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init(), logger: TestLogger()) + let room = DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) let subscription = await room.status.onChange(bufferingPolicy: .unbounded) async let attachedStatusChange = subscription.first { $0.current == .attached } @@ -50,7 +50,7 @@ struct DefaultRoomTests { ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init(), logger: TestLogger()) + let room = DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) // When: `attach` is called on the room let roomAttachError: Error? @@ -79,7 +79,7 @@ struct DefaultRoomTests { ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init(), logger: TestLogger()) + let room = DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) let subscription = await room.status.onChange(bufferingPolicy: .unbounded) async let detachedStatusChange = subscription.first { $0.current == .detached } @@ -112,7 +112,7 @@ struct DefaultRoomTests { ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init(), logger: TestLogger()) + let room = DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) // When: `detach` is called on the room let roomDetachError: Error? diff --git a/Tests/AblyChatTests/DefaultRoomsTests.swift b/Tests/AblyChatTests/DefaultRoomsTests.swift index adf9fafd..75da3c1a 100644 --- a/Tests/AblyChatTests/DefaultRoomsTests.swift +++ b/Tests/AblyChatTests/DefaultRoomsTests.swift @@ -6,7 +6,7 @@ struct DefaultRoomsTests { @Test func get_returnsRoomWithGivenID() async throws { // Given: an instance of DefaultRooms - let realtime = MockRealtime.create() + let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")])) let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger()) // When: get(roomID:options:) is called @@ -25,7 +25,7 @@ struct DefaultRoomsTests { @Test func get_returnsExistingRoomWithGivenID() async throws { // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID - let realtime = MockRealtime.create() + let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")])) let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger()) let roomID = "basketball" @@ -43,7 +43,7 @@ struct DefaultRoomsTests { @Test func get_throwsErrorWhenOptionsDoNotMatch() async throws { // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID and options - let realtime = MockRealtime.create() + let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")])) let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger()) let roomID = "basketball" diff --git a/Tests/AblyChatTests/MessageSubscriptionTests.swift b/Tests/AblyChatTests/MessageSubscriptionTests.swift index 5babe7b3..dd599b7a 100644 --- a/Tests/AblyChatTests/MessageSubscriptionTests.swift +++ b/Tests/AblyChatTests/MessageSubscriptionTests.swift @@ -32,7 +32,7 @@ struct MessageSubscriptionTests { @Test func emit() async { - let subscription = MessageSubscription(bufferingPolicy: .unbounded) + let subscription = MessageSubscription(bufferingPolicy: .unbounded) { _ in fatalError("Not implemented") } async let emittedElements = Array(subscription.prefix(2)) diff --git a/Tests/AblyChatTests/Mocks/MockChannels.swift b/Tests/AblyChatTests/Mocks/MockChannels.swift index 6cbf82b1..68f6a2eb 100644 --- a/Tests/AblyChatTests/Mocks/MockChannels.swift +++ b/Tests/AblyChatTests/Mocks/MockChannels.swift @@ -16,6 +16,10 @@ final class MockChannels: RealtimeChannelsProtocol, Sendable { return channel } + func get(_ name: String, options _: ARTRealtimeChannelOptions) -> MockRealtimeChannel { + get(name) + } + func exists(_: String) -> Bool { fatalError("Not implemented") } diff --git a/Tests/AblyChatTests/Mocks/MockRealtime.swift b/Tests/AblyChatTests/Mocks/MockRealtime.swift index e8c82779..8102bf3e 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtime.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtime.swift @@ -9,7 +9,7 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { } var clientId: String? { - fatalError("Not implemented") + "mockClientId" } required init(options _: ARTClientOptions) { @@ -62,4 +62,8 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { func close() { fatalError("Not implemented") } + + func request(_: String, path _: String, params _: [String: String]?, body _: Any?, headers _: [String: String]?, callback _: @escaping ARTHTTPPaginatedCallback) throws { + fatalError("Not implemented") + } } diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift index f01f70b2..74c20293 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift @@ -1,7 +1,9 @@ -import Ably +@preconcurrency import Ably import AblyChat final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { + let properties: ARTChannelProperties + private let _name: String? init( @@ -12,6 +14,7 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { _name = name self.attachResult = attachResult self.detachResult = detachResult + properties = .init() } /// A threadsafe counter that starts at zero. @@ -141,7 +144,7 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { } func on(_: ARTChannelEvent, callback _: @escaping (ARTChannelStateChange) -> Void) -> ARTEventListener { - fatalError("Not implemented") + ARTEventListener() } func on(_: @escaping (ARTChannelStateChange) -> Void) -> ARTEventListener {