diff --git a/CHANGELOG.md b/CHANGELOG.md index c71d27ee045..5678b1acad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming -### 🔄 Changed +## StreamChat +### 🐞 Fixed +- Fix sending typing events to channels without typing events capability [#4147](https://github.com/GetStream/stream-chat-swift/pull/4147) +- Fix `typing.stop` being sent unconditionally on every message send, even on channels with typing events disabled [#4147](https://github.com/GetStream/stream-chat-swift/pull/4147) + # [4.101.1](https://github.com/GetStream/stream-chat-swift/releases/tag/4.101.1) _June 11, 2026_ diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index d64392ef0bc..ea850137f47 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -150,7 +150,7 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP /// A boolean value indicating if it should send typing events. /// It is `true` if the channel typing events are enabled as well as the user privacy settings. func shouldSendTypingEvents(completion: @escaping (Bool) -> Void) { - guard channel?.canSendTypingEvents ?? true else { + guard channel?.canSendTypingEvents ?? false else { completion(false) return } @@ -1835,8 +1835,11 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP return } - /// Send stop typing event. - eventSender.stopTyping(in: cid, parentMessageId: nil) + /// Send stop typing event only if the channel supports typing events. + shouldSendTypingEvents { isEnabled in + guard isEnabled else { return } + self.eventSender.stopTyping(in: cid, parentMessageId: nil) + } updater.createNewMessage( in: cid, diff --git a/Sources/StreamChat/StateLayer/Chat.swift b/Sources/StreamChat/StateLayer/Chat.swift index 92c949ce685..0a647898401 100644 --- a/Sources/StreamChat/StateLayer/Chat.swift +++ b/Sources/StreamChat/StateLayer/Chat.swift @@ -1349,6 +1349,7 @@ public class Chat { /// /// - Throws: An error while communicating with the Stream API. public func keystroke(parentMessageId: MessageId? = nil) async throws { + guard await state.channel?.canSendTypingEvents ?? false else { return } try await typingEventsSender.keystroke(in: cid, parentMessageId: parentMessageId) } @@ -1360,6 +1361,7 @@ public class Chat { /// /// - Throws: An error while communicating with the Stream API. public func stopTyping(parentMessageId: MessageId? = nil) async throws { + guard await state.channel?.canSendTypingEvents ?? false else { return } try await typingEventsSender.stopTyping(in: cid, parentMessageId: parentMessageId) } diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index 31d94a760d5..9a60ac2a51d 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -3370,6 +3370,55 @@ final class ChannelController_Tests: XCTestCase { XCTAssertEqual(channelFeatureError.localizedDescription, "Channel feature: typing events is disabled for this channel.") } + func test_sendKeystrokeEvent_whenChannelIsNotLoaded_doesNotSend() throws { + // Do not save a channel, so the controller's channel is `nil` and capabilities are unknown. + nonisolated(unsafe) var completionCalled = false + + let error: Error? = try waitFor { completion in + controller.sendKeystrokeEvent { + completionCalled = true + completion($0) + } + } + + XCTAssertTrue(completionCalled) + XCTAssertNil(error) + // The event sender is built lazily only when an event is actually sent, so a `nil` sender confirms no send happened. + XCTAssertNil(env.eventSender?.keystroke_cid) + } + + func test_sendStartTypingEvent_whenChannelIsNotLoaded_errors() throws { + // Do not save a channel, so the controller's channel is `nil` and capabilities are unknown. + nonisolated(unsafe) var completionCalled = false + + let error: Error? = try waitFor { completion in + controller.sendStartTypingEvent { + completionCalled = true + completion($0) + } + } + + XCTAssertTrue(completionCalled) + XCTAssertTrue(error is ClientError.ChannelFeatureDisabled) + XCTAssertNil(env.eventSender?.startTyping_cid) + } + + func test_sendStopTypingEvent_whenChannelIsNotLoaded_errors() throws { + // Do not save a channel, so the controller's channel is `nil` and capabilities are unknown. + nonisolated(unsafe) var completionCalled = false + + let error: Error? = try waitFor { completion in + controller.sendStopTypingEvent { + completionCalled = true + completion($0) + } + } + + XCTAssertTrue(completionCalled) + XCTAssertTrue(error is ClientError.ChannelFeatureDisabled) + XCTAssertNil(env.eventSender?.stopTyping_cid) + } + func test_keystroke_keepsControllerAlive() throws { // Save channel with typing events enabled to database writeAndWaitForMessageUpdates(count: 1, channelChanges: true) { @@ -3588,6 +3637,29 @@ final class ChannelController_Tests: XCTestCase { XCTAssertEqual(env.channelUpdater?.createNewMessage_extraData, transformer.mockTransformedMessage.extraData) } + func test_createNewMessage_doesNotSendStopTyping_whenTypingEventsDisabled() { + let payload = dummyPayload(with: channelId, ownCapabilities: []) + writeAndWaitForMessageUpdates(count: payload.messages.count, channelChanges: true) { session in + try session.saveChannel(payload: payload) + } + + controller.createNewMessage(text: .unique) + + XCTAssertNil(env.eventSender?.stopTyping_cid) + } + + func test_createNewMessage_sendsStopTyping_whenTypingEventsEnabled() throws { + let payload = dummyPayload(with: channelId, ownCapabilities: [ChannelCapability.sendTypingEvents.rawValue]) + writeAndWaitForMessageUpdates(count: payload.messages.count, channelChanges: true) { session in + try session.saveChannel(payload: payload) + } + + controller.createNewMessage(text: .unique) + + wait(for: [env.eventSender!.stopTyping_completion_expectation], timeout: defaultTimeout) + XCTAssertEqual(env.eventSender?.stopTyping_cid, channelId) + } + // MARK: - Create system message func test_createSystemMessage_callsChannelUpdater() { @@ -5429,7 +5501,7 @@ final class ChannelController_Tests: XCTestCase { } let isEnabled = try waitFor { controller.shouldSendTypingEvents(completion: $0) } - XCTAssertEqual(isEnabled, true) + XCTAssertEqual(isEnabled, false) } func test_shouldSendTypingEvents_whenChannelEnabled_whenUserEnabled() throws { diff --git a/Tests/StreamChatTests/StateLayer/Chat_Tests.swift b/Tests/StreamChatTests/StateLayer/Chat_Tests.swift index 8311a122aea..50cdd4c3fe1 100644 --- a/Tests/StreamChatTests/StateLayer/Chat_Tests.swift +++ b/Tests/StreamChatTests/StateLayer/Chat_Tests.swift @@ -245,7 +245,45 @@ final class Chat_Tests: XCTestCase { XCTAssertEqual(channelId, env.channelUpdaterMock.freezeChannel_cid) XCTAssertEqual(false, env.channelUpdaterMock.freezeChannel_freeze) } - + + // MARK: - Typing Indicators + + func test_keystroke_whenChannelCannotSendTypingEvents_doesNotMakeAPIRequest() async throws { + try await setUpChatWithLoadedChannel(ownCapabilities: []) + + try await chat.keystroke() + + XCTAssertNil(env.client.mockAPIClient.request_endpoint) + } + + func test_stopTyping_whenChannelCannotSendTypingEvents_doesNotMakeAPIRequest() async throws { + try await setUpChatWithLoadedChannel(ownCapabilities: []) + + try await chat.stopTyping() + + XCTAssertNil(env.client.mockAPIClient.request_endpoint) + } + + func test_keystroke_whenChannelCanSendTypingEvents_makesAPIRequest() async throws { + try await setUpChatWithLoadedChannel(ownCapabilities: [ChannelCapability.sendTypingEvents.rawValue]) + env.client.mockAPIClient.test_mockResponseResult(Result.success(EmptyResponse())) + + try await chat.keystroke() + + let expectedEndpoint: Endpoint = .startTypingEvent(cid: channelId, parentMessageId: nil) + XCTAssertEqual(env.client.mockAPIClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_stopTyping_whenChannelCanSendTypingEvents_makesAPIRequest() async throws { + try await setUpChatWithLoadedChannel(ownCapabilities: [ChannelCapability.sendTypingEvents.rawValue]) + env.client.mockAPIClient.test_mockResponseResult(Result.success(EmptyResponse())) + + try await chat.stopTyping() + + let expectedEndpoint: Endpoint = .stopTypingEvent(cid: channelId, parentMessageId: nil) + XCTAssertEqual(env.client.mockAPIClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + // MARK: - Invites func test_acceptInvite_whenChannelUpdaterSucceeds_thenAcceptInviteSucceeds() async throws { @@ -1933,6 +1971,15 @@ final class Chat_Tests: XCTestCase { } } + /// Sets up a chat backed by real updaters and loads a channel with the given capabilities into the state. + @MainActor private func setUpChatWithLoadedChannel(ownCapabilities: [String]) async throws { + let payload = ChannelPayload.dummy(channel: .dummy(cid: channelId, ownCapabilities: ownCapabilities)) + env.client.mockAPIClient.test_mockResponseResult(.success(payload)) + try await setUpChat(usesMockedUpdaters: false) + try await chat.get(watch: false) + env.client.mockAPIClient.cleanUp() + } + private func makeChannelPayload( cid: ChannelId? = nil, messageCount: Int,