From 2f116a259da3c4c8bf6926f951fb5a5afbcf52ad Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Tue, 16 Jun 2026 12:01:27 +0300 Subject: [PATCH 1/7] Respect channel capabilities before sending typing events Default to not sending typing events when the channel is not yet loaded, so events are no longer sent to channels (e.g. livestream) whose own_capabilities lack send-typing-events. --- .../ChannelController/ChannelController.swift | 2 +- Sources/StreamChat/StateLayer/Chat.swift | 2 + .../ChannelController_Tests.swift | 49 +++++++++++++++++++ .../StateLayer/Chat_Tests.swift | 49 ++++++++++++++++++- 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index d64392ef0bc..38c6a3a2392 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 } 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..05e8084cf71 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) { 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, From 1801716cd469e51aeddf1ec6c99ff2c721a192d3 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Tue, 16 Jun 2026 14:00:59 +0300 Subject: [PATCH 2/7] Update default typing events test to expect no send when channel not loaded --- .../Controllers/ChannelController/ChannelController_Tests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index 05e8084cf71..159e5624f19 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -5478,7 +5478,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 { From 9be6cf4e8b9141b8cdda68ea06ad6a8b7c010d3e Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 25 Jun 2026 16:48:37 +0100 Subject: [PATCH 3/7] Fix typing.stop sent unconditionally on every message send Guard the stopTyping call in ChannelController.createNewMessage behind shouldSendTypingEvents, and add an early-return in TypingEventsSender when no typing session is active (currentUserLastTypingDate is nil). This prevents pointless typing.stop API requests on channels where typing events are disabled (e.g. livestream), which the server rejects with 400. --- .../ChannelController/ChannelController.swift | 7 +++++-- .../Workers/TypingEventsSender.swift | 1 - .../ChannelController_Tests.swift | 18 ++++++++++++++++++ .../Workers/TypingEventSender_Tests.swift | 19 ++++++++++++++----- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index 38c6a3a2392..ea850137f47 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -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/Workers/TypingEventsSender.swift b/Sources/StreamChat/Workers/TypingEventsSender.swift index 468878c99cf..27144455182 100644 --- a/Sources/StreamChat/Workers/TypingEventsSender.swift +++ b/Sources/StreamChat/Workers/TypingEventsSender.swift @@ -77,7 +77,6 @@ class TypingEventsSender: Worker { cancelScheduledTypingTimerControl() currentUserLastTypingDate = nil } - typingInfo = nil apiClient.request( diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index 159e5624f19..876e9ff50f0 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -3637,6 +3637,24 @@ final class ChannelController_Tests: XCTestCase { XCTAssertEqual(env.channelUpdater?.createNewMessage_extraData, transformer.mockTransformedMessage.extraData) } + func test_createNewMessage_doesNotSendStopTyping_whenTypingEventsDisabled() { + 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() { diff --git a/Tests/StreamChatTests/Workers/TypingEventSender_Tests.swift b/Tests/StreamChatTests/Workers/TypingEventSender_Tests.swift index b4393333bcc..81d03e1f7b6 100644 --- a/Tests/StreamChatTests/Workers/TypingEventSender_Tests.swift +++ b/Tests/StreamChatTests/Workers/TypingEventSender_Tests.swift @@ -111,16 +111,25 @@ final class TypingEventsSender_Tests: XCTestCase { XCTAssertEqual(calls, [AnyEndpoint(startTypingEndpoint), AnyEndpoint(startTypingEndpoint)]) } - func test_stopTyping_withoutParentMessageId_makesCorrectAPICall() { + func test_stopTyping_withoutPriorTyping_doesNotMakeAPICall() { + let cid = ChannelId.unique + + eventSender.stopTyping(in: cid, parentMessageId: nil) + + XCTAssertNil(apiClient.request_endpoint) + } + + func test_stopTyping_afterStartTyping_withoutParentMessageId_makesCorrectAPICall() { let cid = ChannelId.unique let parentMessageId: MessageId? = nil - // Call stopTyping + eventSender.startTyping(in: cid, parentMessageId: parentMessageId) + apiClient.request_endpoint = nil + eventSender.stopTyping(in: cid, parentMessageId: parentMessageId) - // Check the start typing event has been sent. - let startTypingEndpoint: Endpoint = .stopTypingEvent(cid: cid, parentMessageId: parentMessageId) - XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(startTypingEndpoint)) + let stopTypingEndpoint: Endpoint = .stopTypingEvent(cid: cid, parentMessageId: parentMessageId) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(stopTypingEndpoint)) } func test_stopTyping_afterKeystroke() { From aa16f170e9eebdc2d397b71f9e2e142ba7085d6e Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 25 Jun 2026 17:01:15 +0100 Subject: [PATCH 4/7] Tighten test to load channel with typing events disabled The test was passing trivially because no channel was loaded, not because the capability guard worked. Now it explicitly sets up a channel with empty ownCapabilities before asserting. --- .../ChannelController/ChannelController_Tests.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index 876e9ff50f0..9a60ac2a51d 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -3638,6 +3638,11 @@ final class ChannelController_Tests: XCTestCase { } 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) From 0183ecc132deadeb0c9f657e1c83b90510a4a0bc Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 26 Jun 2026 14:51:28 +0100 Subject: [PATCH 5/7] Restore original TypingEventsSender tests for v4 The cherry-pick included test changes for a TypingEventsSender early-return that was not backported to v4. --- .../Workers/TypingEventsSender.swift | 1 + .../Workers/TypingEventSender_Tests.swift | 19 +++++-------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/Sources/StreamChat/Workers/TypingEventsSender.swift b/Sources/StreamChat/Workers/TypingEventsSender.swift index 27144455182..468878c99cf 100644 --- a/Sources/StreamChat/Workers/TypingEventsSender.swift +++ b/Sources/StreamChat/Workers/TypingEventsSender.swift @@ -77,6 +77,7 @@ class TypingEventsSender: Worker { cancelScheduledTypingTimerControl() currentUserLastTypingDate = nil } + typingInfo = nil apiClient.request( diff --git a/Tests/StreamChatTests/Workers/TypingEventSender_Tests.swift b/Tests/StreamChatTests/Workers/TypingEventSender_Tests.swift index 81d03e1f7b6..b4393333bcc 100644 --- a/Tests/StreamChatTests/Workers/TypingEventSender_Tests.swift +++ b/Tests/StreamChatTests/Workers/TypingEventSender_Tests.swift @@ -111,25 +111,16 @@ final class TypingEventsSender_Tests: XCTestCase { XCTAssertEqual(calls, [AnyEndpoint(startTypingEndpoint), AnyEndpoint(startTypingEndpoint)]) } - func test_stopTyping_withoutPriorTyping_doesNotMakeAPICall() { - let cid = ChannelId.unique - - eventSender.stopTyping(in: cid, parentMessageId: nil) - - XCTAssertNil(apiClient.request_endpoint) - } - - func test_stopTyping_afterStartTyping_withoutParentMessageId_makesCorrectAPICall() { + func test_stopTyping_withoutParentMessageId_makesCorrectAPICall() { let cid = ChannelId.unique let parentMessageId: MessageId? = nil - eventSender.startTyping(in: cid, parentMessageId: parentMessageId) - apiClient.request_endpoint = nil - + // Call stopTyping eventSender.stopTyping(in: cid, parentMessageId: parentMessageId) - let stopTypingEndpoint: Endpoint = .stopTypingEvent(cid: cid, parentMessageId: parentMessageId) - XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(stopTypingEndpoint)) + // Check the start typing event has been sent. + let startTypingEndpoint: Endpoint = .stopTypingEvent(cid: cid, parentMessageId: parentMessageId) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(startTypingEndpoint)) } func test_stopTyping_afterKeystroke() { From 866f6b0704b21688ec99a6bc4d20c1bf28074dc4 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 26 Jun 2026 14:55:23 +0100 Subject: [PATCH 6/7] Update CHANGELOG for #4147 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c71d27ee045..d851055b5f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming +## 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) + ### 🔄 Changed # [4.101.1](https://github.com/GetStream/stream-chat-swift/releases/tag/4.101.1) From d4e5eb9390e027651eb6d8a8110d30a9f68ac101 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 26 Jun 2026 14:56:25 +0100 Subject: [PATCH 7/7] Apply suggestion from @nuno-vieira --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d851055b5f9..5678b1acad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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) -### 🔄 Changed # [4.101.1](https://github.com/GetStream/stream-chat-swift/releases/tag/4.101.1) _June 11, 2026_