diff --git a/Sources/AblyChat/BufferingPolicy.swift b/Sources/AblyChat/BufferingPolicy.swift index 78385007..d25d2d0a 100644 --- a/Sources/AblyChat/BufferingPolicy.swift +++ b/Sources/AblyChat/BufferingPolicy.swift @@ -3,11 +3,11 @@ * (This is the same as `AsyncStream.Continuation.BufferingPolicy` but with the generic type parameter `Element` removed.) */ public enum BufferingPolicy: Sendable { - // swiftlint:disable:next missing_docs + /// No buffering limit; all events are stored until consumed. case unbounded - // swiftlint:disable:next missing_docs + /// Buffers up to the specified number of oldest events, dropping new events when full. case bufferingOldest(Int) - // swiftlint:disable:next missing_docs + /// Buffers up to the specified number of newest events, dropping old events when full. case bufferingNewest(Int) internal func asAsyncStreamBufferingPolicy() -> AsyncStream.Continuation.BufferingPolicy { diff --git a/Sources/AblyChat/ChatClient.swift b/Sources/AblyChat/ChatClient.swift index 70b0eef4..84e5e041 100644 --- a/Sources/AblyChat/ChatClient.swift +++ b/Sources/AblyChat/ChatClient.swift @@ -1,56 +1,89 @@ import Ably -// This disable of attributes can be removed once missing_docs fixed here -// swiftlint:disable attributes +/// Protocol defining the interface for an Ably Chat client instance. @MainActor -// swiftlint:disable:next missing_docs public protocol ChatClientProtocol: AnyObject, Sendable { - // swiftlint:enable attributes - - // swiftlint:disable:next missing_docs + /// The underlying Ably Realtime client type. associatedtype Realtime - // swiftlint:disable:next missing_docs + /// The connection type for monitoring client connectivity. associatedtype Connection: AblyChat.Connection - // swiftlint:disable:next missing_docs + /// The rooms manager type for creating and managing chat rooms. associatedtype Rooms: AblyChat.Rooms /** - * Returns the rooms object, which provides access to chat rooms. + * Provides access to the rooms instance for creating and managing chat rooms. + * + * - Returns: The Rooms instance for managing chat rooms + * + * ## Example + * + * ```swift + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room with default options + * let room = try await chatClient.rooms.get("general-chat") * - * - Returns: The rooms object. + * // Get a room with custom options (merges with defaults) + * let configuredRoom = try await chatClient.rooms.get("team-chat", options: RoomOptions( + * typing: TypingOptions(heartbeatThrottle: 1) // in seconds + * )) + * + * // Release a room when done + * try await chatClient.rooms.release("general-chat") + * ``` */ var rooms: Rooms { get } /** - * Returns the underlying connection to Ably, which can be used to monitor the clients - * connection to Ably servers. + * Provides access to the underlying connection to Ably for monitoring connectivity. + * + * - Returns: The Connection instance + * + * ## Example + * + * ```swift + * let chatClient: ChatClient // existing ChatClient instance * - * - Returns: The connection object. + * // Check current connection status + * print("Status: \(chatClient.connection.status)") + * print("Error: \(String(describing: chatClient.connection.error))") + * + * // Monitor connection changes + * let subscription = chatClient.connection.onStatusChange { change in + * print("Connection: \(change.previous) -> \(change.current)") + * } + * ``` */ var connection: Connection { get } /** - * Returns the clientID of the current client, if known. + * Returns the clientId of the current client, if known. * * - Important: When using an Ably key for authentication, this value is determined immediately. If using a token, - * the clientID is not known until the client has successfully connected to and authenticated with + * the clientId is not known until the client has successfully connected to and authenticated with * the server. Use the `chatClient.connection.status` to check the connection status. - - * - Returns: The clientID, or `nil` if unknown. + * + * - Returns: The clientId, or nil if unknown. */ var clientID: String? { get } /** - * Returns the underlying Ably Realtime client. + * Provides direct access to the underlying Ably Realtime client. + * + * Use this for advanced scenarios requiring direct Ably access. Most chat + * operations should use the high-level chat SDK methods instead. + * + * - Note: Directly interacting with the Ably Realtime client can lead to + * unexpected behavior. * - * - Returns: The Ably Realtime client. + * - Returns: The underlying Ably Realtime client instance */ var realtime: Realtime { get } /** - * Returns the resolved client options for the client, including any defaults that have been set. + * The configuration options used to initialize the chat client. * - * - Returns: The client options. + * - Returns: The resolved client options including defaults */ var clientOptions: ChatClientOptions { get } } @@ -72,12 +105,15 @@ internal final class DefaultInternalRealtimeClientFactory>> - // swiftlint:disable:next missing_docs + + /// See ``ChatClientProtocol/rooms`` public var rooms: some Rooms { _rooms } @@ -87,17 +123,67 @@ public class ChatClient: ChatClientProtocol { // (CHA-CS1) Every chat client has a status, which describes the current status of the connection. // (CHA-CS4) The chat client must allow its connection status to be observed by clients. private let _connection: DefaultConnection - // swiftlint:disable:next missing_docs + + /// See ``ChatClientProtocol/connection`` public var connection: some Connection { _connection } /** - * Constructor for Chat + * Creates a new ChatClient instance for interacting with Ably Chat. + * + * The ChatClient is the main entry point for the Ably Chat SDK. It requires a Realtime client + * and provides access to chat rooms through the rooms property. + * + * - Important: The Ably Realtime client must have a clientId set. This identifies + * the user in chat rooms and is required for all chat operations. + * + * - Note: You can provide optional overrides to the ``ChatClient``, these will be merged + * with the default options. See ``ChatClientOptions`` for the available options. * * - Parameters: - * - realtime: The Ably Realtime client. Its `dispatchQueue` option must be the main queue (this is its default behaviour). - * - clientOptions: The client options. + * - realtime: An initialized Ably Realtime client with a configured clientId + * - clientOptions: Optional configuration for the chat client + * + * ## Example - Preferred in production: Use auth URL that returns a JWT + * + * ```swift + * import Ably + * import AblyChat + * + * let realtimeOptions = ARTClientOptions() + * realtimeOptions.authUrl = URL(string: "/api/ably-auth") // Your server endpoint that returns a JWT with clientId + * realtimeOptions.authMethod = "POST" + * let realtimeClientWithJWT = ARTRealtime(options: realtimeOptions) + * + * let chatClient = ChatClient(realtime: realtimeClientWithJWT) + * ``` + * + * ## Example - Alternative for development and server-side operations: Set clientId directly (requires API key) + * + * ```swift + * let realtimeClientWithKey = ARTRealtime(key: "your-ably-api-key") + * realtimeClientWithKey.clientId = "user-123" + * + * let chatClient = ChatClient(realtime: realtimeClientWithKey) + * ``` + * + * ## Example - With custom logging configuration: Defaults to LogLevel.error and console logging + * + * ```swift + * let realtimeOptions = ARTClientOptions() + * realtimeOptions.authUrl = URL(string: "/api/ably-auth") // Your server endpoint that returns a JWT with clientId + * realtimeOptions.authMethod = "POST" + * let realtimeClient = ARTRealtime(options: realtimeOptions) + * + * let chatClientWithLogging = ChatClient( + * realtime: realtimeClient, + * clientOptions: ChatClientOptions( + * logLevel: .debug, + * logHandler: YourLogHandler() // Implements `LogHandler.Simple` protocol + * ) + * ) + * ``` */ public convenience init(realtime: ARTRealtime, clientOptions: ChatClientOptions? = nil) { self.init( @@ -127,7 +213,7 @@ public class ChatClient: ChatClientProtocol { _connection = DefaultConnection(realtime: internalRealtime) } - // swiftlint:disable:next missing_docs + /// See ``ChatClientProtocol/clientID`` public var clientID: String? { realtime.clientId } @@ -151,7 +237,13 @@ public struct ChatClientOptions: Sendable { */ public var logLevel: LogLevel? = .error - // swiftlint:disable:next missing_docs + /** + * Creates a new ChatClientOptions instance. + * + * - Parameters: + * - logHandler: Optional custom log handler for capturing log messages + * - logLevel: The minimum log level for messages (defaults to `.error`) + */ public init(logHandler: LogHandler? = nil, logLevel: LogLevel? = .error) { self.logHandler = logHandler self.logLevel = logLevel diff --git a/Sources/AblyChat/Connection.swift b/Sources/AblyChat/Connection.swift index 778a1a7d..89616cfd 100644 --- a/Sources/AblyChat/Connection.swift +++ b/Sources/AblyChat/Connection.swift @@ -5,26 +5,108 @@ import Ably */ @MainActor public protocol Connection: AnyObject, Sendable { - // swiftlint:disable:next missing_docs + /// The subscription type for connection status change listeners. associatedtype StatusSubscription: AblyChat.StatusSubscription /** * The current status of the connection. + * + * - Returns: The current ``ConnectionStatus`` value + * + * ## Example + * + * ```swift + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Check connection status + * if chatClient.connection.status == .connected { + * print("Connected to Ably") + * } else if chatClient.connection.status == .failed { + * print("Connection failed") + * } + * + * // Use status for conditional logic + * func canAttachToRoom() -> Bool { + * return chatClient.connection.status == .connected + * } + * ``` */ var status: ConnectionStatus { get } /** - * The current error, if any, that caused the connection to enter the current status. + * The error that caused the connection to enter its current status, if any. + * + * - Returns: ``ErrorInfo`` if an error caused the current status, nil otherwise + * + * ## Example + * + * ```swift + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Check for connection errors + * if let error = chatClient.connection.error { + * print("Connection error: \(error.message)") + * print("Error code: \(error.code)") + * } + * // Monitor for errors during status changes + * chatClient.connection.onStatusChange { change in + * if let error = change.error { + * reportErrorToMonitoring(error) + * } + * } + * ``` */ var error: ErrorInfo? { get } /** - * Subscribes a given listener to a connection status changes. + * Registers a listener to be notified of connection status changes. + * + * Status changes indicate the connection lifecycle, including connecting, + * connected, disconnected, suspended, and failed states. Use this to monitor + * connection health and handle network issues. * * - Parameters: - * - callback: The listener closure for capturing ``ConnectionStatusChange`` events. + * - callback: Callback invoked when the connection status changes + * + * - Returns: Subscription object with an `off()` method to unregister + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Monitor connection status changes + * let subscription = chatClient.connection.onStatusChange { change in + * print("Connection: \(change.previous) -> \(change.current)") + * + * // Handle different connection states + * switch change.current { + * case .connected: + * print("Connected to Ably") + * enableChatFeatures() + * hideConnectionWarning() + * + * case .failed: + * print("Connection failed permanently") + * if let error = change.error { + * print("Failure reason: \(error.message)") + * showErrorMessage("Connection failed: \(error.message)") + * } + * requireManualReconnection() + * + * // Other states: Connecting, Disconnected, Suspended + * } * - * - Returns: A subscription that can be used to unsubscribe from ``ConnectionStatusChange`` events. + * // Clean up when done + * subscription.off() + * ``` */ @discardableResult func onStatusChange(_ callback: @escaping @MainActor (ConnectionStatusChange) -> Void) -> StatusSubscription diff --git a/Sources/AblyChat/Events.swift b/Sources/AblyChat/Events.swift index 3fbf3843..817d2628 100644 --- a/Sources/AblyChat/Events.swift +++ b/Sources/AblyChat/Events.swift @@ -8,9 +8,15 @@ public enum ChatMessageAction: Sendable { * Action applied to a new message. */ case messageCreate - // swiftlint:disable:next missing_docs + + /** + * Action applied to an updated message. + */ case messageUpdate - // swiftlint:disable:next missing_docs + + /** + * Action applied to a deleted message. + */ case messageDelete internal static func fromRealtimeAction(_ action: ARTMessageAction) -> Self? { @@ -71,19 +77,30 @@ internal enum RealtimeMessageName: String, Sendable { case chatMessage = "chat.message" } +/// Internal enum representing room reaction event names used in Ably channel messages. internal enum RoomReactionEvents: String { + /// The event name for room-level reactions. case reaction = "roomReaction" } +/// Internal enum representing occupancy event names used in Ably channel metadata. internal enum OccupancyEvents: String { + /// The metadata event name for occupancy updates. case meta = "[meta]occupancy" } -/// Enum representing the typing event types. +/** + * Enum representing the typing event types. + */ public enum TypingEventType: Sendable { - // swiftlint:disable:next missing_docs + /** + * Event triggered when a user is typing. + */ case started - // swiftlint:disable:next missing_docs + + /** + * Event triggered when a user stops typing. + */ case stopped internal var rawValue: String { @@ -96,9 +113,13 @@ public enum TypingEventType: Sendable { } } -/// Enum representing the typing set event types. +/** + * Enum representing the typing set event types. + */ public enum TypingSetEventType: Sendable { - // swiftlint:disable:next missing_docs + /** + * Event triggered when a change occurs in the set of typers. + */ case setChanged internal var rawValue: String { diff --git a/Sources/AblyChat/Headers.swift b/Sources/AblyChat/Headers.swift index 82f4338b..ab70fb25 100644 --- a/Sources/AblyChat/Headers.swift +++ b/Sources/AblyChat/Headers.swift @@ -2,13 +2,13 @@ import Ably /// A value that can be used in ``Headers``. It is the same as ``JSONValue`` except it does not have the `object` or `array` cases. public enum HeadersValue: Sendable, Equatable { - // swiftlint:disable:next missing_docs + /// Represents a string header value. case string(String) - // swiftlint:disable:next missing_docs + /// Represents a numeric header value. case number(Double) - // swiftlint:disable:next missing_docs + /// Represents a boolean header value. case bool(Bool) - // swiftlint:disable:next missing_docs + /// Represents a null header value. case null // MARK: - Convenience getters for associated values @@ -51,28 +51,28 @@ public enum HeadersValue: Sendable, Equatable { } extension HeadersValue: ExpressibleByStringLiteral { - // swiftlint:disable:next missing_docs + /// Creates a HeadersValue from a string literal. public init(stringLiteral value: String) { self = .string(value) } } extension HeadersValue: ExpressibleByIntegerLiteral { - // swiftlint:disable:next missing_docs + /// Creates a HeadersValue from an integer literal. public init(integerLiteral value: Int) { self = .number(Double(value)) } } extension HeadersValue: ExpressibleByFloatLiteral { - // swiftlint:disable:next missing_docs + /// Creates a HeadersValue from a floating-point literal. public init(floatLiteral value: Double) { self = .number(value) } } extension HeadersValue: ExpressibleByBooleanLiteral { - // swiftlint:disable:next missing_docs + /// Creates a HeadersValue from a boolean literal. public init(booleanLiteral value: Bool) { self = .bool(value) } diff --git a/Sources/AblyChat/JSONValue.swift b/Sources/AblyChat/JSONValue.swift index ecace1e0..25430372 100644 --- a/Sources/AblyChat/JSONValue.swift +++ b/Sources/AblyChat/JSONValue.swift @@ -29,17 +29,17 @@ public typealias JSONObject = [String: JSONValue] /// /// > Note: To write a `JSONValue` that corresponds to the `null` JSON value, you must explicitly write `.null`. `JSONValue` deliberately does not implement the `ExpressibleByNilLiteral` protocol in order to avoid confusion between a value of type `JSONValue?` and a `JSONValue` with case `.null`. public indirect enum JSONValue: Sendable, Equatable { - // swiftlint:disable:next missing_docs + /// Represents a JSON object (dictionary). case object(JSONObject) - // swiftlint:disable:next missing_docs + /// Represents a JSON array. case array([JSONValue]) - // swiftlint:disable:next missing_docs + /// Represents a JSON string value. case string(String) - // swiftlint:disable:next missing_docs + /// Represents a JSON number value. case number(Double) - // swiftlint:disable:next missing_docs + /// Represents a JSON boolean value. case bool(Bool) - // swiftlint:disable:next missing_docs + /// Represents the JSON null value. case null // MARK: - Convenience getters for associated values @@ -100,42 +100,42 @@ public indirect enum JSONValue: Sendable, Equatable { } extension JSONValue: ExpressibleByDictionaryLiteral { - // swiftlint:disable:next missing_docs + /// Creates a JSONValue from a dictionary literal. public init(dictionaryLiteral elements: (String, JSONValue)...) { self = .object(.init(uniqueKeysWithValues: elements)) } } extension JSONValue: ExpressibleByArrayLiteral { - // swiftlint:disable:next missing_docs + /// Creates a JSONValue from an array literal. public init(arrayLiteral elements: JSONValue...) { self = .array(elements) } } extension JSONValue: ExpressibleByStringLiteral { - // swiftlint:disable:next missing_docs + /// Creates a JSONValue from a string literal. public init(stringLiteral value: String) { self = .string(value) } } extension JSONValue: ExpressibleByIntegerLiteral { - // swiftlint:disable:next missing_docs + /// Creates a JSONValue from an integer literal. public init(integerLiteral value: Int) { self = .number(Double(value)) } } extension JSONValue: ExpressibleByFloatLiteral { - // swiftlint:disable:next missing_docs + /// Creates a JSONValue from a floating-point literal. public init(floatLiteral value: Double) { self = .number(value) } } extension JSONValue: ExpressibleByBooleanLiteral { - // swiftlint:disable:next missing_docs + /// Creates a JSONValue from a boolean literal. public init(booleanLiteral value: Bool) { self = .bool(value) } diff --git a/Sources/AblyChat/Logging.swift b/Sources/AblyChat/Logging.swift index fdac67d4..6516fb21 100644 --- a/Sources/AblyChat/Logging.swift +++ b/Sources/AblyChat/Logging.swift @@ -1,6 +1,6 @@ import os -// swiftlint:disable:next missing_docs +/// Handler for processing log messages from the Chat SDK. public struct LogHandler: Sendable { fileprivate let simple: any Simple @@ -34,15 +34,15 @@ public struct LogHandler: Sendable { * Represents the different levels of logging that can be used. */ public enum LogLevel: Sendable, Comparable { - // swiftlint:disable:next missing_docs + /// The most verbose log level, typically used for detailed diagnostic information. case trace - // swiftlint:disable:next missing_docs + /// Log level for debug information useful during development. case debug - // swiftlint:disable:next missing_docs + /// Log level for general informational messages. case info - // swiftlint:disable:next missing_docs + /// Log level for warning messages indicating potential issues. case warn - // swiftlint:disable:next missing_docs + /// Log level for error messages indicating failures. case error } diff --git a/Sources/AblyChat/Message.swift b/Sources/AblyChat/Message.swift index 3432c234..f192b18e 100644 --- a/Sources/AblyChat/Message.swift +++ b/Sources/AblyChat/Message.swift @@ -12,7 +12,7 @@ public typealias MessageHeaders = Headers public typealias MessageMetadata = Metadata /** - * ``Metadata`` type used for the metadata within an operation e.g. updating or deleting a message + * ``OperationMetadata`` type for a chat message. Contains information about an update or deletion operation. */ public typealias MessageOperationMetadata = OperationMetadata @@ -100,7 +100,7 @@ public struct Message: Sendable, Equatable { } /** - * Helper function to copy a message with its properties replaced per the parameters. + * Creates a copy of the message with fields replaced per the parameters. * * If an argument is omitted or `nil`, then the current value of that property will be preserved. */ @@ -127,7 +127,9 @@ public struct Message: Sendable, Equatable { } } -/// Represents the version information for a message. +/** + * Represents the detail of a message deletion or update. + */ public struct MessageVersion: Sendable, Equatable { /** * A unique identifier for the latest version of this message. @@ -140,17 +142,17 @@ public struct MessageVersion: Sendable, Equatable { public var timestamp: Date /** - * The optional clientId of the user who performed the update or deletion. + * The optional clientId of the user who performed an update or deletion. */ public var clientID: String? /** - * The optional description for the update or deletion. + * The optional description for an update or deletion. */ public var description: String? /** - * The optional metadata associated with the update or deletion. + * The optional metadata associated with an update or deletion. */ public var metadata: MessageOperationMetadata? @@ -209,17 +211,19 @@ internal extension MessageVersion { } } -// swiftlint:disable:next missing_docs +/// Extension providing convenience methods for applying events to messages. public extension Message { /** * Creates a new message instance with the event applied. * + * - Note: This method will not replace the message reactions if the event is of type `Message`. + * * - Parameters: * - summaryEvent: The event to be applied to the returned message. * - * - Throws: ``ErrorInfo`` if the event is for a different message. + * - Throws: ``ErrorInfo`` if the event is for a different message or if the event is a ``ChatMessageEventType/created``. * - * - Returns: A new message instance with the event applied. + * - Returns: A new message instance with the event applied. If the event is a no-op, such as an event for an old version, the same message is returned (not a copy). */ func with(_ summaryEvent: MessageReactionSummaryEvent) throws(ErrorInfo) -> Self { // (CHA-M11j) For MessageReactionSummaryEvent, the method must verify that the summary.messageSerial in the event matches the message's own serial. If they don't match, an error with code InvalidArgument must be thrown. @@ -238,9 +242,9 @@ public extension Message { * - Parameters: * - messageEvent: The message event to be applied to the returned message. * - * - Throws: ``ErrorInfo`` if the event is for a different message, if it's a created event, or if there are other validation errors. + * - Throws: ``ErrorInfo`` if the event is for a different message or if the event is a ``ChatMessageEventType/created``. * - * - Returns: A new message instance with the event applied, or the original message if the event is older. + * - Returns: A new message instance with the event applied. If the event is a no-op, such as an event for an old version, the same message is returned (not a copy). */ func with(_ messageEvent: ChatMessageEvent) throws(ErrorInfo) -> Self { // (CHA-M11h) When the method receives a MessageEvent of type created, it must throw an ErrorInfo with code InvalidArgument. diff --git a/Sources/AblyChat/MessageReaction.swift b/Sources/AblyChat/MessageReaction.swift index 4923f527..6ad05909 100644 --- a/Sources/AblyChat/MessageReaction.swift +++ b/Sources/AblyChat/MessageReaction.swift @@ -137,7 +137,14 @@ public struct MessageReactionSummary: Sendable, Equatable { */ public var clipped: Bool // TM7c1c - // swiftlint:disable:next missing_docs + /** + * Creates a new ClientIDList instance. + * + * - Parameters: + * - total: Total number of reactions of this type + * - clientIDs: List of client IDs who sent this reaction + * - clipped: Whether the clientIDs list was clipped + */ public init(total: Int, clientIDs: [String], clipped: Bool) { self.total = total self.clientIDs = clientIDs @@ -176,7 +183,16 @@ public struct MessageReactionSummary: Sendable, Equatable { */ public var totalClientIDs: Int // TM7d1e - // swiftlint:disable:next missing_docs + /** + * Creates a new ClientIDCounts instance. + * + * - Parameters: + * - total: Total number of reactions of this type + * - clientIDs: Map of client IDs to their reaction counts + * - totalUnidentified: Sum of counts from unidentified clients + * - clipped: Whether the clientIDs list was clipped + * - totalClientIDs: Total number of distinct client IDs + */ public init(total: Int, clientIDs: [String: Int], totalUnidentified: Int, clipped: Bool, totalClientIDs: Int) { self.total = total self.clientIDs = clientIDs @@ -220,11 +236,11 @@ public struct MessageReactionSummary: Sendable, Equatable { /** * Event interface representing a summary of message reactions. - * This event aggregates different types of reactions (single, distinct, multiple) for a specific message. + * This event aggregates different types of reactions (unique, distinct, multiple) for a specific message. */ public struct MessageReactionSummaryEvent: Sendable, Equatable { /** - * The type of the event (should be equal to summary). + * The type of the event. */ public var type: MessageReactionSummaryEventType @@ -257,26 +273,27 @@ public struct MessageReactionRawEvent: Sendable { */ public struct Reaction: Sendable { /** - * The reaction type (Unique, Distinct, or Multiple). + * Type of reaction. */ public var type: MessageReactionType + /** - * The reaction itself, typically an emoji. + * The reaction name (typically an emoji). */ public var name: String /** - * The serial of the message, for which this reaction was created. + * Serial of the message this reaction is for. */ public var messageSerial: String /** - * An optional count field for reactions of type "multiple". + * Count of the reaction (only for type Multiple, if set). */ public var count: Int? /** - * The clientId of the user who sent the reaction. + * The client ID of the user who added/removed the reaction. */ public var clientID: String diff --git a/Sources/AblyChat/MessageReactions.swift b/Sources/AblyChat/MessageReactions.swift index cf751ea5..d303f6ef 100644 --- a/Sources/AblyChat/MessageReactions.swift +++ b/Sources/AblyChat/MessageReactions.swift @@ -1,90 +1,305 @@ import Ably /** - * Add, delete, and subscribe to message reactions. + * Send, delete, and subscribe to message reactions. * * Get an instance via ``Room/messages/reactions``. */ @MainActor public protocol MessageReactions: AnyObject, Sendable { - // swiftlint:disable:next missing_docs + /// The subscription type for message reaction event listeners. associatedtype Subscription: AblyChat.Subscription /** - * Add a message reaction. + * Sends a reaction to a specific chat message. + * + * - Note: + * - The behavior depends on the reaction type configured for the room. + * - This method uses the Ably Chat REST API and so does not require the room + * to be attached to be called. * * - Parameters: - * - messageSerial: A serial of the message to react to. - * - params: Describe the reaction to add. + * - messageSerial: The unique identifier of the message to react to. + * - params: The reaction parameters. + * + * - Throws: ``ErrorInfo`` with code 40400 if the message does not exist. + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * let room = try await chatClient.rooms.get(named: "sports-chat") + * + * let messageSerial = "01726585978590-001@abcdefghij:001" + * + * // Send a simple reaction to a message + * do { + * try await room.messages.reactions.send( + * forMessageWithSerial: messageSerial, + * params: .init( + * name: "👍" + * ) + * ) + * print("Reaction sent successfully") + * } catch { + * print("Failed to send reaction: \(error)") + * } * - * - Note: It is possible to receive your own reaction via the reactions subscription before this method returns. + * // Send a distinct type reaction (can react with multiple different emojis) + * try await room.messages.reactions.send( + * forMessageWithSerial: messageSerial, + * params: .init( + * name: "❤️", + * type: .distinct + * ) + * ) + * + * // Send a multiple type reaction with count (for vote-style reactions) + * try await room.messages.reactions.send( + * forMessageWithSerial: messageSerial, + * params: .init( + * name: "option-a", + * type: .multiple, + * count: 3 // User votes 3 times for option-a + * ) + * ) + * ``` */ func send(forMessageWithSerial messageSerial: String, params: SendMessageReactionParams) async throws(ErrorInfo) /** - * Delete a message reaction. + * Deletes a previously sent reaction from a chat message. + * + * The deletion behavior depends on the reaction type: + * - **Unique**: Removes the client's single reaction (name not required) + * - **Distinct**: Removes a specific reaction by name + * - **Multiple**: Removes all instances of a reaction by name + * + * - Note: This method uses the Ably Chat REST API and so does not require the room + * to be attached to be called. * * - Parameters: - * - messageSerial: A serial of the message to remove the reaction from. - * - params: The type of reaction annotation and the specific reaction to remove. The reaction to remove is required for all types except ``MessageReactionType/unique``. + * - messageSerial: The unique identifier of the message to remove the reaction from + * - params: Optional deletion parameters + * + * - Throws: + * - ``ErrorInfo`` with code 40400 if the message does not exist. + * - ``ErrorInfo`` with code ``InternalError/ErrorCode/invalidArgument`` if trying to delete a non-Unique reaction without a name. + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * let room = try await chatClient.rooms.get(named: "team-chat") + * + * let messageSerial = "01726585978590-001@abcdefghij:001" + * + * // Delete a distinct reaction (specific emoji) + * do { + * try await room.messages.reactions.delete( + * fromMessageWithSerial: messageSerial, + * params: .init( + * name: "👍", + * type: .distinct + * ) + * ) + * print("Thumbs up reaction removed") + * } catch { + * print("Failed to delete reaction: \(error)") + * } + * + * // Delete a unique reaction (only one per user, name not needed) + * try await room.messages.reactions.delete( + * fromMessageWithSerial: messageSerial, + * params: .init( + * type: .unique + * ) + * ) + * + * // Delete all instances of a multiple reaction + * try await room.messages.reactions.delete( + * fromMessageWithSerial: messageSerial, + * params: .init( + * name: "option-b", + * type: .multiple + * ) + * ) + * ``` */ func delete(fromMessageWithSerial messageSerial: String, params: DeleteMessageReactionParams) async throws(ErrorInfo) /** - * Subscribe to message reaction summaries. Use this to keep message reaction counts up to date efficiently in the UI. + * Subscribes to chat message reaction summary events. + * + * Summary events provide aggregated reaction counts. Each summary event contains counts and + * client lists for all reaction types on a message. + * + * - Note: + * - The room must be attached to receive reaction events. + * - When there are many reacting clients, the client list may be clipped. Check the ``MessageReactionSummary/ClientIDCounts/clipped`` flag and use ``MessageReactions/clientReactions(forMessageWithSerial:clientID:)`` for complete client information when needed. + * - When the rate of reactions is very high, multiple summaries may be rolled up into a single summary event, meaning the delta between sequential summaries is not guaranteed to be a single reaction change. * * - Parameters: - * - callback: A callback to call when a message reaction summary is received. + * - callback: Callback invoked when reaction summaries are updated + * + * - Returns: Subscription object with an unsubscribe method + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * let room = try await chatClient.rooms.get(named: "product-reviews") + * + * // Subscribe to reaction summaries + * let subscription = room.messages.reactions.subscribe { event in + * let reactions = event.reactions + * // Handle distinct reactions + * for (reaction, data) in reactions.distinct { + * print("\(reaction): \(data.total) reactions from \(data.clientIDs.count) users") + * } + * // Handle unique reactions + * for (reaction, data) in reactions.unique { + * print("\(reaction): \(data.total) users reacted") + * } + * // Handle multiple reactions + * for (reaction, data) in reactions.multiple { + * print("\(reaction): \(data.total) total votes") + * } + * } + * + * // Attach to the room to start receiving events + * try await room.attach() * - * - Returns: A subscription handle object that should be used to unsubscribe. + * // Later, unsubscribe when done + * subscription.unsubscribe() + * ``` */ @discardableResult func subscribe(_ callback: @escaping @MainActor (MessageReactionSummaryEvent) -> Void) -> Subscription /** - * Subscribe to individual reaction events. + * Subscribes to individual chat message reaction events. + * + * Raw reaction events provide the individual updates for each reaction + * added or removed. This is most useful for analytics, but is not recommended + * for driving UI due to the high volume of events. + * + * - Note: + * - Requires ``MessagesOptions/rawMessageReactions`` to be enabled in room options. It's a programmer error otherwise. * * - Parameters: - * - callback: A callback to call when a message reaction event is received. + * - callback: Callback invoked for each individual reaction event + * + * - Returns: Subscription object with an unsubscribe method + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Enable raw reactions in room options + * let room = try await chatClient.rooms.get(named: "live-stream", options: RoomOptions( + * messages: MessagesOptions( + * rawMessageReactions: true + * ) + * )) + * + * // Subscribe to raw reaction events for analytics + * let subscription = room.messages.reactions.subscribeRaw { event in + * let reaction = event.reaction + * + * switch event.type { + * case .create: + * print("[\(event.timestamp)] \(reaction.clientID) added \(reaction.name) to message \(reaction.messageSerial)") + * + * case .delete: + * print("[\(event.timestamp)] \(reaction.clientID) removed \(reaction.name) from message \(reaction.messageSerial)") + * } + * + * // Handle multiple type reactions with counts + * if let count = reaction.count { + * print("Reaction has count: \(count)") + * } + * } * - * - Returns: A subscription handle object that should be used to unsubscribe. + * // Attach to the room to start receiving events + * try await room.attach() * - * - Note: If you only need to keep track of reaction counts and clients, use ``subscribe(_:)`` instead. + * // Later, unsubscribe when done + * subscription.unsubscribe() + * ``` */ @discardableResult func subscribeRaw(_ callback: @escaping @MainActor (MessageReactionRawEvent) -> Void) -> Subscription /** - * Get the reaction count for a message for a particular client. + * Retrieves reaction information for a specific client on a message. + * + * Use this method when reaction summaries are clipped (too many reacting clients) + * and you need to check if a specific client has reacted. This is particularly + * useful for determining if the current user has reacted when they're not in + * the summary's client list. + * + * - Note: This method uses the Ably Chat REST API and so does not require the room to be attached to be called. * * - Parameters: - * - messageSerial: The serial of the message to get reactions for. - * - clientID: The client to fetch the reaction summary for (leave unset for current client). + * - messageSerial: The unique identifier of the message + * - clientID: The client ID to check (defaults to current client) * - * - Returns: A clipped reaction summary containing only the requested clientId. + * - Returns: Reaction data for the specified client. * - * For example: + * - Throws: ``ErrorInfo`` with code 40400 if the message does not exist. + * + * ## Example * * ```swift - * room.messages.reactions.subscribe { event in - * Task { - * var modifiedEvent = event - * - * // For brevity of example, we check unique 👍 (normally iterate for all relevant reactions) - * let uniqueLikes = event.reactions.unique["👍"] - * if let uniqueLikes, uniqueLikes.clipped, !uniqueLikes.clientIDs.contains(myClientID) { - * // summary is clipped and doesn't include myClientId, so we need to fetch a clientSummary - * let clientReactions = try await room.messages.reactions.clientReactions( - * forMessageWithSerial: event.messageSerial, - * clientID: myClientID, - * ) - * if clientReactions.unique["👍"] != nil { - * // client has reacted with 👍 - * modifiedEvent.reactions.unique["👍", default: .init(total: 0, clientIDs: [], clipped: false)].clientIDs.append(myClientID) - * } - * } - * // from here, process modifiedEvent as usual + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * let room = try await chatClient.rooms.get(named: "large-event") + * + * let messageSerial = "01726585978590-001@abcdefghij:001" + * + * do { + * // Get reactions for the current client + * let myReactions = try await room.messages.reactions.clientReactions( + * forMessageWithSerial: messageSerial, + * clientID: nil + * ) + * if myReactions.unique["👍"] != nil { + * print("I have reacted with 👍") + * } + * if myReactions.distinct["❤️"] != nil { + * print("I have reacted with ❤️") + * } + * if let voteCount = myReactions.multiple["vote-option-a"]?.clientIDs[myClientID] { + * print("I voted for option A: \(voteCount) times") * } + * + * // Check reactions for a specific client + * let specificClientReactions = try await room.messages.reactions.clientReactions( + * forMessageWithSerial: messageSerial, + * clientID: "specific-client-id" + * ) + * print("Specific client reactions: \(specificClientReactions)") + * } catch { + * print("Failed to get client reactions: \(error)") * } * ``` */ @@ -153,19 +368,19 @@ public extension MessageReactions { } /** - * Parameters for adding a message reaction. + * Parameters for sending a message reaction. */ public struct SendMessageReactionParams: Sendable { /** - * The type of reaction, must be one of ``MessageReactionType``. - * If not set, the default type will be used which is configured in the ``MessagesOptions/defaultMessageReactionType`` of the room. + * The reaction name to send; (e.g., emoji like "👍", "❤️", or custom names). */ - public var type: MessageReactionType? + public var name: String /** - * The reaction to add; ie. the emoji. + * The optional type of reaction, must be one of ``MessageReactionType`` if set. + * If not set, the default type will be used which is configured in the ``MessagesOptions/defaultMessageReactionType`` of the room. */ - public var name: String + public var type: MessageReactionType? /** * The count of the reaction for type ``MessageReactionType/multiple``. @@ -187,12 +402,12 @@ public struct SendMessageReactionParams: Sendable { public struct DeleteMessageReactionParams: Sendable { /** * The type of reaction, must be one of ``MessageReactionType``. + * If not set, the default type will be used which is configured in the ``MessagesOptions/defaultMessageReactionType`` of the room. */ public var type: MessageReactionType? /** - * The reaction to remove, ie. the emoji name. Required for all reaction types - * except ``MessageReactionType/unique``. + * The reaction name to delete; ie. the emoji. Required for all reaction types except ``MessageReactionType/unique``. */ public var name: String? diff --git a/Sources/AblyChat/Messages.swift b/Sources/AblyChat/Messages.swift index 60283dd9..d1d9c5fd 100644 --- a/Sources/AblyChat/Messages.swift +++ b/Sources/AblyChat/Messages.swift @@ -8,114 +8,347 @@ import Ably */ @MainActor public protocol Messages: AnyObject, Sendable { - // swiftlint:disable:next missing_docs + /// The message reactions type for managing reactions to individual messages. associatedtype Reactions: MessageReactions - // swiftlint:disable:next missing_docs + /// The subscription response type returned when subscribing to message events. associatedtype SubscribeResponse: MessageSubscriptionResponse - // swiftlint:disable:next missing_docs + /// The paginated result type for message history queries. associatedtype HistoryResult: PaginatedResult /** - * Subscribe to new messages in this chat room. + * Subscribe to chat message events in this room. + * + * This method allows you to listen for chat message events and provides access to + * historical messages that occurred before the subscription was established. + * + * - Note: The room must be attached for the listener to receive new message events. * * - Parameters: - * - callback: The listener closure for capturing room `ChatMessageEvent` events. + * - callback: A callback function that will be invoked when chat message events occur. + * + * - Returns: A ``MessageSubscriptionResponse`` object that provides: + * - `unsubscribe()`: Method to stop listening for message events + * - `historyBeforeSubscribe()`: Method to retrieve messages sent before subscription + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat * - * - Returns: A subscription that can be used to unsubscribe from `ChatMessageEvent` events. + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room and subscribe to messages + * let room = try await chatClient.rooms.get("general-chat") + * + * let subscription = room.messages.subscribe { event in + * print("Message \(event.type): \(event.message.text)") + * print("From: \(event.message.clientID)") + * print("At: \(event.message.timestamp)") + * // Handle different event types + * } + * + * // Attach to the room to start receiving events + * try await room.attach() + * + * // Later, unsubscribe when done + * subscription.unsubscribe() + * ``` */ func subscribe(_ callback: @escaping @MainActor (ChatMessageEvent) -> Void) -> SubscribeResponse /** - * Get messages that have been previously sent to the chat room, based on the provided options. + * Get messages that have been previously sent to the chat room. * - * - Parameters: - * - params: Parameters for the query. + * This method retrieves historical messages based on the provided query options, + * allowing you to paginate through message history, filter by time ranges, + * and control the order of results. + * + * - Note: This method uses the Ably Chat REST API and so does not require the room + * to be attached to be called. * - * - Returns: A paginated result object that can be used to fetch more messages if available. + * - Parameters: + * - params: Query parameters to filter and control the message retrieval + * + * - Returns: A ``PaginatedResult`` containing an array of ``Message`` objects + * and methods for pagination control + * + * - Throws: ``ErrorInfo`` with code 40003 (invalid argument) when the query fails due to invalid parameters + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * let room = try await chatClient.rooms.get("project-updates") + * + * // Retrieve message history with pagination + * do { + * var result = try await room.messages.history(withParams: HistoryParams( + * limit: 50, + * orderBy: .newestFirst + * )) + * + * print("Retrieved \(result.items.count) messages") + * for message in result.items { + * print("\(message.clientID): \(message.text)") + * } + * + * // Paginate through additional pages if available + * while result.hasNext { + * if let nextPage = try await result.next() { + * print("Next page has \(nextPage.items.count) messages") + * for message in nextPage.items { + * print("\(message.clientID): \(message.text)") + * } + * result = nextPage + * } else { + * break // No more pages + * } + * } + * print("All message history retrieved") + * } catch { + * print("Failed to retrieve message history: \(error)") + * } + * ``` */ func history(withParams params: HistoryParams) async throws(ErrorInfo) -> HistoryResult /** - * Send a message in the chat room. + * Send a message to the chat room. + * + * This method publishes a new message to the chat room using the Ably Chat API. + * The message will be delivered to all subscribers in real-time. + * + * - Important: The function can return before OR after the message is received + * from the realtime channel. This means subscribers may see the message before + * the send operation completes. * - * This method uses the Ably Chat API endpoint for sending messages. + * - Note: This method uses the Ably Chat REST API and so does not require the room + * to be attached to be called. * * - Parameters: - * - params: An object containing `text`, `headers` and `metadata` for the message. + * - params: Message parameters containing the text and optional metadata/headers + * + * - Returns: The sent ``Message`` object + * + * - Throws: ``ErrorInfo`` when the message fails to send due to network issues, authentication problems, or rate limiting + * + * ## Example * - * - Returns: The published message, with the action of the message set as `.messageCreate`. + * ```swift + * import Ably + * import AblyChat * - * - Note: It is possible to receive your own message via the messages subscription before this method returns. + * let chatClient: ChatClient // existing ChatClient instance + * + * let room = try await chatClient.rooms.get("general-chat") + * + * // Send a message with metadata and headers + * do { + * let message = try await room.messages.send(withParams: .init( + * text: "Hello, everyone! 👋", + * metadata: ["priority": "high", "category": "greeting"], + * headers: ["content-type": "text", "language": "en"] + * )) + * + * print("Message sent successfully: \(message.serial)") + * } catch { + * print("Failed to send message: \(error)") + * } + * ``` */ func send(withParams params: SendMessageParams) async throws(ErrorInfo) -> Message /** * Update a message in the chat room. * - * Note that this method may return before OR after the updated message is - * received from the realtime channel. This means you may see the update that - * was just sent in a callback to `subscribe` before this method returns. + * This method modifies an existing message's content, metadata, or headers. + * The update creates a new version of the message while preserving the original + * serial identifier. Subscribers will receive an update event in real-time. * - * NOTE: The Message instance returned by this method is the state of the message as a result of the update operation. - * If you have a subscription to message events via `subscribe`, you should discard the message instance returned by - * this method and use the event payloads from the subscription instead. + * - Important: The function can return before OR after the update event is received + * from the realtime channel. Subscribers may see the update event before this method + * completes. * - * This method uses PUT-like semantics: if headers and metadata are omitted from the updateParams, then - * the existing headers and metadata are replaced with the empty objects. + * - Note: + * - This method uses PUT-like semantics. If metadata or headers are omitted + * from updateParams, they will be replaced with empty objects, not merged with existing values. + * - The returned Message instance represents the state after the update. If you + * have active subscriptions, use the event payloads from those subscriptions instead + * of the returned instance for consistency. + * - This method uses the Ably Chat REST API and so does not require the room + * to be attached to be called. * * - Parameters: - * - serial: The serial of the message to update. - * - updateParams: The parameters for updating the message. + * - serial: The unique identifier of the message to update. + * - params: The new message content and properties. * - details: Optional details to record about the update action. * - * - Returns: The updated message. + * - Returns: The updated ``Message`` object with `isUpdated` set to true and update metadata populated + * + * - Throws: ``ErrorInfo`` when the message is not found, user lacks permissions, or network/server errors occur + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * let room = try await chatClient.rooms.get("team-updates") + * + * // Update a message with corrected text and tracking + * let messageSerial = "01726585978590-001@abcdefghij:001" + * + * do { + * let updatedMessage = try await room.messages.update( + * withSerial: messageSerial, + * params: .init( + * text: "Meeting is scheduled for 3 PM (corrected time)" + * ), + * details: .init( + * description: "Corrected meeting time", + * metadata: ["editTimestamp": "\(Date())"] + * ) + * ) + * + * print("Updated text: \(updatedMessage.text)") + * } catch let error where error.code == 40400 { + * print("Message not found: \(messageSerial)") + * } catch let error where error.code == 40300 { + * print("Permission denied: Cannot update this message") + * } catch { + * print("Failed to update message: \(error)") + * } + * ``` */ func update(withSerial serial: String, params: UpdateMessageParams, details: OperationDetails?) async throws(ErrorInfo) -> Message /** * Delete a message in the chat room. * - * This method uses the Ably Chat API REST endpoint for deleting messages. - * It performs a `soft` delete, meaning the message is marked as deleted. + * This method performs a "soft delete" on a message, marking it as deleted rather + * than permanently removing it. The deleted message will still be visible in message + * history but will be flagged as deleted. Subscribers will receive a deletion event + * in real-time. * - * Note that this method may return before OR after the message is deleted - * from the realtime channel. This means you may see the message that was just - * deleted in a callback to `subscribe` before this method returns. + * - Important: The function can return before OR after the deletion event is received + * from the realtime channel. Subscribers may see the deletion event before this method + * completes. * - * NOTE: The Message instance returned by this method is the state of the message as a result of the delete operation. - * If you have a subscription to message events via `subscribe`, you should discard the message instance returned by - * this method and use the event payloads from the subscription instead. - * - * Should you wish to restore a deleted message, and providing you have the appropriate permissions, - * you can simply send an update to the original message. - * Note: This is subject to change in future versions, whereby a new permissions model will be introduced - * and a deleted message may not be restorable in this way. + * - Note: + * - The returned Message instance represents the state after deletion. If you + * have active subscriptions, use the event payloads from those subscriptions instead + * of the returned instance for consistency. + * - This method uses the Ably Chat REST API and so does not require the room + * to be attached to be called. * * - Parameters: - * - serial: The serial of the message to delete. + * - serial: The unique identifier of the message to delete. * - details: Optional details to record about the delete action. * - * - Returns: The deleted message. + * - Returns: The deleted ``Message`` object with `isDeleted` set to true and deletion metadata populated + * + * - Throws: ``ErrorInfo`` when the message is not found, user lacks permissions, or network/server errors occur + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * let room = try await chatClient.rooms.get("public-chat") + * + * // Serial of the message to delete + * let messageSerial = "01726585978590-001@abcdefghij:001" + * + * do { + * let deletedMessage = try await room.messages.delete( + * withSerial: messageSerial, + * details: .init( + * description: "Inappropriate content removed by moderator", + * metadata: [ + * "reason": "policy-violation", + * "timestamp": "\(Date())" + * ] + * ) + * ) + * + * print("Deleted message: \(deletedMessage.text)") + * } catch let error where error.code == 40400 { + * print("Message not found: \(messageSerial)") + * } catch let error where error.code == 40300 { + * print("Permission denied: Cannot delete this message") + * } catch { + * print("Failed to delete message: \(error)") + * } + * ``` */ func delete(withSerial serial: String, details: OperationDetails?) async throws(ErrorInfo) -> Message /** - * Get a message by its serial. + * Get a specific message by its unique serial identifier. + * + * This method retrieves a single message using its serial, which is a unique + * identifier assigned to each message when it's created. + * + * - Note: This method uses the Ably Chat REST API and so does not require the room + * to be attached to be called. * * - Parameters: - * - serial: The serial of the message to get. + * - serial: The unique serial identifier of the message to retrieve. + * + * - Returns: The ``Message`` object + * + * - Throws: ``ErrorInfo`` when the message is not found or network/server errors occur + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance * - * - Returns: The message with the specified serial. + * let room = try await chatClient.rooms.get("customer-support") + * + * // Get a specific message by its serial + * let messageSerial = "01726585978590-001@abcdefghij:001" + * + * do { + * let message = try await room.messages.get(withSerial: messageSerial) + * + * print("Serial: \(message.serial)") + * print("From: \(message.clientID)") + * print("Text: \(message.text)") + * + * } catch let error where error.statusCode == 404 { + * print("Message not found: \(messageSerial)") + * } catch { + * print("Failed to retrieve message: \(error)") + * } + * ``` */ func get(withSerial serial: String) async throws(ErrorInfo) -> Message /** - * Add, delete, and subscribe to message reactions. + * Send, delete, and subscribe to message reactions. + * + * This property provides access to the message reactions functionality, allowing you to + * add reactions to specific messages, remove reactions, and subscribe to reaction events + * in real-time. */ var reactions: Reactions { get } } -// swiftlint:disable:next missing_docs +/// Extension providing AsyncSequence-based subscription methods for messages. public extension Messages { /** * Subscribe to new messages in this chat room. @@ -194,7 +427,14 @@ public struct SendMessageParams: Sendable { */ public var headers: MessageHeaders? - // swiftlint:disable:next missing_docs + /** + * Creates a new SendMessageParams instance. + * + * - Parameters: + * - text: The text of the message + * - metadata: Optional metadata to attach to the message + * - headers: Optional headers for the message + */ public init(text: String, metadata: MessageMetadata? = nil, headers: MessageHeaders? = nil) { self.text = text self.metadata = metadata @@ -229,7 +469,13 @@ public struct OperationDetails: Sendable { /// Optional metadata that will be added to the action. Defaults to empty. public var metadata: MessageOperationMetadata? - // swiftlint:disable:next missing_docs + /** + * Creates a new OperationDetails instance. + * + * - Parameters: + * - description: Optional description for the operation + * - metadata: Optional metadata to attach to the operation + */ public init(description: String? = nil, metadata: MessageOperationMetadata? = nil) { self.description = description self.metadata = metadata @@ -237,14 +483,20 @@ public struct OperationDetails: Sendable { } /** - * Options for querying messages in a chat room. + * Parameters for querying messages in a chat room. */ public struct HistoryParams: Sendable { - // swiftlint:disable:next missing_docs + /** + * The order in which results should be returned when performing a paginated query (e.g. message history). + */ public enum OrderBy: Sendable { - // swiftlint:disable:next missing_docs + /** + * Return results in ascending order (oldest first). + */ case oldestFirst - // swiftlint:disable:next missing_docs + /** + * Return results in descending order (newest first). + */ case newestFirst } @@ -282,7 +534,15 @@ public struct HistoryParams: Sendable { // (CHA-M5g) The subscribers subscription point must be additionally specified (internally, by us) in the fromSerial query parameter. internal var fromSerial: String? - // swiftlint:disable:next missing_docs + /** + * Creates a new HistoryParams instance. + * + * - Parameters: + * - start: The start of the time window to query from + * - end: The end of the time window to query from + * - limit: The maximum number of messages to return + * - orderBy: The direction to query messages in + */ public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil, orderBy: HistoryParams.OrderBy? = nil) { self.start = start self.end = end @@ -320,7 +580,14 @@ public struct HistoryBeforeSubscribeParams: Sendable { */ public var limit: Int? - // swiftlint:disable:next missing_docs + /** + * Creates a new HistoryBeforeSubscribeParams instance. + * + * - Parameters: + * - start: The start of the time window to query from + * - end: The end of the time window to query from + * - limit: The maximum number of messages to return + */ public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil) { self.start = start self.end = end @@ -368,21 +635,38 @@ internal extension HistoryParams { } } -/// Event type for chat message subscription. +/** + * All chat message events. + */ public enum ChatMessageEventType: Sendable { - // swiftlint:disable:next missing_docs + /** + * Fires when a new chat message is received. + */ case created - // swiftlint:disable:next missing_docs + + /** + * Fires when a chat message is updated. + */ case updated - // swiftlint:disable:next missing_docs + + /** + * Fires when a chat message is deleted. + */ case deleted } -/// Event emitted by message subscriptions, containing the type and the message. +/** + * Payload for a message event. + */ public struct ChatMessageEvent: Sendable { - // swiftlint:disable:next missing_docs + /** + * The type of the message event. + */ public var type: ChatMessageEventType - // swiftlint:disable:next missing_docs + + /** + * The message that was received. + */ public var message: Message /// Memberwise initializer to create a `ChatMessageEvent`. @@ -410,7 +694,7 @@ public struct ChatMessageEvent: Sendable { /// /// You should only iterate over a given `MessageSubscriptionResponseAsyncSequence` once; the results of iterating more than once are undefined. public final class MessageSubscriptionResponseAsyncSequence>: Sendable, AsyncSequence { - // swiftlint:disable:next missing_docs + /// The element type produced by this async sequence. public typealias Element = ChatMessageEvent private let subscription: SubscriptionAsyncSequence @@ -427,8 +711,13 @@ public final class MessageSubscriptionResponseAsyncSequence(mockAsyncSequence: Underlying, mockHistoryBeforeSubscribe: @escaping @Sendable (HistoryBeforeSubscribeParams) async throws(ErrorInfo) -> HistoryResult) where Underlying.Element == Element { subscription = .init(mockAsyncSequence: mockAsyncSequence) historyBeforeSubscribe = mockHistoryBeforeSubscribe @@ -443,12 +732,12 @@ public final class MessageSubscriptionResponseAsyncSequence HistoryResult { try await historyBeforeSubscribe(params) } - // swiftlint:disable:next missing_docs + /// The iterator type for this async sequence. public struct AsyncIterator: AsyncIteratorProtocol { private var subscriptionIterator: SubscriptionAsyncSequence.AsyncIterator @@ -456,13 +745,13 @@ public final class MessageSubscriptionResponseAsyncSequence Element? { await subscriptionIterator.next() } } - // swiftlint:disable:next missing_docs + /// Creates an async iterator for iterating over message events. public func makeAsyncIterator() -> AsyncIterator { .init(subscriptionIterator: subscription.makeAsyncIterator()) } diff --git a/Sources/AblyChat/Occupancy.swift b/Sources/AblyChat/Occupancy.swift index 525bde27..a15321cb 100644 --- a/Sources/AblyChat/Occupancy.swift +++ b/Sources/AblyChat/Occupancy.swift @@ -8,35 +8,141 @@ import Ably */ @MainActor public protocol Occupancy: AnyObject, Sendable { - // swiftlint:disable:next missing_docs + /// The subscription type for occupancy event listeners. associatedtype Subscription: AblyChat.Subscription /** - * Subscribes a given listener to occupancy updates of the chat room. + * Subscribes to occupancy updates for the chat room. * - * Note that it is a programmer error to call this method if occupancy events are not enabled in the room options. Make sure to set `enableEvents: true` in your room's occupancy options to use this feature. + * Receives updates whenever the number of connections or present members in the room changes. + * This is useful for displaying active user counts, monitoring room capacity, or tracking + * engagement metrics. + * + * - Note: + * - Requires ``OccupancyOptions/enableEvents`` to be true in the room's occupancy options. It's a programmer error otherwise. + * - The room should be attached to receive occupancy events. * * - Parameters: - * - callback: The listener closure for capturing room ``OccupancyEvent`` events. + * - callback: Callback invoked when room occupancy changes + * + * - Returns: Subscription object with an unsubscribe method + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Create room with occupancy events enabled + * let room = try await chatClient.rooms.get(named: "conference-room", options: RoomOptions( + * occupancy: .init(enableEvents: true) + * )) + * + * // Subscribe to occupancy updates + * let subscription = room.occupancy.subscribe { event in + * let connections = event.occupancy.connections + * let presenceMembers = event.occupancy.presenceMembers + * + * print("Room occupancy updated:") + * print("Total connections: \(connections)") + * print("Presence members: \(presenceMembers)") + * } * - * - Returns: A subscription that can be used to unsubscribe from ``OccupancyEvent`` events. + * // Attach to the room to start receiving events + * try await room.attach() + * + * // Later, unsubscribe when done + * subscription.unsubscribe() + * ``` */ @discardableResult func subscribe(_ callback: @escaping @MainActor (OccupancyEvent) -> Void) -> Subscription /** - * Get the current occupancy of the chat room. + * Fetches the current occupancy of the chat room from the server. + * + * Retrieves the latest occupancy metrics, including the number + * of active connections and presence members. Use this for on-demand occupancy + * checks or when occupancy events are not enabled. + * + * - Note: This method uses the Ably Chat REST API and so does not require the room + * to be attached to be called. + * + * - Returns: Current occupancy data + * + * - Throws: ``ErrorInfo`` + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * let room = try await chatClient.rooms.get(named: "webinar-room") + * + * // Get current occupancy on demand + * do { + * let occupancy = try await room.occupancy.get() * - * - Returns: A current occupancy of the chat room. + * print("Current room statistics:") + * print("Active connections: \(occupancy.connections)") + * print("Presence members: \(occupancy.presenceMembers)") + * } catch { + * print("Failed to fetch occupancy: \(error)") + * } + * ``` */ func get() async throws(ErrorInfo) -> OccupancyData /** - * Get the latest occupancy data received from realtime events. + * Gets the latest occupancy data cached from realtime events. * - * Note that it is a programmer error to read this property if occupancy events are not enabled in the room options. Make sure to set `enableEvents: true` in your room's occupancy options to use this feature. + * Returns the most recent occupancy metrics received via subscription. Returns nil + * if no occupancy events have been received yet since the room was attached. * - * - Returns: The latest occupancy data, or nil if no realtime events have been received yet. + * - Note: + * - Requires `enableEvents` to be true in the room's occupancy options. + * - Returns nil until the first occupancy event is received. + * - It is a programmer error to read this property if occupancy events are not enabled in the room options. + * + * - Returns: Latest cached occupancy data or nil if no events received + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Room with occupancy events enabled + * let room = try await chatClient.rooms.get(named: "gaming-lobby", options: RoomOptions( + * occupancy: .init(enableEvents: true) + * )) + * + * // Subscribe to occupancy events + * room.occupancy.subscribe { event in + * print("Occupancy updated: \(event.occupancy)") + * } + * + * // Get cached occupancy instantly (after first event) + * func displayCurrentOccupancy() { + * if let occupancy = room.occupancy.current { + * print("Current cached occupancy:") + * print("Connections: \(occupancy.connections)") + * print("Presence: \(occupancy.presenceMembers)") + * } else { + * print("No occupancy data received yet, try fetching from server") + * } + * } + * + * // Attach to the room to start receiving events + * try await room.attach() + * ``` */ var current: OccupancyData? { get } } @@ -98,18 +204,28 @@ public struct OccupancyData: Sendable { } } -// swiftlint:disable:next missing_docs +/** + * Enum representing occupancy events. + */ public enum OccupancyEventType: Sendable { - // swiftlint:disable:next missing_docs + /** + * Event triggered when occupancy is updated. + */ case updated } -// (CHA-O2) The occupancy event format is shown here (https://sdk.ably.com/builds/ably/specification/main/chat-features/#chat-structs-occupancy-event) -// swiftlint:disable:next missing_docs +/** + * Represents an occupancy event. + */ public struct OccupancyEvent: Sendable { - // swiftlint:disable:next missing_docs + /** + * The type of the occupancy event. + */ public var type: OccupancyEventType - // swiftlint:disable:next missing_docs + + /** + * The occupancy data. + */ public var occupancy: OccupancyData /// Memberwise initializer to create a `OccupancyEvent`. diff --git a/Sources/AblyChat/PaginatedResult.swift b/Sources/AblyChat/PaginatedResult.swift index ea87fab2..bf2e49da 100644 --- a/Sources/AblyChat/PaginatedResult.swift +++ b/Sources/AblyChat/PaginatedResult.swift @@ -1,26 +1,51 @@ import Ably -// This disable of attributes can be removed once missing_docs fixed here -// swiftlint:disable attributes +/** + * Represents the result of a paginated query. + */ @MainActor -// swiftlint:disable:next missing_docs public protocol PaginatedResult: AnyObject, Sendable { - // swiftlint:enable attributes - - // swiftlint:disable:next missing_docs + /// The type of items contained in this paginated result. associatedtype Item - // swiftlint:disable:next missing_docs + /** + * The items returned by the query. + */ var items: [Item] { get } - // swiftlint:disable:next missing_docs + + /** + * Whether there are more items to query. + * + * - Returns: `true` if there are more items to query, `false` otherwise. + */ var hasNext: Bool { get } - // swiftlint:disable:next missing_docs + + /** + * Whether this is the last page of items. + * + * - Returns: `true` if this is the last page of items, `false` otherwise. + */ var isLast: Bool { get } - // swiftlint:disable:next missing_docs + + /** + * Fetches the next page of items. + * + * - Returns: The next page of items or `nil` if there are no more items. + */ func next() async throws(ErrorInfo) -> Self? - // swiftlint:disable:next missing_docs + + /** + * Fetches the first page of items. + * + * - Returns: The first page of items. + */ func first() async throws(ErrorInfo) -> Self - // swiftlint:disable:next missing_docs + + /** + * Fetches the current page of items. + * + * - Returns: The current page of items. + */ func current() async throws(ErrorInfo) -> Self } diff --git a/Sources/AblyChat/Presence.swift b/Sources/AblyChat/Presence.swift index cf8f6a75..3041e13f 100644 --- a/Sources/AblyChat/Presence.swift +++ b/Sources/AblyChat/Presence.swift @@ -1,6 +1,8 @@ import Ably -// swiftlint:disable:next missing_docs +/** + * Type for data that can be entered into presence as an object literal. + */ public typealias PresenceData = JSONObject /** @@ -11,77 +13,278 @@ public typealias PresenceData = JSONObject */ @MainActor public protocol Presence: AnyObject, Sendable { - // swiftlint:disable:next missing_docs + /// The subscription type for presence event listeners. associatedtype Subscription: AblyChat.Subscription /** - * Same as ``get(params:)``, but with defaults params. + * Same as ``Presence/get(withParams:)``, but with defaults params. */ func get() async throws(ErrorInfo) -> [PresenceMember] /** - * Method to get list of the current online users and returns the latest presence messages associated to it. + * Retrieves the current members present in the chat room. + * + * - Note: The room must be attached before calling this method. * * - Parameters: - * - params: ``PresenceParams`` that control how the presence set is retrieved. + * - params: Optional parameters to filter the presence set * - * - Returns: An array of ``PresenceMember``s. + * - Returns: An array of presence members currently in the room * - * - Throws: An `ErrorInfo`. + * - Throws: ``ErrorInfo`` with code ``InternalError/ErrorCode/roomInInvalidState`` if the room is not attached + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room with default options and attach to it + * let room = try await chatClient.rooms.get("meeting-room") + * try await room.attach() + * + * do { + * // Get all currently present members + * let members = try await room.presence.get() + * print("\(members.count) users present in the room") + * + * for member in members { + * print("User \(member.clientID) is present with data: \(String(describing: member.data))") + * } + * + * // Get members with a specific client ID + * let specificUser = try await room.presence.get(withParams: .init(clientID: "user-456")) + * if !specificUser.isEmpty { + * print("User-456 is in the room") + * } + * } catch { + * print("Failed to get presence members: \(error)") + * } + * ``` */ func get(withParams params: PresenceParams) async throws(ErrorInfo) -> [PresenceMember] /** - * Method to check if user with supplied clientId is online. + * Checks whether a specific user is currently present in the chat room. + * Useful if you just need a boolean check rather than the full presence member data. + * + * - Note: The room must be attached before calling this method. * * - Parameters: - * - clientID: The client ID to check if it is present in the room. + * - clientID: The client ID of the user to check * - * - Returns: A boolean value indicating whether the user is present in the room. + * - Returns: true if the user is present, false otherwise * - * - Throws: An `ErrorInfo`. + * - Throws: ``ErrorInfo`` with code ``InternalError/ErrorCode/roomInInvalidState`` if the room is not attached, or with ``ErrorInfo`` if the operation fails for any other reason + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room with default options and attach to it + * let room = try await chatClient.rooms.get("meeting-room") + * try await room.attach() + * + * do { + * // Check if a specific user is present + * let isPresent = try await room.presence.isUserPresent(withClientID: "user-456") + * + * if isPresent { + * print("User-456 is currently in the room") + * } else { + * print("User-456 is not in the room") + * } + * } catch { + * print("Failed to check user presence: \(error)") + * } + * ``` */ func isUserPresent(withClientID clientID: String) async throws(ErrorInfo) -> Bool /** - * Method to join room presence, will emit an enter event to all subscribers. Repeat calls will trigger more enter events. + * Enters the current user into the chat room presence set. + * Emits an 'enter' event to all presence subscribers. Multiple calls will emit additional `update` events if the + * user is already present. + * + * - Note: The room must be attached before calling this method. * * - Parameters: - * - data: The users data, a JSON serializable object that will be sent to all subscribers. + * - data: Optional JSON-serializable data to associate with the user's presence * - * - Throws: An `ErrorInfo`. + * - Throws: ``ErrorInfo`` with code ``InternalError/ErrorCode/roomInInvalidState`` if the room is not attached + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room with default options and attach to it + * let room = try await chatClient.rooms.get("meeting-room") + * try await room.attach() + * + * do { + * // Enter with user metadata + * try await room.presence.enter(withData: [ + * "avatar": "https://example.com/avatar.jpg", + * "status": "online", + * "role": "moderator" + * ]) + * + * print("Successfully entered the room") + * } catch { + * print("Failed to enter room: \(error)") + * } + * ``` */ func enter(withData data: PresenceData) async throws(ErrorInfo) /** - * Method to update room presence, will emit an update event to all subscribers. If the user is not present, it will be treated as a join event. + * Updates the presence data for the current user in the chat room. + * Emits an 'update' event to all subscribers. If the user is not already present, they will be entered automatically. + * + * - Note: + * - The room must be attached before calling this method. + * - This method uses PUT-like semantics - the entire presence data is replaced with the new value. * * - Parameters: - * - data: The users data, a JSON serializable object that will be sent to all subscribers. + * - data: JSON-serializable data to replace the user's current presence data * - * - Throws: An `ErrorInfo`. + * - Throws: ``ErrorInfo`` with code ``InternalError/ErrorCode/roomInInvalidState`` if the room is not attached + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room with default options + * let room = try await chatClient.rooms.get("meeting-room") + * try await room.attach() + * + * do { + * // Initial enter with status + * try await room.presence.enter(withData: [ + * "username": "John Doe", + * "status": "online" + * ]) + * + * // Update status to busy (replaces entire data object) + * try await room.presence.update(withData: [ + * "username": "John Doe", + * "status": "busy", + * "statusMessage": "In a meeting" + * ]) + * + * print("Presence status updated") + * } catch { + * print("Failed to update presence: \(error)") + * } + * ``` */ func update(withData data: PresenceData) async throws(ErrorInfo) /** - * Method to leave room presence, will emit a leave event to all subscribers. If the user is not present, it will be treated as a no-op. + * Removes the current user from the chat room presence set. + * Emits a 'leave' event to all subscribers. If the user is not present, this is a no-op. + * + * - Note: The room must be attached before calling this method. * * - Parameters: - * - data: The users data, a JSON serializable object that will be sent to all subscribers. + * - data: Optional final presence data to include with the leave event * - * - Throws: An `ErrorInfo`. + * - Throws: ``ErrorInfo`` with code ``InternalError/ErrorCode/roomInInvalidState`` if the room is not attached + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room with default options + * let room = try await chatClient.rooms.get("meeting-room") + * try await room.attach() + * + * do { + * // Enter the room + * try await room.presence.enter(withData: [ + * "avatar": "https://example.com/avatar.jpg", + * "status": "online" + * ]) + * + * // Do some work in the room... + * + * // Leave with a final status message + * try await room.presence.leave(withData: [ + * "status": "offline", + * "lastSeen": "\(Date())" + * ]) + * + * print("Successfully left the room") + * } catch { + * print("Failed to leave room: \(error)") + * } + * ``` */ func leave(withData data: PresenceData) async throws(ErrorInfo) /** - * Subscribes a given listener to all presence events in the chat room. + * Subscribes to all presence events in the chat room. * - * Note that it is a programmer error to call this method if presence events are not enabled in the room options. Make sure to set `enableEvents: true` in your room's presence options to use this feature (this is the default value). + * - Note: + * - Requires `enableEvents` to be true in the room's presence options. + * - The room must be attached to receive events in real-time. * * - Parameters: - * - callback: The listener closure for capturing room ``PresenceEvent`` events. + * - callback: Callback function invoked when any presence event occurs * - * - Returns: A subscription that can be used to unsubscribe from ``PresenceEvent`` events. + * - Returns: Subscription object with an unsubscribe method + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room with default options + * let room = try await chatClient.rooms.get("meeting-room") + * + * // Subscribe to all presence events + * let subscription = room.presence.subscribe { event in + * let type = event.type + * let member = event.member + * switch type { + * case .enter: + * print("\(member.clientID) entered at \(member.updatedAt)") + * case .leave: + * print("\(member.clientID) left at \(member.updatedAt)") + * case .update: + * print("\(member.clientID) updated their data: \(String(describing: member.data))") + * case .present: + * print("\(member.clientID) is already present") + * } + * } + * + * // Attach to the room to start receiving events + * try await room.attach() + * + * // Later, unsubscribe when done + * subscription.unsubscribe() + * ``` */ @discardableResult func subscribe(_ callback: @escaping @MainActor (PresenceEvent) -> Void) -> Subscription @@ -111,7 +314,7 @@ public protocol Presence: AnyObject, Sendable { func leave() async throws(ErrorInfo) } -// swiftlint:disable:next missing_docs +/// Extension providing AsyncSequence-based subscription methods for presence. public extension Presence { /** * Subscribes to all presence events in the chat room. @@ -146,7 +349,10 @@ public extension Presence { } /** - * Type for PresenceMember + * Type for PresenceMember. + * + * Presence members are unique based on their `connectionId` and `clientId`. It is possible for + * multiple users to have the same `clientId` if they are connected to the room from different devices. */ public struct PresenceMember: Sendable { /// Memberwise initializer to create a `PresenceMember`. @@ -165,7 +371,9 @@ public struct PresenceMember: Sendable { */ public var clientID: String - /// The connection ID of this presence member. + /** + * The connection ID of this presence member. + */ public var connectionID: String /** @@ -178,7 +386,10 @@ public struct PresenceMember: Sendable { * The extras associated with the presence member. */ public var extras: [String: JSONValue]? - // swiftlint:disable:next missing_docs + + /** + * The timestamp of when the last change in state occurred for this presence member. + */ public var updatedAt: Date } @@ -272,7 +483,14 @@ public struct PresenceParams: Sendable { /// Sets whether to wait for a full presence set synchronization between Ably and the clients on the room to complete before returning the results. Synchronization begins as soon as the room is ``RoomStatus/attached``. When set to `true` the results will be returned as soon as the sync is complete. When set to `false` the current list of members will be returned without the sync completing. The default is `true`. public var waitForSync = true - // swiftlint:disable:next missing_docs + /** + * Creates a new PresenceParams instance. + * + * - Parameters: + * - clientID: Filters presence members by a specific client ID + * - connectionID: Filters presence members by a specific connection ID + * - waitForSync: Whether to wait for presence sync to complete (defaults to `true`) + */ public init(clientID: String? = nil, connectionID: String? = nil, waitForSync: Bool = true) { self.clientID = clientID self.connectionID = connectionID diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index d82de853..694cbe5b 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -5,139 +5,398 @@ import Ably */ @MainActor public protocol Room: AnyObject, Sendable { - // swiftlint:disable:next missing_docs + /// The underlying Ably Realtime channel type used by this room. associatedtype Channel - // swiftlint:disable:next missing_docs + /// The messages feature type for sending and receiving chat messages. associatedtype Messages: AblyChat.Messages - // swiftlint:disable:next missing_docs + /// The presence feature type for managing user presence in the room. associatedtype Presence: AblyChat.Presence - // swiftlint:disable:next missing_docs + /// The room reactions feature type for sending and receiving room-level reactions. associatedtype Reactions: AblyChat.RoomReactions - // swiftlint:disable:next missing_docs + /// The typing indicators feature type for managing typing events. associatedtype Typing: AblyChat.Typing - // swiftlint:disable:next missing_docs + /// The occupancy feature type for monitoring room occupancy metrics. associatedtype Occupancy: AblyChat.Occupancy - // swiftlint:disable:next missing_docs + /// The subscription type for room status change listeners. associatedtype StatusSubscription: AblyChat.StatusSubscription /** * The unique identifier of the room. * - * - Returns: The room identifier. + * - Returns: The room name as provided when the room was created + * + * ## Example + * + * ```swift + * let room = try await chatClient.rooms.get("sports-discussion") + * print("Connected to room: \(room.name)") + * + * // Output: Connected to room: sports-discussion + * ``` */ var name: String { get } /** - * Allows you to send, subscribe-to and query messages in the room. + * Provides access to the messages feature for sending, receiving, and querying chat messages. + * + * - Returns: The ``Messages`` instance for this room + * + * ## Example * - * - Returns: The messages instance for the room. + * ```swift + * let room = try await chatClient.rooms.get("team-chat") + * + * // Access messages feature + * let messages = room.messages + * ``` */ var messages: Messages { get } /** - * Allows you to subscribe to presence events in the room. + * Provides access to the presence feature for tracking user presence state. + * + * - Returns: The Presence instance for this room * - * - Note: To access this property if presence is not enabled for the room is a programmer error, and will lead to `fatalError` being called. + * ## Example * - * - Returns: The presence instance for the room. + * ```swift + * let room = try await chatClient.rooms.get("meeting-room") + * + * // Access presence feature + * let presence = room.presence + * ``` */ var presence: Presence { get } /** - * Allows you to interact with room-level reactions. + * Provides access to room-level reactions for sending ephemeral reactions. + * + * - Returns: The ``RoomReactions`` instance for this room * - * - Note: To access this property if presence is not enabled for the room is a programmer error, and will lead to `fatalError` being called. + * ## Example * - * - Returns: The room reactions instance for the room. + * ```swift + * let room = try await chatClient.rooms.get("live-stream") + * + * // Access room reactions feature + * let reactions = room.reactions + * ``` */ var reactions: Reactions { get } /** - * Allows you to interact with typing events in the room. + * Provides access to the typing indicators feature for showing who is currently typing. + * + * - Returns: The ``Typing`` instance for this room * - * - Note: To access this property if presence is not enabled for the room is a programmer error, and will lead to `fatalError` being called. + * ## Example * - * - Returns: The typing instance for the room. + * ```swift + * let room = try await chatClient.rooms.get("support-chat") + * + * // Access typing feature + * let typing = room.typing + * ``` */ var typing: Typing { get } /** - * Allows you to interact with occupancy metrics for the room. + * Provides access to room occupancy metrics for tracking connection and presence counts. + * + * - Returns: The ``Occupancy`` instance for this room * - * - Note: To access this property if presence is not enabled for the room is a programmer error, and will lead to `fatalError` being called. + * ## Example * - * - Returns: The occupancy instance for the room. + * ```swift + * let room = try await chatClient.rooms.get("webinar-room") + * + * // Access occupancy feature + * let occupancy = room.occupancy + * ``` */ var occupancy: Occupancy { get } /** - * The current status of the room. + * The current lifecycle status of the room. + * + * - Returns: The current ``RoomStatus`` value + * + * ## Example + * + * ```swift + * let room = try await chatClient.rooms.get("game-lobby") * - * - Returns: The current room status. + * // Check room status + * if room.status == .attached { + * print("Room is connected and ready") + * } else if room.status == .failed { + * print("Room connection failed") + * } + * ``` */ var status: RoomStatus { get } /** - * The current error, if any, that caused the room to enter the current status. + * The error that caused the room to enter its current status, if any. + * + * - Returns: ErrorInfo if an error caused the current status, nil otherwise + * + * ## Example + * + * ```swift + * let room = try await chatClient.rooms.get("private-chat") + * + * if let error = room.error { + * print("Room error: \(error.message)") + * print("Error code: \(error.code)") + * + * // Handle specific error codes + * if error.code == 40300 { + * showMessage("Access denied to this room") + * } else { + * showMessage("Connection failed: \(error.message)") + * } + * } + * ``` */ var error: ErrorInfo? { get } /** - * Subscribes a given listener to the room status changes. + * Registers a listener to be notified of room status changes. + * + * Status changes indicate the room's connection lifecycle. Use this to + * monitor room health and handle connection issues over time. * * - Parameters: - * - callback: The listener closure for capturing ``RoomStatusChange`` events. + * - callback: Callback invoked when the room status changes + * + * - Returns: Subscription object with an unsubscribe method + * + * ## Example * - * - Returns: A subscription that can be used to unsubscribe from ``RoomStatusChange`` events. + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * let room = try await chatClient.rooms.get("support-chat") + * + * // Monitor room status changes + * let statusSubscription = room.onStatusChange { change in + * print("Room status: \(change.previous) -> \(change.current)") + * + * // Handle different status transitions + * switch change.current { + * case .attached: + * print("Room is now connected") + * enableChatUI() + * showOnlineIndicator() + * + * case .attaching: + * print("Connecting to room...") + * showConnectingSpinner() + * default: + * break + * } + * } + * + * // Clean up when done + * statusSubscription.off() + * ``` */ @discardableResult func onStatusChange(_ callback: @escaping @MainActor (RoomStatusChange) -> Void) -> StatusSubscription /** - * Subscribes a given listener to a detected discontinuity. + * Registers a handler for discontinuity events in the room's connection. + * + * A discontinuity occurs when the connection is interrupted and cannot resume + * from its previous state, potentially resulting in missed messages or events. + * Use this to detect gaps in the event stream and take corrective action. + * + * - Note: + * - Discontinuities require fetching missed messages via history. + * - Message subscriptions automatically reset their position on discontinuity, see ``MessageSubscriptionResponse/historyBeforeSubscribe(withParams:)`` for more information. + * - You should subscribe to discontinuities before attaching to the room. * * - Parameters: - * - callback: The listener closure for capturing discontinuity events. + * - handler: Callback invoked when a discontinuity is detected * - * - Returns: A subscription that can be used to unsubscribe from discontinuity events. + * - Returns: Subscription object with an unsubscribe method + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * let room = try await chatClient.rooms.get(named: "critical-updates") + * + * // Handle discontinuities to ensure no messages are missed + * let discontinuitySubscription = room.onDiscontinuity { reason in + * print("Discontinuity detected: \(reason)") + * + * // Show warning to user + * showDiscontinuityWarning("Connection interrupted - fetching missed messages...") + * + * // You may also want to fetch missed messages to fill gaps during the discontinuity. + * } + * + * // Attach to the room to start receiving events + * try await room.attach() + * + * // Clean up + * discontinuitySubscription.off() + * ``` */ @discardableResult func onDiscontinuity(_ callback: @escaping @MainActor (ErrorInfo) -> Void) -> StatusSubscription /** - * Attaches to the room to receive events in realtime. + * Attaches to the room to begin receiving events. * - * If a room fails to attach, it will enter either the ``RoomStatus/suspended(error:)`` or ``RoomStatus/failed(error:)`` state. + * Establishes an attachment to the room, enabling message delivery, + * presence updates, typing, and other events. The room must be + * attached before non-REST-based operations (like `presence.enter()`) can be performed. * - * If the room enters the failed state, then it will not automatically retry attaching and intervention is required. + * - Note: + * - If attachment fails, the room enters ``RoomStatus/suspended`` or ``RoomStatus/failed`` state. + * - Suspended rooms automatically retry; Failed rooms require manual intervention. + * - Throws an ``ErrorInfo`` for suspended states, but the room will retry attaching after a delay. * - * If the room enters the suspended state, then the call to attach will throw `ErrorInfo` with the cause of the suspension. However, - * the room will automatically retry attaching after a delay. + * - Throws: ``ErrorInfo`` if the room enters suspended state (auto-retry will occur), or if the room enters failed state (manual intervention required) * - * - Throws: An `ErrorInfo`. + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * let room = try await chatClient.rooms.get("team-standup") + * + * // Attach to room with error handling + * do { + * try await room.attach() + * print("Successfully attached to room") + * + * // Now safe to use room features + * try await room.presence.enter() + * + * // And subscriptions will start receiving events + * room.messages.subscribe { event in + * print("New message: \(event.message)") + * } + * } catch { + * print("Failed to attach to room: \(error)") + * + * // Check current room status + * if room.status == .suspended { + * print("Room suspended, will retry automatically") + * } else if room.status == .failed { + * print("Room failed, manual intervention needed") + * } + * } + * ``` */ func attach() async throws(ErrorInfo) /** - * Detaches from the room to stop receiving events in realtime. + * Detaches from the room to stop receiving chat events. + * + * Subscriptions remain registered but won't receive events until the room is + * reattached. Use this to gracefully detach when leaving a chat view. This command leaves all + * subscriptions intact, so they will resume receiving events when the room is reattached. * - * - Throws: An `ErrorInfo`. + * - Throws: ``ErrorInfo`` + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room with default options and attach to it + * let room = try await chatClient.rooms.get("customer-support") + * try await room.attach() + * + * // Do chat operations... + * + * do { + * // Detach from room + * try await room.detach() + * print("Successfully detached from room") + * } catch { + * print("Failed to detach from room: \(error)") + * } + * ``` */ func detach() async throws(ErrorInfo) /** - * Returns the room options. + * Returns a copy of the options used to configure the room. + * + * Provides access to all room configuration including presence, typing, reactions, + * and occupancy settings. The returned object is a copy to prevent external + * modifications to the room's configuration. + * + * - Returns: A copy of the room options + * + * ## Example * - * - Returns: A copy of the options used to create the room. + * ```swift + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Create room with specific options + * let room = try await chatClient.rooms.get(named: "conference-hall", options: RoomOptions( + * presence: PresenceOptions( + * enableEvents: true + * ), + * typing: TypingOptions( + * heartbeatThrottle: 1.5 + * ), + * occupancy: OccupancyOptions( + * enableEvents: true + * ) + * )) + * + * // Get room options to check configuration + * let options = room.options + * + * print("Room configuration:") + * print("Presence events: \(String(describing: options.presence?.enableEvents))") + * print("Typing throttle: \(String(describing: options.typing?.heartbeatThrottle))") + * print("Occupancy events: \(String(describing: options.occupancy?.enableEvents))") + * ``` */ var options: RoomOptions { get } /** - * Get the underlying Ably realtime channel used for the room. + * Provides direct access to the underlying Ably Realtime channel. + * + * Use this for advanced scenarios requiring direct access to the underlying channel. Directly interacting + * with the Ably channel can lead to unexpected behavior, and so is generally discouraged. + * + * - Returns: The underlying Ably RealtimeChannel instance + * + * ## Example + * + * ```swift + * let room = try await chatClient.rooms.get(named: "advanced-room") * - * - Returns: The realtime channel. + * // Access underlying channel for advanced operations + * let channel = room.channel + * ``` */ var channel: Channel { get } } @@ -373,12 +632,12 @@ internal class DefaultRoom Void) -> Subscription @@ -69,7 +152,8 @@ public extension RoomReactions { */ public struct SendReactionParams: Sendable { /** - * The name of the reaction, for example an emoji or a short string such as "like". + * The name of the reaction, for example an emoji or a short string (e.g., "❤️", "👏", "confetti", "applause"). + * * It is the only mandatory parameter to send a room-level reaction. */ public var name: String @@ -109,17 +193,28 @@ public struct SendReactionParams: Sendable { } } -/// Event type for room reaction subscription. +/** + * The type of room reaction events. + */ public enum RoomReactionEventType: Sendable { - // swiftlint:disable:next missing_docs + /** + * Event triggered when a room reaction was received. + */ case reaction } -/// Event emitted by room reaction subscriptions, containing the type and the reaction. +/** + * Event that is emitted when a room reaction is received. + */ public struct RoomReactionEvent: Sendable { - // swiftlint:disable:next missing_docs + /** + * The type of the event. + */ public var type: RoomReactionEventType - // swiftlint:disable:next missing_docs + + /** + * The reaction that was received. + */ public var reaction: RoomReaction /// Memberwise initializer to create a `RoomReactionEvent`. diff --git a/Sources/AblyChat/Rooms.swift b/Sources/AblyChat/Rooms.swift index cbb5029a..01ff85c3 100644 --- a/Sources/AblyChat/Rooms.swift +++ b/Sources/AblyChat/Rooms.swift @@ -5,31 +5,59 @@ import Ably */ @MainActor public protocol Rooms: AnyObject, Sendable { - // swiftlint:disable:next missing_docs + /// The underlying Ably Realtime channel type used by rooms. associatedtype Channel - // swiftlint:disable:next missing_docs + /// The room type managed by this rooms instance. associatedtype Room: AblyChat.Room where Room.Channel == Channel /** - * Gets a room reference by name. The Rooms class ensures that only one reference - * exists for each room. A new reference object is created if it doesn't already - * exist, or if the one used previously was released using ``release(named:)``. + * Gets a room reference by its unique identifier. * - * Always call `release(named:)` after the ``Room`` object is no longer needed. + * Creates a new room instance or returns an existing one. The Rooms class ensures + * only one instance exists per room name. Always call `release()` when the room + * is no longer needed to free resources. * - * If a call to this method is made for a room that is currently being released, then this it returns only when - * the release operation is complete. - * - * If a call to this method is made, followed by a subsequent call to `release(named:)` before the `get(named:options:)` returns, then the - * promise will throw an error. + * - Note: + * - If options differ from an existing room, an error is thrown. + * - If `get` is called during a release, it waits for release to complete. + * - If `release` is called before `get` resolves, the error will be thrown. * * - Parameters: - * - name: The name of the room. - * - options: The options for the room. + * - name: The unique identifier of the room + * - options: Configuration for the room features + * + * - Returns: The Room instance + * + * - Throws: ``ErrorInfo`` with code ``ErrorCode/roomExistsWithDifferentOptions`` if room exists with different options, ``ErrorCode/resourceDisposed`` if the rooms instance has been disposed, or ``ErrorCode/roomReleasedBeforeOperationCompleted`` if room is released before get completes + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat * - * - Returns: A new or existing `Room` object. + * let chatClient: ChatClient // existing ChatClient instance * - * - Throws: `ErrorInfo` if a room with the same name but different options already exists. + * // Get a room with default options + * let room = try await chatClient.rooms.get(named: "general-chat") + * + * // Always release when done + * await chatClient.rooms.release(named: "general-chat") + * + * // Handle errors when options conflict + * do { + * // This will throw if 'game-room' already exists with different options + * let room1 = try await chatClient.rooms.get(named: "game-room", options: RoomOptions( + * typing: TypingOptions(heartbeatThrottle: 1) + * )) + * + * let room2 = try await chatClient.rooms.get(named: "game-room", options: RoomOptions( + * typing: TypingOptions(heartbeatThrottle: 2) // Different options! + * )) + * } catch let error where error.code == 40000 { + * print("Room already exists with different options") + * } + * ``` */ func get(named name: String, options: RoomOptions) async throws(ErrorInfo) -> Room @@ -39,24 +67,56 @@ public protocol Rooms: AnyObject, Sendable { func get(named name: String) async throws(ErrorInfo) -> Room /** - * Release the ``Room`` object if it exists. This method only releases the reference - * to the Room object from the Rooms instance and detaches the room from Ably. It does not unsubscribe to any - * events. + * Releases a room, freeing its resources and detaching it from Ably. * - * After calling this function, the room object is no-longer usable. If you wish to get the room object again, - * you must call ``Rooms/get(named:options:)``. + * After release, the room object is no longer usable. To use the room again, + * call `get()` to create a new instance. This method only releases the reference + * and detaches from Ably; it doesn't unsubscribe existing event listeners. * - * Calling this function will abort any in-progress `get(named:options:)` calls for the same room. + * - Note: + * - Calling release aborts any in-progress `get` calls for the same room. + * - The room object becomes unusable after release. * * - Parameters: - * - name: The name of the room. + * - name: The unique identifier of the room to release + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room with default options and attach to it + * let room = try await chatClient.rooms.get(named: "temporary-chat") + * try await room.attach() + * + * // Do chat operations... + * + * // When done, release the room + * await chatClient.rooms.release(named: "temporary-chat") + * + * // The room object is now unusable + * try { + * await room.messages.send(withParams: .init(text: "This will fail")) + * } catch { + * print("Room has been released") + * } + * + * // To use the room again, get a new instance + * let newRoom = try await chatClient.rooms.get(named: "temporary-chat") + * + * // Handle release of non-existent rooms (no-op) + * await chatClient.rooms.release("non-existent-room") // Safe, does nothing + * ``` */ func release(named name: String) async } -// swiftlint:disable:next missing_docs +/// Extension providing convenience methods for getting rooms. public extension Rooms { - // swiftlint:disable:next missing_docs + /// Convenience method to get a room with default options. func get(named name: String) async throws(ErrorInfo) -> Room { // CHA-RC4a try await get(named: name, options: .init()) diff --git a/Sources/AblyChat/Subscription.swift b/Sources/AblyChat/Subscription.swift index b348153e..0a9dd71d 100644 --- a/Sources/AblyChat/Subscription.swift +++ b/Sources/AblyChat/Subscription.swift @@ -8,8 +8,10 @@ import Ably @MainActor public protocol Subscription: Sendable { /** - * This method should be called when the subscription is no longer needed, - * it will make sure no further events will be sent to the subscriber and + * Unsubscribes from the subscription. + * + * This method should be called when the subscription is no longer needed. + * It ensures that no further events will be sent to the subscriber and * that references to the subscriber are cleaned up. */ func unsubscribe() @@ -34,25 +36,111 @@ public protocol StatusSubscription: Sendable { */ @MainActor public protocol MessageSubscriptionResponse: Subscription, Sendable { - // swiftlint:disable:next missing_docs + /// The paginated result type for historical messages. associatedtype HistoryResult: PaginatedResult /** - * Get the previous messages that were sent to the room before the listener was subscribed. - * - * If the client experiences a discontinuity event (i.e. the connection was lost and could not be resumed), the starting point of - * historyBeforeSubscribe will be reset. - * - * Calls to historyBeforeSubscribe will wait for continuity to be restored before resolving. + * Get the previous messages that were sent to the room before the listener was subscribed. This can be used to populate + * a room on initial subscription or to refresh local state after a discontinuity event. * - * Once continuity is restored, the subscription point will be set to the beginning of this new period of continuity. To - * ensure that no messages are missed, you should call historyBeforeSubscribe after any period of discontinuity to - * fill any gaps in the message history. + * - Note: + * - If the client experiences a discontinuity event (i.e. the connection was lost and could not be resumed), the starting point of + * `historyBeforeSubscribe` will be reset. + * - Calls to `historyBeforeSubscribe` will then wait for continuity to be restored before resolving. + * - Once continuity is restored, the subscription point will be set to the beginning of this new period of continuity. To + * ensure that no messages are missed (or updates/deletes), you should call `historyBeforeSubscribe` after any period of discontinuity to + * re-populate your local state. * * - Parameters: * - params: Parameters for the history query. * * - Returns: A paginated result of messages, in newest-to-oldest order. + * + * - Throws: ``ErrorInfo`` + * + * ## Example - Populating messages on initial subscription + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * let room = try await chatClient.rooms.get("general-chat") + * + * // Local message state + * var localMessages: [Message] = [] + * + * func updateLocalMessageState(messages: inout [Message], message: Message) { + * // Find existing message in local state + * if let existingIndex = messages.firstIndex(where: { $0.serial == message.serial }) { + * // Existing message, update local state + * messages[existingIndex] = messages[existingIndex].with(message) + * } else { + * // New message, add to local state + * messages.append(message) + * } + * // Messages should be ordered by serial + * messages.sort { $0.serial < $1.serial } + * } + * + * // Subscribe a listener to message events + * let subscription = room.messages.subscribe { event in + * print("Message \(event.type): \(event.message.text)") + * updateLocalMessageState(messages: &localMessages, message: event.message) + * } + * + * // Attach to the room to start receiving message events + * try await room.attach() + * + * // Get historical messages before subscription + * do { + * let history = try await subscription.historyBeforeSubscribe(withParams: .init(limit: 50)) + * print("Retrieved \(history.items.count) historical messages") + * + * // Process historical messages + * for message in history.items { + * print("Historical: \(message.text) from \(message.clientID)") + * updateLocalMessageState(messages: &localMessages, message: message) + * } + * } catch { + * print("Failed to retrieve message history: \(error)") + * } + * ``` + * + * ## Example - Handling discontinuities to refresh local state + * + * ```swift + * // Subscribe a listener to message events as before + * let subscription = room.messages.subscribe { event in + * print("Message \(event.type): \(event.message.text)") + * updateLocalMessageState(messages: &localMessages, message: event.message) + * } + * + * // Subscribe to discontinuity events on the room + * room.onDiscontinuity { reason in + * print("Discontinuity detected: \(reason)") + * // Clear local state and re-fetch messages + * localMessages = [] + * Task { + * do { + * // Fetch messages before the new subscription point + * let history = try await subscription.historyBeforeSubscribe(withParams: .init(limit: 100)) + * + * // Merge each message into local state + * for message in history.items { + * updateLocalMessageState(messages: &localMessages, message: message) + * } + * + * print("Refreshed local state with \(localMessages.count) messages") + * } catch { + * print("Failed to refresh messages after discontinuity: \(error)") + * } + * } + * } + * + * // Attach to the room to start receiving events + * try await room.attach() + * ``` */ func historyBeforeSubscribe(withParams params: HistoryBeforeSubscribeParams) async throws(ErrorInfo) -> HistoryResult } diff --git a/Sources/AblyChat/SubscriptionAsyncSequence.swift b/Sources/AblyChat/SubscriptionAsyncSequence.swift index 5a66f9fb..2037bcd7 100644 --- a/Sources/AblyChat/SubscriptionAsyncSequence.swift +++ b/Sources/AblyChat/SubscriptionAsyncSequence.swift @@ -59,8 +59,17 @@ public final class SubscriptionAsyncSequence: Sendable, Async mode = .default(stream: stream, continuation: continuation) } - // 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. It is a programmer error to pass a throwing AsyncSequence. - // swiftlint:disable:next missing_docs + /** + * Creates a mock subscription for testing purposes. + * + * This initializer allows you to create a subscription that replays events from a provided async sequence. + * Use this when creating mock implementations of the Chat SDK for testing. + * + * - Note: You should not need to use this initializer when using the Chat SDK. It is exposed only to allow users to create mock versions of the SDK's protocols. + * + * - Parameters: + * - mockAsyncSequence: An async sequence whose elements will be replayed by this subscription + */ public init(mockAsyncSequence: Underlying) where Underlying.Element == Element { mode = .mockAsyncSequence(.init(asyncSequence: mockAsyncSequence)) } @@ -112,7 +121,7 @@ public final class SubscriptionAsyncSequence: Sendable, Async } } - // swiftlint:disable:next missing_docs + /// The iterator type for ``SubscriptionAsyncSequence``. public struct AsyncIterator: AsyncIteratorProtocol { fileprivate enum Mode { case `default`(iterator: AsyncStream.AsyncIterator) @@ -138,13 +147,13 @@ public final class SubscriptionAsyncSequence: Sendable, Async self.mode = mode } - // swiftlint:disable:next missing_docs + /// Asynchronously advances to the next element and returns it, or nil if no next element exists. public mutating func next() async -> Element? { await mode.next() } } - // swiftlint:disable:next missing_docs + /// Creates an async iterator for this subscription sequence. public func makeAsyncIterator() -> AsyncIterator { let iteratorMode: AsyncIterator.Mode = switch mode { case let .default(stream: stream, continuation: _): diff --git a/Sources/AblyChat/Typing.swift b/Sources/AblyChat/Typing.swift index 44996cae..17a5aa4b 100644 --- a/Sources/AblyChat/Typing.swift +++ b/Sources/AblyChat/Typing.swift @@ -8,45 +8,162 @@ import Ably */ @MainActor public protocol Typing: AnyObject, Sendable { - // swiftlint:disable:next missing_docs + /// The subscription type for typing event listeners. associatedtype Subscription: AblyChat.Subscription /** - * Subscribes a given listener to all typing events from users in the chat room. + * Subscribes to typing events from users in the chat room. + * + * Receives updates whenever a user starts or stops typing, providing real-time + * feedback about who is currently composing messages. The subscription emits + * events containing the current set of typing users and details about what changed. + * + * - Note: The room must be attached to receive typing events. * * - Parameters: - * - callback: The listener closure for capturing room ``TypingEvent`` events. + * - callback: Callback invoked when the typing state changes + * + * - Returns: Subscription object with an unsubscribe method + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room with default options + * let room = try await chatClient.rooms.get(named: "team-chat") + * + * // Subscribe to typing events + * let subscription = room.typing.subscribe { event in + * let currentlyTyping = event.currentlyTyping + * + * // Display who is currently typing + * if currentlyTyping.isEmpty { + * hideTypingIndicator() + * } else if currentlyTyping.count == 1 { + * showTypingIndicator("\(currentlyTyping[0]) is typing...") + * } else if currentlyTyping.count == 2 { + * showTypingIndicator("\(currentlyTyping[0]) and \(currentlyTyping[1]) are typing...") + * } else { + * showTypingIndicator("\(currentlyTyping.count) people are typing...") + * } + * } * - * - Returns: A subscription that can be used to unsubscribe from ``TypingEvent`` events. + * // Attach to the room to start receiving events + * try await room.attach() + * + * // Later, unsubscribe when done + * subscription.unsubscribe() + * ``` */ @discardableResult func subscribe(_ callback: @escaping @MainActor (TypingSetEvent) -> Void) -> Subscription /** - * Get the current typers, a set of clientIds. + * Gets the current set of users who are typing. + * + * Returns a Set containing the client IDs of all users currently typing in the room. + * This provides a snapshot of the typing state at the time of the call. + * + * - Returns: Set of client IDs currently typing + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room with default options + * let room = try await chatClient.rooms.get(named: "support-chat") + * + * // Attach to the room to start receiving events + * try await room.attach() + * + * // Fetch the current cached set of typing users + * let typingUsers = room.typing.current + * + * print("\(typingUsers.count) users are typing") * - * - Returns: A set of clientIds that are currently typing. + * if typingUsers.contains("agent-001") { + * print("Support agent is typing a response...") + * } + * ``` */ var current: Set { get } /** - * Keystroke indicates that the current user is typing. This will emit a ``TypingEvent`` event to inform listening clients and begin a timer, - * once the timer expires, another ``TypingEvent`` event will be emitted. In both cases ``TypingEvent/currentlyTyping`` - * contains a list of userIds who are currently typing. + * Sends a typing started event to notify other users that the current user is typing. * - * The heartbeat throttle interval is configurable through the ``TypingOptions/heartbeatThrottle`` parameter. - * It will show the current user as typing for the duration of the throttle, plus an internally defined timeout. - * Any keystrokes within the throttle period will be ignored, with no new events being sent. + * Events are throttled according to the `heartbeatThrottleMs` room option to prevent + * excessive network traffic. If called within the throttle interval, the operation + * becomes a no-op. Multiple rapid calls are serialized to maintain consistency. * - * - Throws: An `ErrorInfo`. + * - Note: + * - The connection must be in the `connected` state. + * - Calls to `keystroke()` and `stop()` are serialized and resolve in order. + * - The most recent operation always determines the final typing state. + * - The room must be attached to send typing events. + * + * - Throws: ``ErrorInfo`` if the operation fails to send the event + * + * ## Example + * + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room with default options and attach to it + * let room = try await chatClient.rooms.get(named: "project-discussion", options: RoomOptions()) + * try await room.attach() + * + * do { + * try await room.typing.keystroke() + * } catch { + * print("Typing indicator error: \(error)") + * } + * ``` */ func keystroke() async throws(ErrorInfo) /** - * Stop indicates that the current user has stopped typing. This will emit a ``TypingEvent`` event to inform listening clients, - * and immediately clear the typing timeout timer. + * Sends a typing stopped event to notify other users that the current user has stopped typing. + * + * If the user is not currently typing, this operation is a no-op. Multiple rapid calls + * are serialized to maintain consistency, with the most recent operation determining + * the final state. + * + * - Note: + * - The connection must be in the `connected` state. + * - Calls to `keystroke()` and `stop()` are serialized and resolve in order. + * - The room must be attached to send typing events. + * + * - Throws: ``ErrorInfo`` if the operation fails to send the event + * + * ## Example * - * - Throws: An `ErrorInfo`. + * ```swift + * import Ably + * import AblyChat + * + * let chatClient: ChatClient // existing ChatClient instance + * + * // Get a room with default options and attach to it + * let room = try await chatClient.rooms.get(named: "customer-support") + * try await room.attach() + * + * // Start typing in the room + * try await room.typing.keystroke() + * + * // User sends a message, or deletes their draft, etc. + * try await room.typing.stop() + * ``` */ func stop() async throws(ErrorInfo) } @@ -84,20 +201,22 @@ public extension Typing { } /** - * Represents a typing event. + * Represents a change in the state of current typers. */ public struct TypingSetEvent: Sendable { - // swiftlint:disable:next missing_docs + /** + * The type of the event. + */ public var type: TypingSetEventType /** - * Get a set of clientIds that are currently typing. + * The set of clientIds that are currently typing. */ public var currentlyTyping: Set /** - * Get the details of the operation that modified the typing event. - */ + * Represents the change that resulted in the new set of typers. + */ public var change: Change /// Memberwise initializer to create a `TypingSetEvent`. @@ -109,11 +228,18 @@ public struct TypingSetEvent: Sendable { self.change = change } - // swiftlint:disable:next missing_docs + /** + * Represents the change that resulted in the new set of typers. + */ public struct Change: Sendable { - // swiftlint:disable:next missing_docs + /** + * The client ID of the user who stopped/started typing. + */ public var clientID: String - // swiftlint:disable:next missing_docs + + /** + * Type of the change. + */ public var type: TypingEventType /// Memberwise initializer to create a `Change`.