diff --git a/.swiftlint.yml b/.swiftlint.yml index 36105ec..f27ca7f 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -77,7 +77,6 @@ opt_in_rules: # Opt-in rules of type "lint" that we’ve decided we want: - array_init - empty_xctest_method - - missing_docs - override_in_extension - yoda_condition - private_swiftui_state diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89b652b..93817e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,9 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with ` ## Development guidelines - The aim of the [example app](README.md#example-app) is that it demonstrate all of the core functionality of the SDK. So if you add a new feature, try to add something to the example app to demonstrate this feature. +- We should aim to make it easy for consumers of the SDK to be able to mock out the SDK in the tests for their own code. A couple of things that will aid with this: + - Describe the SDK’s functionality via protocols (when doing so would still be sufficiently idiomatic to Swift). + - When defining a `struct` that is emitted by the public API of the library, make sure to define a public memberwise initializer so that users can create one to be emitted by their mocks. (There is no way to make Swift’s autogenerated memberwise initializer public, so you will need to write one yourself. In Xcode, you can do this by clicking at the start of the type declaration and doing Editor → Refactor → Generate Memberwise Initializer.) ## Building for Swift 6 diff --git a/Example/AblyChatExample.xcodeproj/project.pbxproj b/Example/AblyChatExample.xcodeproj/project.pbxproj index 685c63a..a704d8b 100644 --- a/Example/AblyChatExample.xcodeproj/project.pbxproj +++ b/Example/AblyChatExample.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 212F95A72C6CAD9300420287 /* MockRealtime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 212F95A62C6CAD9300420287 /* MockRealtime.swift */; }; 21971DFF2C60D89C0074B8AE /* AblyChat in Frameworks */ = {isa = PBXBuildFile; productRef = 21971DFE2C60D89C0074B8AE /* AblyChat */; }; 21F09AA02C60CAF00025AF73 /* AblyChatExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21F09A9F2C60CAF00025AF73 /* AblyChatExampleApp.swift */; }; 21F09AA22C60CAF00025AF73 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21F09AA12C60CAF00025AF73 /* ContentView.swift */; }; @@ -15,6 +16,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 212F95A62C6CAD9300420287 /* MockRealtime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRealtime.swift; sourceTree = ""; }; 21F09A9C2C60CAF00025AF73 /* AblyChatExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AblyChatExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 21F09A9F2C60CAF00025AF73 /* AblyChatExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AblyChatExampleApp.swift; sourceTree = ""; }; 21F09AA12C60CAF00025AF73 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -35,6 +37,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 212F95A52C6CAD7E00420287 /* Mocks */ = { + isa = PBXGroup; + children = ( + 212F95A62C6CAD9300420287 /* MockRealtime.swift */, + ); + path = Mocks; + sourceTree = ""; + }; 21971DFD2C60D89C0074B8AE /* Frameworks */ = { isa = PBXGroup; children = ( @@ -62,6 +72,7 @@ 21F09A9E2C60CAF00025AF73 /* AblyChatExample */ = { isa = PBXGroup; children = ( + 212F95A52C6CAD7E00420287 /* Mocks */, 21F09A9F2C60CAF00025AF73 /* AblyChatExampleApp.swift */, 21F09AA12C60CAF00025AF73 /* ContentView.swift */, 21F09AA32C60CAF20025AF73 /* Assets.xcassets */, @@ -152,6 +163,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 212F95A72C6CAD9300420287 /* MockRealtime.swift in Sources */, 21F09AA22C60CAF00025AF73 /* ContentView.swift in Sources */, 21F09AA02C60CAF00025AF73 /* AblyChatExampleApp.swift in Sources */, ); diff --git a/Example/AblyChatExample/ContentView.swift b/Example/AblyChatExample/ContentView.swift index 6856f82..750c754 100644 --- a/Example/AblyChatExample/ContentView.swift +++ b/Example/AblyChatExample/ContentView.swift @@ -3,7 +3,10 @@ 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 = AblyChatClient() + @State private var ablyChatClient = DefaultChatClient( + realtime: MockRealtime(key: ""), + clientOptions: ClientOptions() + ) var body: some View { VStack { diff --git a/Example/AblyChatExample/Mocks/MockRealtime.swift b/Example/AblyChatExample/Mocks/MockRealtime.swift new file mode 100644 index 0000000..8aa0d01 --- /dev/null +++ b/Example/AblyChatExample/Mocks/MockRealtime.swift @@ -0,0 +1,40 @@ +import Ably + +/// A mock implementation of `ARTRealtimeProtocol`. 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 +class MockRealtime: NSObject, ARTRealtimeProtocol { + var device: ARTLocalDevice { + fatalError("Not implemented") + } + + var clientId: String? + + required init(options _: ARTClientOptions) {} + + required init(key _: String) {} + + required init(token _: String) {} + + func time(_: @escaping ARTDateTimeCallback) { + fatalError("Not implemented") + } + + func ping(_: @escaping ARTCallback) { + fatalError("Not implemented") + } + + func stats(_: @escaping ARTPaginatedStatsCallback) -> Bool { + fatalError("Not implemented") + } + + func stats(_: ARTStatsQuery?, callback _: @escaping ARTPaginatedStatsCallback) throws { + fatalError("Not implemented") + } + + func connect() { + fatalError("Not implemented") + } + + func close() { + fatalError("Not implemented") + } +} diff --git a/Sources/AblyChat/.swiftformat b/Sources/AblyChat/.swiftformat new file mode 100644 index 0000000..6252d93 --- /dev/null +++ b/Sources/AblyChat/.swiftformat @@ -0,0 +1,2 @@ +# To avoid clash with SwiftLint’s explicit_acl rule +--disable redundantInternal diff --git a/Sources/AblyChat/.swiftlint.yml b/Sources/AblyChat/.swiftlint.yml new file mode 100644 index 0000000..b358037 --- /dev/null +++ b/Sources/AblyChat/.swiftlint.yml @@ -0,0 +1,3 @@ +opt_in_rules: + # Opt-in rules of type "idiomatic" that we’ve decided we want: + - explicit_acl diff --git a/Sources/AblyChat/AblyChat.swift b/Sources/AblyChat/AblyChat.swift deleted file mode 100644 index dc0816d..0000000 --- a/Sources/AblyChat/AblyChat.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Ably - -/// Temporary class just used to check that the example app and tests can use the library. TODO remove this once we start building the library -public class AblyChatClient { - /// Initializes an instance of `AblyChatClient`. - public init() {} -} diff --git a/Sources/AblyChat/BufferingPolicy.swift b/Sources/AblyChat/BufferingPolicy.swift new file mode 100644 index 0000000..110dac3 --- /dev/null +++ b/Sources/AblyChat/BufferingPolicy.swift @@ -0,0 +1,7 @@ +// Describes what to do with realtime events that come in faster than the consumer of an `AsyncSequence` can handle them. +// (This is the same as `AsyncStream.Continuation.BufferingPolicy` but with the generic type parameter `T` removed.) +public enum BufferingPolicy: Sendable { + case unbounded + case bufferingOldest(Int) + case bufferingNewest(Int) +} diff --git a/Sources/AblyChat/ChatClient.swift b/Sources/AblyChat/ChatClient.swift new file mode 100644 index 0000000..aa919a4 --- /dev/null +++ b/Sources/AblyChat/ChatClient.swift @@ -0,0 +1,45 @@ +import Ably + +public protocol ChatClient: AnyObject, Sendable { + var rooms: any Rooms { get } + var connection: any Connection { get } + var clientID: String { get } + var realtime: any ARTRealtimeProtocol { get } + var clientOptions: ClientOptions { get } +} + +public final class DefaultChatClient: ChatClient { + public init(realtime _: ARTRealtimeProtocol, clientOptions _: ClientOptions?) { + // This one doesn’t do `fatalError`, so that I can call it in the example app + } + + public var rooms: any Rooms { + fatalError("Not yet implemented") + } + + public var connection: any Connection { + fatalError("Not yet implemented") + } + + public var clientID: String { + fatalError("Not yet implemented") + } + + public var realtime: any ARTRealtimeProtocol { + fatalError("Not yet implemented") + } + + public var clientOptions: ClientOptions { + fatalError("Not yet implemented") + } +} + +public struct ClientOptions: Sendable { + public var logHandler: LogHandler? + public var logLevel: LogLevel? + + public init(logHandler: (any LogHandler)? = nil, logLevel: LogLevel? = nil) { + self.logHandler = logHandler + self.logLevel = logLevel + } +} diff --git a/Sources/AblyChat/Connection.swift b/Sources/AblyChat/Connection.swift new file mode 100644 index 0000000..c53e954 --- /dev/null +++ b/Sources/AblyChat/Connection.swift @@ -0,0 +1,36 @@ +import Ably + +public protocol Connection: AnyObject, Sendable { + var status: any ConnectionStatus { get } +} + +public protocol ConnectionStatus: AnyObject, Sendable { + var current: ConnectionLifecycle { get } + // TODO: (https://github.com/ably-labs/ably-chat-swift/issues/12): consider how to avoid the need for an unwrap + var error: ARTErrorInfo? { get } + func onChange(bufferingPolicy: BufferingPolicy) -> Subscription +} + +public enum ConnectionLifecycle: Sendable { + case initialized + case connecting + case connected + case disconnected + case suspended + case failed +} + +public struct ConnectionStatusChange: Sendable { + public var current: ConnectionLifecycle + public var previous: ConnectionLifecycle + // TODO: (https://github.com/ably-labs/ably-chat-swift/issues/12): consider how to avoid the need for an unwrap + public var error: ARTErrorInfo? + public var retryIn: TimeInterval + + public init(current: ConnectionLifecycle, previous: ConnectionLifecycle, error: ARTErrorInfo? = nil, retryIn: TimeInterval) { + self.current = current + self.previous = previous + self.error = error + self.retryIn = retryIn + } +} diff --git a/Sources/AblyChat/EmitsDiscontinuities.swift b/Sources/AblyChat/EmitsDiscontinuities.swift new file mode 100644 index 0000000..ed3119f --- /dev/null +++ b/Sources/AblyChat/EmitsDiscontinuities.swift @@ -0,0 +1,5 @@ +import Ably + +public protocol EmitsDiscontinuities { + func subscribeToDiscontinuities() -> Subscription +} diff --git a/Sources/AblyChat/Headers.swift b/Sources/AblyChat/Headers.swift new file mode 100644 index 0000000..9735a7f --- /dev/null +++ b/Sources/AblyChat/Headers.swift @@ -0,0 +1,13 @@ +import Foundation + +public enum HeadersValue: Sendable { + case string(String) + case number(NSNumber) + case bool(Bool) + case null +} + +// The corresponding type in TypeScript is +// Record +// There may be a better way to represent it in Swift; this will do for now. Have omitted `undefined` because I don’t know how that would occur. +public typealias Headers = [String: HeadersValue] diff --git a/Sources/AblyChat/Logging.swift b/Sources/AblyChat/Logging.swift new file mode 100644 index 0000000..307c26a --- /dev/null +++ b/Sources/AblyChat/Logging.swift @@ -0,0 +1,14 @@ +public typealias LogContext = [String: any Sendable] + +public protocol LogHandler: AnyObject, Sendable { + func log(message: String, level: LogLevel, context: LogContext?) +} + +public enum LogLevel: Sendable { + case trace + case debug + case info + case warn + case error + case silent +} diff --git a/Sources/AblyChat/Message.swift b/Sources/AblyChat/Message.swift new file mode 100644 index 0000000..92ce94f --- /dev/null +++ b/Sources/AblyChat/Message.swift @@ -0,0 +1,36 @@ +import Foundation + +public typealias MessageHeaders = Headers +public typealias MessageMetadata = Metadata + +public struct Message: Sendable { + public var timeserial: String + public var clientID: String + public var roomID: String + public var text: String + 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) { + self.timeserial = timeserial + self.clientID = clientID + self.roomID = roomID + self.text = text + self.createdAt = createdAt + self.metadata = metadata + self.headers = headers + } + + public func isBefore(_: Message) -> Bool { + fatalError("Not yet implemented") + } + + public func isAfter(_: Message) -> Bool { + fatalError("Not yet implemented") + } + + public func isEqual(_: Message) -> Bool { + fatalError("Not yet implemented") + } +} diff --git a/Sources/AblyChat/Messages.swift b/Sources/AblyChat/Messages.swift new file mode 100644 index 0000000..9f7a620 --- /dev/null +++ b/Sources/AblyChat/Messages.swift @@ -0,0 +1,74 @@ +import Ably + +public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities { + func subscribe(bufferingPolicy: BufferingPolicy) -> MessageSubscription + func get(options: QueryOptions) async throws -> any PaginatedResult + func send(params: SendMessageParams) async throws -> Message + var channel: ARTRealtimeChannelProtocol { get } +} + +public struct SendMessageParams: Sendable { + public var text: String + public var metadata: MessageMetadata? + public var headers: MessageHeaders? + + public init(text: String, metadata: MessageMetadata? = nil, headers: MessageHeaders? = nil) { + self.text = text + self.metadata = metadata + self.headers = headers + } +} + +public struct QueryOptions: Sendable { + public enum Direction: Sendable { + case forwards + case backwards + } + + public var start: Date? + public var end: Date? + public var limit: Int? + public var direction: Direction? + + public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil, direction: QueryOptions.Direction? = nil) { + self.start = start + self.end = end + self.limit = limit + self.direction = direction + } +} + +public struct QueryOptionsWithoutDirection: Sendable { + public var start: Date? + public var end: Date? + public var limit: Int? + + public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil) { + self.start = start + self.end = end + self.limit = limit + } +} + +// Currently a copy-and-paste of `Subscription`; see notes on that one. For `MessageSubscription`, my intention is that the `BufferingPolicy` passed to `subscribe(bufferingPolicy:)` will also define what the `MessageSubscription` does with messages that are received _before_ the user starts iterating over the sequence (this buffering will allow us to implement the requirement that there be no discontinuity between the the last message returned by `getPreviousMessages` and the first element you get when you iterate). +public struct MessageSubscription: Sendable, AsyncSequence { + public typealias Element = Message + + public init(mockAsyncSequence _: T) where T.Element == Element { + fatalError("Not yet implemented") + } + + public func getPreviousMessages(params _: QueryOptionsWithoutDirection) async throws -> any PaginatedResult { + fatalError("Not yet implemented") + } + + public struct AsyncIterator: AsyncIteratorProtocol { + public mutating func next() async -> Element? { + fatalError("Not implemented") + } + } + + public func makeAsyncIterator() -> AsyncIterator { + fatalError("Not implemented") + } +} diff --git a/Sources/AblyChat/Metadata.swift b/Sources/AblyChat/Metadata.swift new file mode 100644 index 0000000..e6f94f0 --- /dev/null +++ b/Sources/AblyChat/Metadata.swift @@ -0,0 +1,2 @@ +// TODO: (https://github.com/ably-labs/ably-chat-swift/issues/13): try to improve this type +public typealias Metadata = [String: (any Sendable)?] diff --git a/Sources/AblyChat/Occupancy.swift b/Sources/AblyChat/Occupancy.swift new file mode 100644 index 0000000..58c6807 --- /dev/null +++ b/Sources/AblyChat/Occupancy.swift @@ -0,0 +1,17 @@ +import Ably + +public protocol Occupancy: AnyObject, Sendable, EmitsDiscontinuities { + func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription + func get() async throws -> OccupancyEvent + var channel: ARTRealtimeChannelProtocol { get } +} + +public struct OccupancyEvent: Sendable { + public var connections: Int + public var presenceMembers: Int + + public init(connections: Int, presenceMembers: Int) { + self.connections = connections + self.presenceMembers = presenceMembers + } +} diff --git a/Sources/AblyChat/PaginatedResult.swift b/Sources/AblyChat/PaginatedResult.swift new file mode 100644 index 0000000..d849b89 --- /dev/null +++ b/Sources/AblyChat/PaginatedResult.swift @@ -0,0 +1,11 @@ +public protocol PaginatedResult: AnyObject, Sendable { + associatedtype T + + var items: [T] { get } + var hasNext: Bool { get } + var isLast: Bool { get } + // TODO: (https://github.com/ably-labs/ably-chat-swift/issues/11): consider how to avoid the need for an unwrap + var next: (any PaginatedResult)? { get async throws } + var first: any PaginatedResult { get async throws } + var current: Bool { get async throws } +} diff --git a/Sources/AblyChat/Presence.swift b/Sources/AblyChat/Presence.swift new file mode 100644 index 0000000..dc9773f --- /dev/null +++ b/Sources/AblyChat/Presence.swift @@ -0,0 +1,63 @@ +import Ably + +// TODO: (https://github.com/ably-labs/ably-chat-swift/issues/13): try to improve this type +public typealias PresenceData = any Sendable + +public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { + func get() async throws -> any PaginatedResult<[PresenceMember]> + func get(params: ARTRealtimePresenceQuery?) async throws -> any PaginatedResult<[PresenceMember]> + func isUserPresent(clientID: String) async throws -> Bool + func enter() async throws + func enter(data: PresenceData) async throws + func update() async throws + func update(data: PresenceData) async throws + func leave() async throws + func leave(data: PresenceData) async throws + func subscribe(event: PresenceEventType) -> Subscription + func subscribe(events: [PresenceEventType]) -> Subscription +} + +public struct PresenceMember: Sendable { + public enum Action: Sendable { + case present + case enter + case leave + case update + } + + public init(clientID: String, data: PresenceData, action: PresenceMember.Action, extras: (any Sendable)?, updatedAt: Date) { + self.clientID = clientID + self.data = data + self.action = action + self.extras = extras + self.updatedAt = updatedAt + } + + public var clientID: String + public var data: PresenceData? + public var action: Action + // TODO: (https://github.com/ably-labs/ably-chat-swift/issues/13): try to improve this type + public var extras: (any Sendable)? + public var updatedAt: Date +} + +public enum PresenceEventType: Sendable { + case enter + case leave + case update + case present +} + +public struct PresenceEvent: Sendable { + public var action: PresenceEventType + public var clientID: String + public var timestamp: Date + public var data: PresenceData? + + public init(action: PresenceEventType, clientID: String, timestamp: Date, data: PresenceData?) { + self.action = action + self.clientID = clientID + self.timestamp = timestamp + self.data = data + } +} diff --git a/Sources/AblyChat/Reaction.swift b/Sources/AblyChat/Reaction.swift new file mode 100644 index 0000000..7b23fb5 --- /dev/null +++ b/Sources/AblyChat/Reaction.swift @@ -0,0 +1,22 @@ +import Foundation + +public typealias ReactionHeaders = Headers +public typealias ReactionMetadata = Metadata + +public struct Reaction: Sendable { + public var type: String + public var metadata: ReactionMetadata + public var headers: ReactionHeaders + public var createdAt: Date + public var clientID: String + public var isSelf: Bool + + public init(type: String, metadata: ReactionMetadata, headers: ReactionHeaders, createdAt: Date, clientID: String, isSelf: Bool) { + self.type = type + self.metadata = metadata + self.headers = headers + self.createdAt = createdAt + self.clientID = clientID + self.isSelf = isSelf + } +} diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift new file mode 100644 index 0000000..286821a --- /dev/null +++ b/Sources/AblyChat/Room.swift @@ -0,0 +1,16 @@ +public protocol Room: AnyObject, Sendable { + var roomID: String { get } + var messages: any Messages { get } + // To access this property if presence is not enabled for the room is a programmer error, and will lead to `fatalError` being called. + var presence: any Presence { get } + // To access this property if reactions are not enabled for the room is a programmer error, and will lead to `fatalError` being called. + var reactions: any RoomReactions { get } + // To access this property if typing is not enabled for the room is a programmer error, and will lead to `fatalError` being called. + var typing: any Typing { get } + // To access this property if occupancy is not enabled for the room is a programmer error, and will lead to `fatalError` being called. + var occupancy: any Occupancy { get } + var status: any RoomStatus { get } + func attach() async throws + func detach() async throws + var options: RoomOptions { get } +} diff --git a/Sources/AblyChat/RoomOptions.swift b/Sources/AblyChat/RoomOptions.swift new file mode 100644 index 0000000..a927bfc --- /dev/null +++ b/Sources/AblyChat/RoomOptions.swift @@ -0,0 +1,41 @@ +import Foundation + +public struct RoomOptions: Sendable { + public var presence: PresenceOptions? + public var typing: TypingOptions? + public var reactions: RoomReactionsOptions? + public var occupancy: OccupancyOptions? + + public init(presence: PresenceOptions? = nil, typing: TypingOptions? = nil, reactions: RoomReactionsOptions? = nil, occupancy: OccupancyOptions? = nil) { + self.presence = presence + self.typing = typing + self.reactions = reactions + self.occupancy = occupancy + } +} + +public struct PresenceOptions: Sendable { + public var enter = true + public var subscribe = true + + public init(enter: Bool = true, subscribe: Bool = true) { + self.enter = enter + self.subscribe = subscribe + } +} + +public struct TypingOptions: Sendable { + public var timeout: TimeInterval = 10 + + public init(timeout: TimeInterval = 10) { + self.timeout = timeout + } +} + +public struct RoomReactionsOptions: Sendable { + public init() {} +} + +public struct OccupancyOptions: Sendable { + public init() {} +} diff --git a/Sources/AblyChat/RoomReactions.swift b/Sources/AblyChat/RoomReactions.swift new file mode 100644 index 0000000..e59e0fc --- /dev/null +++ b/Sources/AblyChat/RoomReactions.swift @@ -0,0 +1,11 @@ +import Ably + +public protocol RoomReactions: AnyObject, Sendable, EmitsDiscontinuities { + func send(params: RoomReactionParams) async throws + func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription + var channel: ARTRealtimeChannelProtocol { get } +} + +public struct RoomReactionParams: Sendable { + public init() {} +} diff --git a/Sources/AblyChat/RoomStatus.swift b/Sources/AblyChat/RoomStatus.swift new file mode 100644 index 0000000..24eee9d --- /dev/null +++ b/Sources/AblyChat/RoomStatus.swift @@ -0,0 +1,33 @@ +import Ably + +public protocol RoomStatus: AnyObject, Sendable { + var current: RoomLifecycle { get } + // TODO: (https://github.com/ably-labs/ably-chat-swift/issues/12): consider how to avoid the need for an unwrap + var error: ARTErrorInfo? { get } + func onChange(bufferingPolicy: BufferingPolicy) -> Subscription +} + +public enum RoomLifecycle: Sendable { + case initialized + case attaching + case attached + case detaching + case detached + case suspended + case failed + case releasing + case released +} + +public struct RoomStatusChange: Sendable { + public var current: RoomLifecycle + public var previous: RoomLifecycle + // TODO: (https://github.com/ably-labs/ably-chat-swift/issues/12): consider how to avoid the need for an unwrap + public var error: ARTErrorInfo? + + public init(current: RoomLifecycle, previous: RoomLifecycle, error: ARTErrorInfo? = nil) { + self.current = current + self.previous = previous + self.error = error + } +} diff --git a/Sources/AblyChat/Rooms.swift b/Sources/AblyChat/Rooms.swift new file mode 100644 index 0000000..f09478a --- /dev/null +++ b/Sources/AblyChat/Rooms.swift @@ -0,0 +1,5 @@ +public protocol Rooms: AnyObject, Sendable { + func get(roomID: String, options: RoomOptions) throws -> any Room + func release(roomID: String) async throws + var clientOptions: ClientOptions { get } +} diff --git a/Sources/AblyChat/Subscription.swift b/Sources/AblyChat/Subscription.swift new file mode 100644 index 0000000..e79b09b --- /dev/null +++ b/Sources/AblyChat/Subscription.swift @@ -0,0 +1,25 @@ +// A non-throwing `AsyncSequence` (means that we can iterate over it without a `try`). +// +// This should respect the `BufferingPolicy` passed to the `subscribe(bufferingPolicy:)` method. +// +// At some point we should define how this thing behaves when you iterate over it from multiple loops, or when you pass it around. I’m not yet sufficiently experienced with `AsyncSequence` to know what’s idiomatic. I tried the same thing out with `AsyncStream` (two tasks iterating over a single stream) and it appears that each element is delivered to precisely one consumer. But we can leave that for later. On a similar note consider whether it makes a difference whether this is a struct or a class. +// +// TODO: I wanted to implement this as a protocol (from which `MessageSubscription` would then inherit) but struggled to do so, hence the struct. Try again sometime. We can also revisit our implementation of `AsyncSequence` if we migrate to Swift 6, which adds primary types and typed errors to `AsyncSequence` and should make things easier. +public struct Subscription: Sendable, AsyncSequence { + // This is a workaround for the fact that, as mentioned above, `Subscription` is a struct when I would have liked it to be a protocol. It allows people mocking our SDK to create a `Subscription` so that they can return it from their mocks. The intention of this initializer is that if you use it, then the created `Subscription` will just replay the sequence that you pass it. + public init(mockAsyncSequence _: T) where T.Element == Element { + fatalError("Not implemented") + } + + // (The below is just necessary boilerplate to get this to compile; the key point is that `next()` does not have a `throws` annotation.) + + public struct AsyncIterator: AsyncIteratorProtocol { + public mutating func next() async -> Element? { + fatalError("Not implemented") + } + } + + public func makeAsyncIterator() -> AsyncIterator { + fatalError("Not implemented") + } +} diff --git a/Sources/AblyChat/Typing.swift b/Sources/AblyChat/Typing.swift new file mode 100644 index 0000000..fdc380a --- /dev/null +++ b/Sources/AblyChat/Typing.swift @@ -0,0 +1,17 @@ +import Ably + +public protocol Typing: AnyObject, Sendable, EmitsDiscontinuities { + func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription + func get() async throws -> Set + func start() async throws + func stop() async throws + var channel: ARTRealtimeChannelProtocol { get } +} + +public struct TypingEvent: Sendable { + public var currentlyTyping: Set + + public init(currentlyTyping: Set) { + self.currentlyTyping = currentlyTyping + } +} diff --git a/Tests/AblyChatTests/AblyChatTests.swift b/Tests/AblyChatTests/AblyChatTests.swift index 054ea7f..c8c5c0b 100644 --- a/Tests/AblyChatTests/AblyChatTests.swift +++ b/Tests/AblyChatTests/AblyChatTests.swift @@ -3,6 +3,6 @@ import XCTest final class AblyChatTests: XCTestCase { func testExample() throws { - XCTAssertNoThrow(AblyChatClient()) + XCTAssertNoThrow(DefaultChatClient(realtime: MockRealtime(key: ""), clientOptions: ClientOptions())) } } diff --git a/Tests/AblyChatTests/Mocks/MockRealtime.swift b/Tests/AblyChatTests/Mocks/MockRealtime.swift new file mode 100644 index 0000000..f49b06c --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRealtime.swift @@ -0,0 +1,41 @@ +import Ably +import Foundation + +/// A mock implementation of `ARTRealtimeProtocol`. Copied from the class of the same name in the example app. We’ll figure out how to do mocking in tests properly in https://github.com/ably-labs/ably-chat-swift/issues/5. +class MockRealtime: NSObject, ARTRealtimeProtocol { + var device: ARTLocalDevice { + fatalError("Not implemented") + } + + var clientId: String? + + required init(options _: ARTClientOptions) {} + + required init(key _: String) {} + + required init(token _: String) {} + + func time(_: @escaping ARTDateTimeCallback) { + fatalError("Not implemented") + } + + func ping(_: @escaping ARTCallback) { + fatalError("Not implemented") + } + + func stats(_: @escaping ARTPaginatedStatsCallback) -> Bool { + fatalError("Not implemented") + } + + func stats(_: ARTStatsQuery?, callback _: @escaping ARTPaginatedStatsCallback) throws { + fatalError("Not implemented") + } + + func connect() { + fatalError("Not implemented") + } + + func close() { + fatalError("Not implemented") + } +}