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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@
/// 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
}
Expand Down Expand Up @@ -1835,8 +1835,11 @@
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,
Expand Down Expand Up @@ -2097,12 +2100,12 @@
itemReuseKeyPaths: (\ChatMessage.id, \MessageDTO.id)
)
observer.onDidChange = { [weak self] changes in
self?.delegateCallback {
guard let self = self else { return }
log.debug("didUpdateMessages: \(changes.map(\.debugDescription))")

$0.channelController(self, didUpdateMessages: changes)
}

Check warning on line 2108 in Sources/StreamChat/Controllers/ChannelController/ChannelController.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 2 closure expressions.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-swift&issues=AZ8ESw9GpyrngDj5QBPg&open=AZ8ESw9GpyrngDj5QBPg&pullRequest=4147
}
return observer
}()
Expand Down
2 changes: 2 additions & 0 deletions Sources/StreamChat/StateLayer/Chat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down
49 changes: 48 additions & 1 deletion Tests/StreamChatTests/StateLayer/Chat_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<EmptyResponse, Error>.success(EmptyResponse()))

try await chat.keystroke()

let expectedEndpoint: Endpoint<EmptyResponse> = .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<EmptyResponse, Error>.success(EmptyResponse()))

try await chat.stopTyping()

let expectedEndpoint: Endpoint<EmptyResponse> = .stopTypingEvent(cid: channelId, parentMessageId: nil)
XCTAssertEqual(env.client.mockAPIClient.request_endpoint, AnyEndpoint(expectedEndpoint))
}

// MARK: - Invites

func test_acceptInvite_whenChannelUpdaterSucceeds_thenAcceptInviteSucceeds() async throws {
Expand Down Expand Up @@ -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,
Expand Down
Loading