Skip to content

Commit 0db0a49

Browse files
Use a single channel for all features
Resolves #242. The API changes are based on the JS API changes from branch integration/single-channel-integration at 0924cd5. The behavioural changes are based on [1] at 27ddbb3. This commit is quite large, and combines API and behavioural changes, because the new specification points are written with reference to the new API and so it was hard to disentangle the two. Key changes to public API: - Updated room options API: - there's no concept of a feature being turned "on" any more, and correspondingly no error when you try and use a feature that's not turned on - removed `allFeaturesEnabled`, since the equivalent has been removed from JS - you can now fetch a room without passing a room options; this uses the default options - Removed feature-specific error codes - Discontinuities now always have an error - Extended the set of RoomStatus that can have an error (this does not have an equivalent in the JS API; it's a result of the room lifecycle manager statuses that mean we're now more driven by the errors emitted by ably-cocoa; we still need to revisit this in #12) Key changes to interactions with Realtime: - Switched to v3 of the REST API - Use a single channel for all room features; this simplifies the room lifecycle manager and the DefaultRoom class - Room reactions now use ephemeral messages - Resolves #133 (setting of presence-related channel modes). Internal changes: - Removed FeatureChannel and RoomLifecycleContributor - Switch to using callbacks for ably-cocoa state changes (i.e. end the experiment of using AsyncSequence in the ably-cocoa wrapper) and enforce that ably-cocoa deliver its state changes on the main thread; together with fc83fc1 this allows us to no longer have to work around the fact that the spec's method for detecting a discontinuity assumes a single-threaded environment, hence resolving #239. [1] ably/specification#282
1 parent d6b2a71 commit 0db0a49

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1284
-3528
lines changed

Example/AblyChatExample/ContentView.swift

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,7 @@ struct ContentView: View {
6969
}
7070

7171
private func room() async throws -> Room {
72-
try await chatClient.rooms.get(
73-
roomID: roomID,
74-
options: .allFeaturesEnabled
75-
)
72+
try await chatClient.rooms.get(roomID: roomID)
7673
}
7774

