diff --git a/Sources/AblyChat/Errors.swift b/Sources/AblyChat/Errors.swift index d4d153c8..2ae459b8 100644 --- a/Sources/AblyChat/Errors.swift +++ b/Sources/AblyChat/Errors.swift @@ -11,6 +11,8 @@ public let errorDomain = "AblyChatErrorDomain" The error codes for errors in the ``errorDomain`` error domain. */ public enum ErrorCode: Int { + case nonspecific = 40000 + /// ``Rooms.get(roomID:options:)`` was called with a different set of room options than was used on a previous call. You must first release the existing room instance using ``Rooms.release(roomID:)``. /// /// TODO this code is a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32 @@ -36,7 +38,8 @@ public enum ErrorCode: Int { internal var statusCode: Int { // TODO: These are currently a guess, revisit once outstanding spec question re status codes is answered (https://github.com/ably/specification/pull/200#discussion_r1755222945), and also revisit in https://github.com/ably-labs/ably-chat-swift/issues/32 switch self { - case .inconsistentRoomOptions, + case .nonspecific, + .inconsistentRoomOptions, .messagesDetachmentFailed, .presenceDetachmentFailed, .reactionsDetachmentFailed, @@ -69,6 +72,8 @@ internal enum ChatError { case roomInFailedState case roomIsReleasing case roomIsReleased + case presenceOperationRequiresRoomAttach(feature: RoomFeature) + case presenceOperationDisallowedForCurrentRoomStatus(feature: RoomFeature) /// The ``ARTErrorInfo.code`` that should be returned for this error. internal var code: ErrorCode { @@ -107,20 +112,14 @@ internal enum ChatError { .roomIsReleasing case .roomIsReleased: .roomIsReleased + case .presenceOperationRequiresRoomAttach, + .presenceOperationDisallowedForCurrentRoomStatus: + .nonspecific } } - /// A helper type for parameterising the construction of error messages. - private enum AttachOrDetach { - case attach - case detach - } - - private static func localizedDescription( - forFailureOfOperation operation: AttachOrDetach, - feature: RoomFeature - ) -> String { - let featureDescription = switch feature { + private static func descriptionOfFeature(_ feature: RoomFeature) -> String { + switch feature { case .messages: "messages" case .occupancy: @@ -132,7 +131,18 @@ internal enum ChatError { case .typing: "typing" } + } + + /// A helper type for parameterising the construction of error messages. + private enum AttachOrDetach { + case attach + case detach + } + private static func localizedDescription( + forFailureOfOperation operation: AttachOrDetach, + feature: RoomFeature + ) -> String { let operationDescription = switch operation { case .attach: "attach" @@ -140,7 +150,7 @@ internal enum ChatError { "detach" } - return "The \(featureDescription) feature failed to \(operationDescription)." + return "The \(descriptionOfFeature(feature)) feature failed to \(operationDescription)." } /// The ``ARTErrorInfo.localizedDescription`` that should be returned for this error. @@ -158,6 +168,10 @@ internal enum ChatError { "Cannot perform operation because the room is in a releasing state." case .roomIsReleased: "Cannot perform operation because the room is in a released state." + case let .presenceOperationRequiresRoomAttach(feature): + "To perform this \(Self.descriptionOfFeature(feature)) operation, you must first attach the room." + case let .presenceOperationDisallowedForCurrentRoomStatus(feature): + "This \(Self.descriptionOfFeature(feature)) operation can not be performed given the current room status." } } @@ -171,7 +185,9 @@ internal enum ChatError { case .inconsistentRoomOptions, .roomInFailedState, .roomIsReleasing, - .roomIsReleased: + .roomIsReleased, + .presenceOperationRequiresRoomAttach, + .presenceOperationDisallowedForCurrentRoomStatus: nil } } diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index 99653346..4c2bf869 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -82,15 +82,17 @@ internal actor DefaultRoom throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.") } - let featureChannels = Self.createFeatureChannels(roomID: roomID, realtime: realtime) - channels = featureChannels.mapValues(\.channel) - let contributors = featureChannels.values.map(\.contributor) + let featureChannelPartialDependencies = Self.createFeatureChannelPartialDependencies(roomID: roomID, realtime: realtime) + channels = featureChannelPartialDependencies.mapValues(\.channel) + let contributors = featureChannelPartialDependencies.values.map(\.contributor) lifecycleManager = await lifecycleManagerFactory.createManager( contributors: contributors, logger: logger ) + let featureChannels = Self.createFeatureChannels(partialDependencies: featureChannelPartialDependencies, lifecycleManager: lifecycleManager) + // TODO: Address force unwrapping of `channels` within feature initialisation below: https://github.com/ably-labs/ably-chat-swift/issues/105 messages = await DefaultMessages( @@ -108,7 +110,12 @@ internal actor DefaultRoom ) : nil } - private static func createFeatureChannels(roomID: String, realtime: RealtimeClient) -> [RoomFeature: DefaultFeatureChannel] { + private struct FeatureChannelPartialDependencies { + internal var channel: RealtimeChannelProtocol + internal var contributor: DefaultRoomLifecycleContributor + } + + private static func createFeatureChannelPartialDependencies(roomID: String, realtime: RealtimeClient) -> [RoomFeature: FeatureChannelPartialDependencies] { .init(uniqueKeysWithValues: [RoomFeature.messages, RoomFeature.reactions].map { feature in let channel = realtime.getChannel(feature.channelNameForRoomID(roomID)) let contributor = DefaultRoomLifecycleContributor(channel: .init(underlyingChannel: channel), feature: feature) @@ -117,6 +124,16 @@ internal actor DefaultRoom }) } + private static func createFeatureChannels(partialDependencies: [RoomFeature: FeatureChannelPartialDependencies], lifecycleManager: RoomLifecycleManager) -> [RoomFeature: DefaultFeatureChannel] { + partialDependencies.mapValues { partialDependencies in + .init( + channel: partialDependencies.channel, + contributor: partialDependencies.contributor, + roomLifecycleManager: lifecycleManager + ) + } + } + public nonisolated var presence: any Presence { fatalError("Not yet implemented") } diff --git a/Sources/AblyChat/RoomFeature.swift b/Sources/AblyChat/RoomFeature.swift index d630c3e2..42cf71b7 100644 --- a/Sources/AblyChat/RoomFeature.swift +++ b/Sources/AblyChat/RoomFeature.swift @@ -29,19 +29,38 @@ internal enum RoomFeature { /// Provides all of the channel-related functionality that a room feature (e.g. an implementation of ``Messages``) needs. /// -/// This mishmash exists to give a room feature access to both: +/// This mishmash exists to give a room feature access to: /// /// - a `RealtimeChannelProtocol` object (this is the interface that our features are currently written against, as opposed to, say, `RoomLifecycleContributorChannel`) /// - the discontinuities emitted by the room lifecycle +/// - the presence-readiness wait mechanism supplied by the room lifecycle internal protocol FeatureChannel: Sendable, EmitsDiscontinuities { var channel: RealtimeChannelProtocol { get } + + /// Waits until we can perform presence operations on the contributors of this room without triggering an implicit attach. + /// + /// Implements the checks described by CHA-PR3d, CHA-PR3e, CHA-PR3f, and CHA-PR3g (and similar ones described by other functionality that performs contributor presence operations). Namely: + /// + /// - CHA-PR3d, CHA-PR10d, CHA-PR6c, CHA-T2c: If the room is in the ATTACHING status, it waits for the current ATTACH to complete and then returns. If the current ATTACH fails, then it re-throws that operation’s error. + /// - CHA-PR3e, CHA-PR11e, CHA-PR6d, CHA-T2d: If the room is in the ATTACHED status, it returns immediately. + /// - CHA-PR3f, CHA-PR11f, CHA-PR6e, CHA-T2e: If the room is in the DETACHED status, it throws an `ARTErrorInfo` derived from ``ChatError.presenceOperationRequiresRoomAttach(feature:)``. + /// - // CHA-PR3g, CHA-PR11g, CHA-PR6f, CHA-T2f: If the room is in any other status, it throws an `ARTErrorInfo` derived from ``ChatError.presenceOperationDisallowedForCurrentRoomStatus(feature:)``. + /// + /// - Parameters: + /// - requester: The room feature that wishes to perform a presence operation. This is only used for customising the message of the thrown error. + func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(ARTErrorInfo) } internal struct DefaultFeatureChannel: FeatureChannel { internal var channel: RealtimeChannelProtocol internal var contributor: DefaultRoomLifecycleContributor + internal var roomLifecycleManager: RoomLifecycleManager internal func subscribeToDiscontinuities() async -> Subscription { await contributor.subscribeToDiscontinuities() } + + internal func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(ARTErrorInfo) { + try await roomLifecycleManager.waitToBeAbleToPerformPresenceOperations(requestedByFeature: requester) + } } diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index 433456fd..dcdc7aad 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -46,6 +46,7 @@ internal protocol RoomLifecycleManager: Sendable { func performReleaseOperation() async var roomStatus: RoomStatus { get async } func onChange(bufferingPolicy: BufferingPolicy) async -> Subscription + func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(ARTErrorInfo) } internal protocol RoomLifecycleManagerFactory: Sendable { @@ -556,7 +557,7 @@ internal actor DefaultRoomLifecycleManager Bool { +func isChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode, cause: ARTErrorInfo? = nil, message: String? = nil) -> Bool { guard let ablyError = maybeError as? ARTErrorInfo else { return false } @@ -13,4 +13,11 @@ func isChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode, && ablyError.code == code.rawValue && ablyError.statusCode == code.statusCode && ablyError.cause == cause + && { + guard let message else { + return true + } + + return ablyError.message == message + }() } diff --git a/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift b/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift index 41759530..2b12b92f 100644 --- a/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift @@ -5,9 +5,14 @@ final actor MockFeatureChannel: FeatureChannel { let channel: RealtimeChannelProtocol // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) private var discontinuitySubscriptions: [Subscription] = [] + private let resultOfWaitToBeAbleToPerformPresenceOperations: Result? - init(channel: RealtimeChannelProtocol) { + init( + channel: RealtimeChannelProtocol, + resultOfWaitToBeAblePerformPresenceOperations: Result? = nil + ) { self.channel = channel + resultOfWaitToBeAbleToPerformPresenceOperations = resultOfWaitToBeAblePerformPresenceOperations } func subscribeToDiscontinuities() async -> Subscription { @@ -21,4 +26,12 @@ final actor MockFeatureChannel: FeatureChannel { subscription.emit(discontinuity) } } + + func waitToBeAbleToPerformPresenceOperations(requestedByFeature _: RoomFeature) async throws(ARTErrorInfo) { + guard let resultOfWaitToBeAbleToPerformPresenceOperations else { + fatalError("resultOfWaitToBeAblePerformPresenceOperations must be set before waitToBeAbleToPerformPresenceOperations is called") + } + + try resultOfWaitToBeAbleToPerformPresenceOperations.get() + } } diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift index ad1e004f..2cd797a7 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift @@ -55,4 +55,8 @@ actor MockRoomLifecycleManager: RoomLifecycleManager { subscription.emit(statusChange) } } + + func waitToBeAbleToPerformPresenceOperations(requestedByFeature _: RoomFeature) async throws(ARTErrorInfo) { + fatalError("Not implemented") + } }