From 0c59e6d9d31c747591c2272a9c11eac5153c2038 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 17 Jul 2026 19:08:23 +0200 Subject: [PATCH] fix(ui): avoid spurious StreamTypingIndicator rebuilds on unchanged typing users Co-Authored-By: Claude Opus 4.8 --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../lib/src/indicators/typing_indicator.dart | 13 ++ .../src/indicators/typing_indicator_test.dart | 220 +++++++++++++----- 3 files changed, 178 insertions(+), 56 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index dd65f0b533..517ed93444 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed shadowed messages not hidden in channel list items. - Fixed `StreamMessageListView` firing `markThreadRead` on a reply-less parent, which produced a guaranteed 404 every time the thread view was opened before the first reply. - Fixed dismissing the `StreamMessageListView` unread indicator being ignored while a channel is receiving a rapid burst of messages. +- Fixed `StreamTypingIndicator` rebuilding on every typing event by comparing the typing users by id, so it only rebuilds when the set of typing users changes. ## 10.1.0 diff --git a/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart index 10a84b2c7f..1e890f4c8b 100644 --- a/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:lottie/lottie.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; @@ -32,6 +33,17 @@ class StreamTypingIndicator extends StatelessWidget { /// Id of the parent message in case of a thread final String? parentId; + // Compares the typing users by id so the indicator only rebuilds when the + // set of typing users changes. The stream maps to a new lazy Iterable on + // every typing event (and on the stale-typing cleanup), so identity equality + // would rebuild even when the same users are still typing. Order-sensitive + // because only the first user is shown by name. + bool _typingUsersEquals(Iterable? previous, Iterable? current) { + final previousIds = previous?.map((it) => it.id).toList() ?? const []; + final currentIds = current?.map((it) => it.id).toList() ?? const []; + return const ListEquality().equals(previousIds, currentIds); + } + @override Widget build(BuildContext context) { final channelState = channel?.state ?? StreamChannel.of(context).channel.state!; @@ -43,6 +55,7 @@ class StreamTypingIndicator extends StatelessWidget { stream: channelState.typingEventsStream.map( (typingEvents) => typingEvents.entries.where((element) => element.value.parentId == parentId).map((e) => e.key), ), + comparator: _typingUsersEquals, builder: (context, users) => AnimatedSwitcher( layoutBuilder: (currentChild, previousChildren) => Stack( children: [ diff --git a/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart b/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart index 0a55b81f2c..6328fdcc8a 100644 --- a/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -6,93 +8,199 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../mocks.dart'; void main() { - testWidgets( - 'it should show channel typing', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ + late MockClient client; + late MockClientState clientState; + late MockChannel channel; + late MockChannelState channelState; + + setUp(() { + client = MockClient(); + clientState = MockClientState(); + channel = MockChannel(); + channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer( + (i) => Stream.value({ 'name': 'test', - }); - when(() => channelState.membersStream).thenAnswer( - (i) => Stream.value([ - Member( - userId: 'user-id', - user: User(id: 'user-id'), - ), - ]), - ); - when(() => channelState.members).thenReturn([ + }), + ); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.membersStream).thenAnswer( + (i) => Stream.value([ Member( userId: 'user-id', user: User(id: 'user-id'), ), - ]); - when(() => channelState.messages).thenReturn([ + ]), + ); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.messages).thenReturn([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ), + ]); + when(() => channelState.messagesStream).thenAnswer( + (i) => Stream.value([ Message( text: 'hello', user: User(id: 'other-user'), ), - ]); - when(() => channelState.messagesStream).thenAnswer( - (i) => Stream.value([ - Message( - text: 'hello', - user: User(id: 'other-user'), + ]), + ); + }); + + Widget buildTarget({Key? key}) { + return MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamTypingIndicator( + key: key, + ), ), - ]), - ); + ), + ), + ); + } + testWidgets( + 'it should show channel typing', + (WidgetTester tester) async { when(() => channelState.typingEvents).thenAnswer( (i) => { - User(id: 'other-user', extraData: const {'name': 'demo'}): Event(type: EventType.typingStart), + User(id: 'other-user', name: 'demo'): Event(type: EventType.typingStart), }, ); when(() => channelState.typingEventsStream).thenAnswer( (i) => Stream.value({ - User(id: 'other-user', extraData: const {'name': 'demo'}): Event(type: EventType.typingStart), + User(id: 'other-user', name: 'demo'): Event(type: EventType.typingStart), }), ); const typingKey = Key('typing'); + await tester.pumpWidget(buildTarget(key: typingKey)); + + // wait for the initial state to be rendered. + await tester.pump(Duration.zero); + + expect(find.byKey(typingKey), findsOneWidget); + expect(find.byType(Flexible), findsOneWidget); + }, + ); + + // Advances the stream event and the 300ms AnimatedSwitcher transition + // without settling (the typing dots animation repeats forever). + Future emit( + WidgetTester tester, + StreamController> controller, + Map typingEvents, + ) async { + controller.add(typingEvents); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + } + + testWidgets( + 'it updates as the set of typing users changes', + (WidgetTester tester) async { + final typingController = StreamController>.broadcast(); + addTearDown(typingController.close); + + when(() => channelState.typingEvents).thenReturn(const {}); + when(() => channelState.typingEventsStream).thenAnswer( + (_) => typingController.stream, + ); + + Event event() => Event(type: EventType.typingStart); + await tester.pumpWidget( MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( + home: Scaffold( + body: StreamTypingIndicator(channel: channel), + ), + ), + ); + await tester.pump(); + + // No one is typing -> no typing row is shown. + expect(find.byType(Flexible), findsNothing); + + // A single user is typing -> their name is shown. + await emit(tester, typingController, { + User(id: 'user-a', name: 'Alice'): event(), + }); + expect(find.text('Alice is typing'), findsOneWidget); + + // A second user joins -> the summarized text is shown. + await emit(tester, typingController, { + User(id: 'user-a', name: 'Alice'): event(), + User(id: 'user-b', name: 'Bob'): event(), + }); + expect(find.text('Alice and 1 more are typing'), findsOneWidget); + + // Everyone stops typing -> the typing row is removed again. + await emit(tester, typingController, const {}); + expect(find.byType(Flexible), findsNothing); + }, + ); + + testWidgets( + 'it only shows typing users matching its parentId', + (WidgetTester tester) async { + final typingController = StreamController>.broadcast(); + addTearDown(typingController.close); + + when(() => channelState.typingEvents).thenReturn(const {}); + when(() => channelState.typingEventsStream).thenAnswer( + (_) => typingController.stream, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamTypingIndicator( channel: channel, - child: const Scaffold( - body: StreamTypingIndicator( - key: typingKey, - ), - ), + parentId: 'thread-1', ), ), ), ); + await tester.pump(); - // wait for the initial state to be rendered. - await tester.pump(Duration.zero); + // Typing in the main channel (no parentId) is ignored by a thread + // indicator. + await emit(tester, typingController, { + User(id: 'user-a', name: 'Alice'): Event(type: EventType.typingStart), + }); + expect(find.byType(Flexible), findsNothing); - expect(find.byKey(typingKey), findsOneWidget); - expect(find.byType(Flexible), findsOneWidget); + // Typing within the thread is shown. + await emit(tester, typingController, { + User(id: 'user-b', name: 'Bob'): Event( + type: EventType.typingStart, + parentId: 'thread-1', + ), + }); + expect(find.text('Bob is typing'), findsOneWidget); }, ); }