Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
xcode-version: 16.2

- name: Spec coverage
run: swift run BuildTool spec-coverage --spec-commit-sha 45f0939
run: swift run BuildTool spec-coverage --spec-commit-sha 2f88b1b
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down
7 changes: 2 additions & 5 deletions Example/AblyChatExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,7 @@ struct ContentView: View {
}

private func room() async throws -> Room {
try await chatClient.rooms.get(
roomID: roomID,
options: .allFeaturesEnabled
)
try await chatClient.rooms.get(roomID: roomID)
}

private var sendTitle: String {
Expand Down Expand Up @@ -361,7 +358,7 @@ struct ContentView: View {
statusInfo = "\(status.current)...".capitalized
} else {
statusInfo = "\(status.current)".capitalized
if status.current == .attached {
if status.current.isAttached {
Task {
try? await Task.sleep(nanoseconds: 1 * 1_000_000_000)
withAnimation {
Expand Down
36 changes: 7 additions & 29 deletions Example/AblyChatExample/Mocks/MockClients.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ class MockRoom: Room {
nonisolated let typing: any Typing
nonisolated let occupancy: any Occupancy

let channel: any RealtimeChannelProtocol = MockRealtime.Channel()

init(roomID: String, options: RoomOptions) {
self.roomID = roomID
self.options = options
Expand All @@ -76,26 +78,28 @@ class MockRoom: Room {

private func createSubscription() -> MockSubscription<RoomStatusChange> {
mockSubscriptions.create(randomElement: {
RoomStatusChange(current: [.attached, .attached, .attached, .attached, .attaching(error: nil), .attaching(error: nil), .suspended(error: .createUnknownError())].randomElement()!, previous: .attaching(error: nil))
RoomStatusChange(current: [.attached(error: nil), .attached(error: nil), .attached(error: nil), .attached(error: nil), .attaching(error: nil), .attaching(error: nil), .suspended(error: .createUnknownError())].randomElement()!, previous: .attaching(error: nil))
}, interval: 8)
}

func onStatusChange(bufferingPolicy _: BufferingPolicy) -> Subscription<RoomStatusChange> {
.init(mockAsyncSequence: createSubscription())
}

func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription<DiscontinuityEvent> {
fatalError("Not yet implemented")
}
Comment thread
lawrence-forooghian marked this conversation as resolved.
}

class MockMessages: Messages {
let clientID: String
let roomID: String
let channel: any RealtimeChannelProtocol

private let mockSubscriptions = MockSubscriptionStorage<Message>()

init(clientID: String, roomID: String) {
self.clientID = clientID
self.roomID = roomID
channel = MockRealtime.Channel()
}

private func createSubscription() -> MockSubscription<Message> {
Expand Down Expand Up @@ -179,23 +183,17 @@ class MockMessages: Messages {
mockSubscriptions.emit(message)
return message
}

func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription<DiscontinuityEvent> {
fatalError("Not yet implemented")
}
}

class MockRoomReactions: RoomReactions {
let clientID: String
let roomID: String
let channel: any RealtimeChannelProtocol

private let mockSubscriptions = MockSubscriptionStorage<Reaction>()

init(clientID: String, roomID: String) {
self.clientID = clientID
self.roomID = roomID
channel = MockRealtime.Channel()
}

private func createSubscription() -> MockSubscription<Reaction> {
Expand Down Expand Up @@ -226,23 +224,17 @@ class MockRoomReactions: RoomReactions {
func subscribe(bufferingPolicy _: BufferingPolicy) -> Subscription<Reaction> {
.init(mockAsyncSequence: createSubscription())
}

func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription<DiscontinuityEvent> {
fatalError("Not yet implemented")
}
}

class MockTyping: Typing {
let clientID: String
let roomID: String
let channel: any RealtimeChannelProtocol

private let mockSubscriptions = MockSubscriptionStorage<TypingEvent>()

init(clientID: String, roomID: String) {
self.clientID = clientID
self.roomID = roomID
channel = MockRealtime.Channel()
}

private func createSubscription() -> MockSubscription<TypingEvent> {
Expand Down Expand Up @@ -272,10 +264,6 @@ class MockTyping: Typing {
func stop() async throws(ARTErrorInfo) {
mockSubscriptions.emit(TypingEvent(currentlyTyping: [], change: .init(clientId: clientID, type: .stopped)))
}

func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription<DiscontinuityEvent> {
fatalError("Not yet implemented")
}
}

class MockPresence: Presence {
Expand Down Expand Up @@ -392,23 +380,17 @@ class MockPresence: Presence {
func subscribe(events _: [PresenceEventType], bufferingPolicy _: BufferingPolicy) -> Subscription<PresenceEvent> {
.init(mockAsyncSequence: createSubscription())
}

func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription<DiscontinuityEvent> {
fatalError("Not yet implemented")
}
}

class MockOccupancy: Occupancy {
let clientID: String
let roomID: String
let channel: any RealtimeChannelProtocol

private let mockSubscriptions = MockSubscriptionStorage<OccupancyEvent>()

init(clientID: String, roomID: String) {
self.clientID = clientID
self.roomID = roomID
channel = MockRealtime.Channel()
}

private func createSubscription() -> MockSubscription<OccupancyEvent> {
Expand All @@ -425,10 +407,6 @@ class MockOccupancy: Occupancy {
func get() async throws(ARTErrorInfo) -> OccupancyEvent {
OccupancyEvent(connections: 10, presenceMembers: 5)
}

func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription<DiscontinuityEvent> {
fatalError("Not yet implemented")
}
}

class MockConnection: Connection {
Expand Down
4 changes: 0 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,6 @@ let package = Package(
name: "Ably",
package: "ably-cocoa"
),
.product(
name: "AsyncAlgorithms",
package: "swift-async-algorithms"
),
]
),
.testTarget(
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,9 @@ Task {
}
}

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

// Add a listener to observe changes to the chat rooms status
let statusSubscription = room.onStatusChange()
Expand Down
53 changes: 33 additions & 20 deletions Sources/AblyChat/AblyCocoaExtensions/InternalAblyCocoaTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ import Ably
/// - `async` methods instead of callbacks
/// - typed throws
/// - `JSONValue` instead of `Any`
/// - `AsyncSequence` where helpful
///
/// 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.
///
/// 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).
///
Expand Down Expand Up @@ -66,9 +63,6 @@ internal protocol InternalRealtimeChannelProtocol: AnyObject, Sendable {
func subscribe(_ name: String, callback: @escaping @MainActor (ARTMessage) -> Void) -> ARTEventListener?
var properties: ARTChannelProperties { get }
func off(_ listener: ARTEventListener)

/// Equivalent to subscribing to a `RealtimeChannelProtocol` object’s state changes via its `on(_:)` method. The subscription should use the ``BufferingPolicy/unbounded`` buffering policy.
func subscribeToState() -> Subscription<ARTChannelStateChange>
}

/// Expresses the requirements of the object returned by ``InternalRealtimeChannelProtocol/presence``.
Expand All @@ -95,6 +89,34 @@ internal protocol InternalConnectionProtocol: AnyObject, Sendable {
func off(_ listener: ARTEventListener)
}

/// Converts a `@MainActor` callback into one that can be passed as a callback to ably-cocoa.
///
/// 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.
///
/// 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.)
///
/// - 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).
private func toAblyCocoaCallback<Arg>(_ callback: @escaping @MainActor (Arg) -> Void) -> (Arg) -> Void {
{ arg in
let sendingBox = UnsafeSendingBox(value: arg)

// We use `preconditionIsolated` in addition to `assumeIsolated` because only the former accepts a message.
MainActor.preconditionIsolated("The Ably Chat SDK requires that your ARTRealtime instance be using the main queue as its dispatchQueue.")
MainActor.assumeIsolated {
callback(sendingBox.value)
}
}
}

/// 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.
private final class UnsafeSendingBox<T>: @unchecked Sendable {
var value: T

init(value: T) {
self.value = value
}
}

internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtocol {
private let underlying: RealtimeClient
internal let channels: Channels
Expand Down Expand Up @@ -224,11 +246,11 @@ internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtoc
}

internal func on(_ cb: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener {
underlying.on(cb)
underlying.on(toAblyCocoaCallback(cb))
}

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

internal func unsubscribe(_ listener: ARTEventListener?) {
Expand All @@ -242,15 +264,6 @@ internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtoc
internal func off(_ listener: ARTEventListener) {
underlying.off(listener)
}

internal func subscribeToState() -> Subscription<ARTChannelStateChange> {
let subscription = Subscription<ARTChannelStateChange>(bufferingPolicy: .unbounded)
let eventListener = underlying.on { subscription.emit($0) }
subscription.addTerminationHandler { [weak underlying] in
underlying?.unsubscribe(eventListener)
}
return subscription
}
}

internal final class Presence: InternalRealtimePresenceProtocol {
Expand Down Expand Up @@ -345,11 +358,11 @@ internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtoc
}

internal func subscribe(_ callback: @escaping @MainActor (ARTPresenceMessage) -> Void) -> ARTEventListener? {
underlying.subscribe(callback)
underlying.subscribe(toAblyCocoaCallback(callback))
}

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

internal func unsubscribe(_ listener: ARTEventListener) {
Expand Down Expand Up @@ -389,7 +402,7 @@ internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtoc
}

internal func on(_ cb: @escaping @MainActor (ARTConnectionStateChange) -> Void) -> ARTEventListener {
underlying.on(cb)
underlying.on(toAblyCocoaCallback(cb))
}

internal func off(_ listener: ARTEventListener) {
Expand Down
13 changes: 6 additions & 7 deletions Sources/AblyChat/ChatAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@ import Ably
@MainActor
internal final class ChatAPI: Sendable {
private let realtime: any InternalRealtimeClientProtocol
private let apiVersion = "/chat/v1"
private let apiVersionV2 = "/chat/v2" // TODO: remove v1 after full transition to v2
private let apiVersionV3 = "/chat/v3"

public init(realtime: any InternalRealtimeClientProtocol) {
self.realtime = realtime
}

// (CHA-M6) Messages should be queryable from a paginated REST API.
internal func getMessages(roomId: String, params: QueryOptions) async throws(InternalError) -> any PaginatedResult<Message> {
let endpoint = "\(apiVersionV2)/rooms/\(roomId)/messages"
let endpoint = "\(apiVersionV3)/rooms/\(roomId)/messages"
let result: Result<PaginatedResultWrapper<Message>, InternalError> = await makePaginatedRequest(endpoint, params: params.asQueryItems())
return try result.get()
}
Expand Down Expand Up @@ -47,7 +46,7 @@ internal final class ChatAPI: Sendable {
throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.").toInternalError()
}

let endpoint = "\(apiVersionV2)/rooms/\(roomId)/messages"
let endpoint = "\(apiVersionV3)/rooms/\(roomId)/messages"
var body: [String: JSONValue] = ["text": .string(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.
Expand Down Expand Up @@ -86,7 +85,7 @@ internal final class ChatAPI: Sendable {
throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.").toInternalError()
}

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

if let description = params.description {
Expand Down Expand Up @@ -172,7 +171,7 @@ internal final class ChatAPI: Sendable {
}

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

Expand Down
2 changes: 1 addition & 1 deletion Sources/AblyChat/ChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public class DefaultChatClient: ChatClient {
* Constructor for Chat
*
* - Parameters:
* - realtime: The Ably Realtime client.
* - 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).
* - clientOptions: The client options.
*/
public convenience init(realtime suppliedRealtime: any SuppliedRealtimeClientProtocol, clientOptions: ChatClientOptions?) {
Expand Down
Loading