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
2 changes: 1 addition & 1 deletion docs/docs_screenshots/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ dependencies:
stream_core_flutter:
git:
url: https://github.com/GetStream/stream-core-flutter.git
ref: 1546087d1a3dac29e824853fb2a9f05dcd2d41e6
ref: bd6e2546fa25e2b605cefef4494e0be056d8a0dc
path: packages/stream_core_flutter

dev_dependencies:
Expand Down
2 changes: 1 addition & 1 deletion melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ command:
stream_core_flutter:
git:
url: https://github.com/GetStream/stream-core-flutter.git
ref: 1546087d1a3dac29e824853fb2a9f05dcd2d41e6
ref: bd6e2546fa25e2b605cefef4494e0be056d8a0dc
path: packages/stream_core_flutter
stream_thumbnail: ^0.2.0
synchronized: ^3.4.0
Expand Down
1 change: 1 addition & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

- Fixed `Event.aiState` reading `AI_STATE_EXTERNAL_SOURCES` as `idle` instead of `checkingSources`.
- Fixed `Channel.disableSlowMode` and `StreamChatClient.disableSlowdown` always failing.
- Fixed users still showing as typing after sending or editing a message, or after switching between a thread and the channel.

## 10.5.0

Expand Down
8 changes: 8 additions & 0 deletions packages/stream_chat/lib/src/client/channel/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,8 @@ class Channel {

/// Send a [message] to this channel.
///
/// Also stops the current user's typing.
///
/// If [skipPush] is true the message will not send a push notification.
///
/// Waits for a [_messageAttachmentsUploadCompleter] to complete
Expand All @@ -760,6 +762,8 @@ class Channel {
}) async {
_checkInitialized();

_keyStrokeHandler.cancel();

// Clean up stale error messages before sending a new message.
state?.cleanUpStaleErrorMessages();

Expand Down Expand Up @@ -859,6 +863,8 @@ class Channel {

/// Updates the [message] in this channel.
///
/// Also stops the current user's typing.
///
/// Waits for a [_messageAttachmentsUploadCompleter] to complete
/// before actually updating the message.
Future<UpdateMessageResponse> updateMessage(
Expand All @@ -868,6 +874,8 @@ class Channel {
}) async {
_checkInitialized();

_keyStrokeHandler.cancel();

// Cancelling previous completer in case it's called again in the process
// Eg. Updating the message while the previous call is in progress.
_messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ class ChannelEventHandler {
final message = event.message;
if (message == null) return;

return _mutations.onMessageUpdated(message);
return _mutations.onMessageUpdated(message, editor: event.user);
}

void _onDraftUpdated(Event event) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,34 @@ class ChannelStateMutations {
void onTypingStop(User user) => _removeTypingEvent(user);

/// Adds the new [message], updating the watcher count when provided.
///
/// Also ends the author's typing in the [message]'s channel or thread.
void onMessageNew(Message message, {int? watcherCount}) {
_state.addNewMessage(message);

final author = message.user;
if (author != null) _endTypingFor(author, message, at: message.createdAt);

if (watcherCount != null) {
_state.updateChannelState(
_state.channelState.copyWith(watcherCount: watcherCount),
);
}
}

// Ends [user]'s typing in [message]'s context that started by [at]. Their
// `EventType.typingStop` may arrive late, or never.
void _endTypingFor(User user, Message message, {required DateTime at}) {
// Matched by id: the stored user may be an older snapshot.
final typing = _state.typingEvents.entries.firstWhereOrNull((it) => it.key.id == user.id);
if (typing == null) return;

final MapEntry(key: typingUser, value: typingEvent) = typing;
final isSameContext = typingEvent.parentId == message.parentId;
final startedBefore = !typingEvent.createdAt.isAfter(at);
if (isSameContext && startedBefore) onTypingStop(typingUser);
}

/// Marks the [message] as deleted.
void onMessageDeleted(Message message, {bool hardDelete = false}) {
// Decrement the locally-tracked unread count for hard-deleted
Expand All @@ -70,8 +88,19 @@ class ChannelStateMutations {
}

/// Applies the updated [message] without inserting it when absent.
void onMessageUpdated(Message message) {
return _state.updateMessage(message, upsert: false);
///
/// A text edit also ends the [editor]'s typing in the [message]'s channel or
/// thread.
void onMessageUpdated(Message message, {User? editor}) {
_state.updateMessage(message, upsert: false);

if (editor == null) return;

// Not every update is an edit: reply counts, link previews, translations.
final editedAt = message.messageTextUpdatedAt;
if (editedAt == null) return;

_endTypingFor(editor, message, at: editedAt);
}

/// Applies the updated [draft].
Expand Down
7 changes: 7 additions & 0 deletions packages/stream_chat/lib/src/client/key_stroke_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,14 @@ class KeyStrokeHandler {
///
/// This is meant to be called every time the user presses a key. The method
/// will manage requests and timer as needed.
///
/// Switching [parentId] stops typing under the previous one.
Future<void> call([String? parentId]) async {
if (_lastTypingEvent != null && parentId != _currentParentId) {
// ignore: no-empty-block
_stopTyping(_currentParentId).catchError((_) {});
}

final completer = _resetKeyStrokeCompleter();

_cancelKeyStrokeTimer();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,15 @@ void main() {
verify(() => mutations.onMessageUpdated(message)).called(1);
});

test('message.updated passes the user who made the edit', () {
final editor = User(id: 'editor');
final event = Event(type: EventType.messageUpdated, message: message, user: editor);

handler.handleEvent(event);

verify(() => mutations.onMessageUpdated(message, editor: editor)).called(1);
});

test('message.updated ignores events without a message', () {
handler.handleEvent(Event(type: EventType.messageUpdated));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ void main() {
state = MockChannelClientState();

when(() => client.logger).thenReturn(MockLogger());
when(() => state.typingEvents).thenReturn({});
when(() => client.state).thenReturn(FakeClientState());

// A real channel (without an attached state) so that the capability and
Expand Down Expand Up @@ -100,6 +101,53 @@ void main() {
verifyNever(() => state.updateChannelState(any()));
});

test("onMessageNew clears its author's typing in the same channel", () {
final sentAt = DateTime.utc(2026, 10, 13, 12);
final typing = Event(
type: EventType.typingStart,
user: otherUser,
createdAt: sentAt.subtract(const Duration(seconds: 2)),
);
when(() => state.typingEvents).thenReturn({otherUser: typing});

mutations.onMessageNew(message.copyWith(createdAt: sentAt));

expect(removeTypingEventCalls, [otherUser]);
});

test("onMessageNew keeps its author's typing in a thread", () {
final threadTyping = Event(type: EventType.typingStart, user: otherUser, parentId: 'parent-id');
when(() => state.typingEvents).thenReturn({otherUser: threadTyping});

mutations.onMessageNew(message);

expect(removeTypingEventCalls, isEmpty);
});

test("onMessageNew keeps its author's typing that started after the message", () {
final sentAt = DateTime.utc(2026, 10, 13, 12);
final sentMessage = message.copyWith(createdAt: sentAt);
final nextTyping = Event(
type: EventType.typingStart,
user: otherUser,
createdAt: sentAt.add(const Duration(milliseconds: 200)),
);
when(() => state.typingEvents).thenReturn({otherUser: nextTyping});

mutations.onMessageNew(sentMessage);

expect(removeTypingEventCalls, isEmpty);
});

test('onMessageNew keeps other users typing', () {
final someoneElse = User(id: 'someone-else');
when(() => state.typingEvents).thenReturn({someoneElse: Event(type: EventType.typingStart, user: someoneElse)});

mutations.onMessageNew(message);

expect(removeTypingEventCalls, isEmpty);
});

test('onMessageNew also applies the watcher count when provided', () {
stubChannelState(ChannelState(channel: ChannelModel(cid: 'messaging:test')));

Expand Down Expand Up @@ -144,6 +192,48 @@ void main() {
verify(() => state.deleteMessage(message, hardDelete: true)).called(1);
});

test("onMessageUpdated clears the editor's typing in the same channel", () {
final editedAt = DateTime.utc(2026, 10, 13, 12);
final typing = Event(
type: EventType.typingStart,
user: otherUser,
createdAt: editedAt.subtract(const Duration(seconds: 2)),
);
when(() => state.typingEvents).thenReturn({otherUser: typing});

mutations.onMessageUpdated(message.copyWith(messageTextUpdatedAt: editedAt), editor: otherUser);

expect(removeTypingEventCalls, [otherUser]);
});

test("onMessageUpdated keeps the editor's typing when the text was not edited", () {
final updatedAt = DateTime.utc(2026, 10, 13, 12);
final typing = Event(
type: EventType.typingStart,
user: otherUser,
createdAt: updatedAt.subtract(const Duration(seconds: 2)),
);
when(() => state.typingEvents).thenReturn({otherUser: typing});

mutations.onMessageUpdated(message.copyWith(updatedAt: updatedAt), editor: otherUser);

expect(removeTypingEventCalls, isEmpty);
});

test("onMessageUpdated keeps the editor's typing that started after the edit", () {
final editedAt = DateTime.utc(2026, 10, 13, 12);
final nextTyping = Event(
type: EventType.typingStart,
user: otherUser,
createdAt: editedAt.add(const Duration(milliseconds: 200)),
);
when(() => state.typingEvents).thenReturn({otherUser: nextTyping});

mutations.onMessageUpdated(message.copyWith(messageTextUpdatedAt: editedAt), editor: otherUser);

expect(removeTypingEventCalls, isEmpty);
});

test('onMessageUpdated applies the message without upserting', () {
mutations.onMessageUpdated(message);

Expand Down
97 changes: 97 additions & 0 deletions packages/stream_chat/test/src/client/channel/channel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5834,6 +5834,103 @@ void main() {
},
);

test('.sendMessage stops typing when the user is typing', () async {
// The group shares one client mock.
addTearDown(() => clearInteractions(client));

final channelState = _generateChannelState(
channelId,
channelType,
ownCapabilities: [ChannelCapability.typingEvents, ChannelCapability.sendMessage],
);

final channel = Channel.fromState(client, channelState);
addTearDown(channel.dispose);

final startTypingEvent = Event(type: EventType.typingStart);
final stopTypingEvent = Event(type: EventType.typingStop);
final message = Message(id: 'test-message-id', text: 'Hello world!');

when(
() => client.sendEvent(channelId, channelType, any(that: isSameEventAs(startTypingEvent))),
).thenAnswer((_) async => EmptyResponse());
when(
() => client.sendEvent(channelId, channelType, any(that: isSameEventAs(stopTypingEvent))),
).thenAnswer((_) async => EmptyResponse());
when(
() => client.sendMessage(any(that: isSameMessageAs(message)), channelId, channelType),
).thenAnswer((_) async => SendMessageResponse()..message = message);

channel.keyStroke().ignore();
await channel.sendMessage(message);

verify(
() => client.sendEvent(channelId, channelType, any(that: isSameEventAs(stopTypingEvent))),
).called(1);
});

test('.updateMessage stops typing when the user is typing', () async {
// The group shares one client mock.
addTearDown(() => clearInteractions(client));

final channelState = _generateChannelState(
channelId,
channelType,
ownCapabilities: [ChannelCapability.typingEvents, ChannelCapability.updateOwnMessage],
);

final channel = Channel.fromState(client, channelState);
addTearDown(channel.dispose);

final startTypingEvent = Event(type: EventType.typingStart);
final stopTypingEvent = Event(type: EventType.typingStop);
final message = Message(id: 'test-message-id', text: 'Edited');

when(
() => client.sendEvent(channelId, channelType, any(that: isSameEventAs(startTypingEvent))),
).thenAnswer((_) async => EmptyResponse());
when(
() => client.sendEvent(channelId, channelType, any(that: isSameEventAs(stopTypingEvent))),
).thenAnswer((_) async => EmptyResponse());
when(
() => client.updateMessage(any(that: isSameMessageAs(message))),
).thenAnswer((_) async => UpdateMessageResponse()..message = message);

channel.keyStroke().ignore();
await channel.updateMessage(message);

verify(
() => client.sendEvent(channelId, channelType, any(that: isSameEventAs(stopTypingEvent))),
).called(1);
});

test('.sendMessage does not send a typing stop when the user is not typing', () async {
// The group shares one client mock.
addTearDown(() => clearInteractions(client));

final channelState = _generateChannelState(
channelId,
channelType,
ownCapabilities: [ChannelCapability.typingEvents, ChannelCapability.sendMessage],
);

final channel = Channel.fromState(client, channelState);
addTearDown(channel.dispose);

final stopTypingEvent = Event(type: EventType.typingStop);
final message = Message(id: 'test-message-id', text: 'Hello world!');

when(
() => client.sendMessage(any(that: isSameMessageAs(message)), channelId, channelType),
).thenAnswer((_) async => SendMessageResponse()..message = message);

await channel.sendMessage(message);

verifyNever(
() => client.sendEvent(channelId, channelType, any(that: isSameEventAs(stopTypingEvent))),
);
});

test(
".startTyping should return if we don't have the capability",
() async {
Expand Down
Loading
Loading