7875
private var sendTitle: String {

Example/AblyChatExample/Mocks/MockClients.swift

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ class MockRoom: Room {
5252
nonisolated let typing: any Typing
5353
nonisolated let occupancy: any Occupancy
5454

55+
let channel: any RealtimeChannelProtocol = MockRealtime.Channel()
56+
5557
init(roomID: String, options: RoomOptions) {
5658
self.roomID = roomID
5759
self.options = options
@@ -83,19 +85,21 @@ class MockRoom: Room {
8385
func onStatusChange(bufferingPolicy _: BufferingPolicy) -> Subscription<RoomStatusChange> {
8486
.init(mockAsyncSequence: createSubscription())
8587
}
88+
89+
func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription<DiscontinuityEvent> {
90+
fatalError("Not yet implemented")
91+
}
8692
}
8793

8894
class MockMessages: Messages {
8995
let clientID: String
9096
let roomID: String
91-
let channel: any RealtimeChannelProtocol
9297

9398
private let mockSubscriptions = MockSubscriptionStorage<Message>()
9499

95100
init(clientID: String, roomID: String) {
96101
self.clientID = clientID
97102
self.roomID = roomID
98-
channel = MockRealtime.Channel()
99103
}
100104

101105
private func createSubscription() -> MockSubscription<Message> {
@@ -179,23 +183,17 @@ class MockMessages: Messages {
179183
mockSubscriptions.emit(message)
180184
return message
181185
}
182-
183-
func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription<DiscontinuityEvent> {
184-
fatalError("Not yet implemented")
185-
}
186186
}
187187

188188
class MockRoomReactions: RoomReactions {
189189
let clientID: String
190190
let roomID: String
191-
let channel: any RealtimeChannelProtocol
192191

193192
private let mockSubscriptions = MockSubscriptionStorage<Reaction>()
194193

195194
init(clientID: String, roomID: String) {
196195
self.clientID = clientID
197196
self.roomID = roomID
198-
channel = MockRealtime.Channel()
199197
}
200198

201199
private func createSubscription() -> MockSubscription<Reaction> {
@@ -226,23 +224,17 @@ class MockRoomReactions: RoomReactions {
226224
func subscribe(bufferingPolicy _: BufferingPolicy) -> Subscription<Reaction> {
227225
.init(mockAsyncSequence: createSubscription())
228226
}
229-
230-
func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription<DiscontinuityEvent> {
231-
fatalError("Not yet implemented")
232-
}
233227
}
234228

235229
class MockTyping: Typing {
236230
let clientID: String
237231
let roomID: String
238-
let channel: any RealtimeChannelProtocol
239232

240233
private let mockSubscriptions = MockSubscriptionStorage<TypingEvent>()
241234

242235
init(clientID: String, roomID: String) {
243236
self.clientID = clientID
244237
self.roomID = roomID
245-
channel = MockRealtime.Channel()
246238
}
247239

248240
private func createSubscription() -> MockSubscription<TypingEvent> {
@@ -272,10 +264,6 @@ class MockTyping: Typing {
272264
func stop() async throws(ARTErrorInfo) {
273265
mockSubscriptions.emit(TypingEvent(currentlyTyping: [], change: .init(clientId: clientID, type: .stopped)))
274266
}
275-
276-
func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription<DiscontinuityEvent> {
277-
fatalError("Not yet implemented")
278-
}
279267
}
280268

281269
class MockPresence: Presence {
@@ -392,23 +380,17 @@ class MockPresence: Presence {
392380
func subscribe(events _: [PresenceEventType], bufferingPolicy _: BufferingPolicy) -> Subscription<PresenceEvent> {
393381
.init(mockAsyncSequence: createSubscription())
394382
}
395-
396-
func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription<DiscontinuityEvent> {
397-
fatalError("Not yet implemented")
398-
}
399383
}
400384

401385
class MockOccupancy: Occupancy {
402386
let clientID: String
403387
let roomID: String
404-
let channel: any RealtimeChannelProtocol
405388

406389
private let mockSubscriptions = MockSubscriptionStorage<OccupancyEvent>()
407390

408391
init(clientID: String, roomID: String) {
409392
self.clientID = clientID
410393
self.roomID = roomID
411-
channel = MockRealtime.Channel()
412394
}
413395

414396
private func createSubscription() -> MockSubscription<OccupancyEvent> {
@@ -425,10 +407,6 @@ class MockOccupancy: Occupancy {
425407
func get() async throws(ARTErrorInfo) -> OccupancyEvent {
426408
OccupancyEvent(connections: 10, presenceMembers: 5)
427409
}
428-
429-
func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription<DiscontinuityEvent> {
430-
fatalError("Not yet implemented")
431-
}
432410
}
433411

434412
class MockConnection: Connection {

Package.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,6 @@ let package = Package(
5151
name: "Ably",
5252
package: "ably-cocoa"
5353
),
54-
.product(
55-
name: "AsyncAlgorithms",
56-
package: "swift-async-algorithms"
57-
),
5854
.product(
5955
name: "Semaphore",
6056
package: "Semaphore"

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,9 @@ Task {
109109
}
110110
}
111111

112-
// Get a chat room for the tutorial - using the defaults to enable all features in the chat room
112+
// Get a chat room for the tutorial
113113
let room = try await chatClient.rooms.get(
114-
roomID: "readme-getting-started", options: RoomOptions.allFeaturesEnabled)
114+
roomID: "readme-getting-started")
115115

116116
// Add a listener to observe changes to the chat rooms status
117117
let statusSubscription = room.onStatusChange()

Sources/AblyChat/AblyCocoaExtensions/InternalAblyCocoaTypes.swift

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@ import Ably
88
/// - typed throws
99
/// - `JSONValue` instead of `Any`
1010
/// - `Sendable` types where helpful
11-
/// - `AsyncSequence` where helpful
12-
///
13-
/// Note that the API of this protocol is not currently consistent; for example there are some places in the codebase where we subscribe to Realtime channel state using callbacks, and other places where we subscribe using `AsyncSequence`. We should aim to make this consistent; see https://github.com/ably/ably-chat-swift/issues/245.
1411
///
1512
/// Hopefully we will eventually be able to remove this interface once we've improved the experience of using ably-cocoa from Swift (https://github.com/ably/ably-cocoa/issues/1967).
1613
///
@@ -67,9 +64,6 @@ internal protocol InternalRealtimeChannelProtocol: AnyObject, Sendable {
6764
func subscribe(_ name: String, callback: @escaping @MainActor (ARTMessage) -> Void) -> ARTEventListener?
6865
var properties: ARTChannelProperties { get }
6966
func off(_ listener: ARTEventListener)
70-
71-
/// Equivalent to subscribing to a `RealtimeChannelProtocol` object’s state changes via its `on(_:)` method. The subscription should use the ``BufferingPolicy/unbounded`` buffering policy.
72-
func subscribeToState() -> Subscription<ARTChannelStateChange>
7367
}
7468

7569
/// Expresses the requirements of the object returned by ``InternalRealtimeChannelProtocol/presence``.
@@ -96,6 +90,34 @@ internal protocol InternalConnectionProtocol: AnyObject, Sendable {
9690
func off(_ listener: ARTEventListener)
9791
}
9892

93+
/// Converts a `@MainActor` callback into one that can be passed as a callback to ably-cocoa.
94+
///
95+
/// The returned callback asserts that it is called on the main thread and then synchronously calls the passed callback. It also allows non-`Sendable` values to be passed from ably-cocoa to the passed callback.
96+
///
97+
/// The main thread assertion is our way of asserting the requirement, documented in the `DefaultChatClient` initializer, that the ably-cocoa client must be using the main queue as its `dispatchQueue`. (This is the only way we can do it without accessing private ably-cocoa API, since we don't publicly expose the options that a client is using.)
98+
///
99+
/// - Warning: You must be sure that after ably-cocoa calls the returned callback, it will not modify any of the mutable state contained inside the argument that it passes to the callback. This is true of the two non-`Sendable` types with which we're currently using it; namely `ARTMessage` and `ARTPresenceMessage`. Ideally, we would instead annotate these callback arguments in ably-cocoa with `NS_SWIFT_SENDING`, to allow us to then mark the corresponding argument in these callbacks as `sending` and not have to circumvent compiler sendability checking, but as of Xcode 16.1 this annotation does yet not seem to have any effect; see [ably-cocoa#1967](https://github.com/ably/ably-cocoa/issues/1967).
100+
private func toAblyCocoaCallback<Arg>(_ callback: @escaping @MainActor (Arg) -> Void) -> (Arg) -> Void {
101+
{ arg in
102+
let sendingBox = UnsafeSendingBox(value: arg)
103+
104+
// We use `preconditionIsolated` in addition to `assumeIsolated` because only the former accepts a message.
105+
MainActor.preconditionIsolated("The Ably Chat SDK requires that your ARTRealtime instance be using the main queue as its dispatchQueue.")
106+
MainActor.assumeIsolated {
107+
callback(sendingBox.value)
108+
}
109+
}
110+
}
111+
112+
/// A box that makes the compiler ignore that a non-Sendable value is crossing an isolation boundary. Used by `toAblyCocoaCallback`; don't use it elsewhere unless you know what you're doing.
113+
private final class UnsafeSendingBox<T>: @unchecked Sendable {
114+
var value: T
115+
116+
init(value: T) {
117+
self.value = value
118+
}
119+
}
120+
99121
internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtocol {
100122
private let underlying: RealtimeClient
101123
internal let channels: Channels
@@ -225,11 +247,11 @@ internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtoc
225247
}
226248

227249
internal func on(_ cb: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener {
228-
underlying.on(cb)
250+
underlying.on(toAblyCocoaCallback(cb))
229251
}
230252

231253
internal func on(_ event: ARTChannelEvent, callback cb: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener {
232-
underlying.on(event, callback: cb)
254+
underlying.on(event, callback: toAblyCocoaCallback(cb))
233255
}
234256

235257
internal func unsubscribe(_ listener: ARTEventListener?) {
@@ -247,15 +269,6 @@ internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtoc
247269
internal func off(_ listener: ARTEventListener) {
248270
underlying.off(listener)
249271
}
250-
251-
internal func subscribeToState() -> Subscription<ARTChannelStateChange> {
252-
let subscription = Subscription<ARTChannelStateChange>(bufferingPolicy: .unbounded)
253-
let eventListener = underlying.on { subscription.emit($0) }
254-
subscription.addTerminationHandler { [weak underlying] in
255-
underlying?.unsubscribe(eventListener)
256-
}
257-
return subscription
258-
}
259272
}
260273

261274
internal final class Presence: InternalRealtimePresenceProtocol {
@@ -350,11 +363,11 @@ internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtoc
350363
}
351364

352365
internal func subscribe(_ callback: @escaping @MainActor (ARTPresenceMessage) -> Void) -> ARTEventListener? {
353-
underlying.subscribe(callback)
366+
underlying.subscribe(toAblyCocoaCallback(callback))
354367
}
355368

356369
internal func subscribe(_ action: ARTPresenceAction, callback: @escaping @MainActor (ARTPresenceMessage) -> Void) -> ARTEventListener? {
357-
underlying.subscribe(action, callback: callback)
370+
underlying.subscribe(action, callback: toAblyCocoaCallback(callback))
358371
}
359372

360373
internal func unsubscribe(_ listener: ARTEventListener) {
@@ -394,7 +407,7 @@ internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtoc
394407
}
395408

396409
internal func on(_ cb: @escaping @MainActor (ARTConnectionStateChange) -> Void) -> ARTEventListener {
397-
underlying.on(cb)
410+
underlying.on(toAblyCocoaCallback(cb))
398411
}
399412

400413
internal func off(_ listener: ARTEventListener) {

Sources/AblyChat/ChatAPI.swift

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,15 @@ import Ably
33
@MainActor
44
internal final class ChatAPI: Sendable {
55
private let realtime: any InternalRealtimeClientProtocol
6-
private let apiVersion = "/chat/v1"
7-
private let apiVersionV2 = "/chat/v2" // TODO: remove v1 after full transition to v2
6+
private let apiVersionV3 = "/chat/v3"
87

98
public init(realtime: any InternalRealtimeClientProtocol) {
109
self.realtime = realtime
1110
}
1211

1312
// (CHA-M6) Messages should be queryable from a paginated REST API.
1413
internal func getMessages(roomId: String, params: QueryOptions) async throws(InternalError) -> any PaginatedResult<Message> {
15-
let endpoint = "\(apiVersionV2)/rooms/\(roomId)/messages"
14+
let endpoint = "\(apiVersionV3)/rooms/\(roomId)/messages"
1615
return try await makePaginatedRequest(endpoint, params: params.asQueryItems())
1716
}
1817

@@ -46,7 +45,7 @@ internal final class ChatAPI: Sendable {
4645
throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.").toInternalError()
4746
}
4847

49-
let endpoint = "\(apiVersionV2)/rooms/\(roomId)/messages"
48+
let endpoint = "\(apiVersionV3)/rooms/\(roomId)/messages"
5049
var body: [String: JSONValue] = ["text": .string(params.text)]
5150

5251
// (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.
@@ -85,7 +84,7 @@ internal final class ChatAPI: Sendable {
8584
throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.").toInternalError()
8685
}
8786

88-
let endpoint = "\(apiVersionV2)/rooms/\(modifiedMessage.roomID)/messages/\(modifiedMessage.serial)"
87+
let endpoint = "\(apiVersionV3)/rooms/\(modifiedMessage.roomID)/messages/\(modifiedMessage.serial)"
8988
var body: [String: JSONValue] = [:]
9089
let messageObject: [String: JSONValue] = [
9190
"text": .string(modifiedMessage.text),
@@ -133,7 +132,7 @@ internal final class ChatAPI: Sendable {
133132
// (CHA-M9) A client must be able to delete a message in a room.
134133
// (CHA-M9a) A client may delete a message via the Chat REST API by calling the delete method.
135134
internal func deleteMessage(message: Message, params: DeleteMessageParams) async throws(InternalError) -> Message {
136-
let endpoint = "\(apiVersionV2)/rooms/\(message.roomID)/messages/\(message.serial)/delete"
135+
let endpoint = "\(apiVersionV3)/rooms/\(message.roomID)/messages/\(message.serial)/delete"
137136
var body: [String: JSONValue] = [:]
138137

139138
if let description = params.description {
@@ -171,7 +170,7 @@ internal final class ChatAPI: Sendable {
171170
}
172171

173172
internal func getOccupancy(roomId: String) async throws(InternalError) -> OccupancyEvent {
174-
let endpoint = "\(apiVersion)/rooms/\(roomId)/occupancy"
173+
let endpoint = "\(apiVersionV3)/rooms/\(roomId)/occupancy"
175174
return try await makeRequest(endpoint, method: "GET")
176175
}
177176

Sources/AblyChat/ChatClient.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public class DefaultChatClient: ChatClient {
6969
* Constructor for Chat
7070
*
7171
* - Parameters:
72-
* - realtime: The Ably Realtime client.
72+
* - realtime: The Ably Realtime client. If this is an instance of `ARTRealtime` from the ably-cocoa SDK, then its `dispatchQueue` option must be the main queue (this is its default behaviour).
7373
* - clientOptions: The client options.
7474
*/
7575
public convenience init(realtime suppliedRealtime: any SuppliedRealtimeClientProtocol, clientOptions: ChatClientOptions?) {

0 commit comments

Comments
 (0)