diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 64f4b52948..0ad953a379 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -56,7 +56,6 @@ import 'channel_management_provider.dart'; import 'channel_sections/channel_sections_provider.dart'; import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; -import 'channel_typing_indicator.dart'; import 'inline_agent_typing.dart'; import 'channel_ids_by_name_provider.dart'; import 'channels_provider.dart'; @@ -817,9 +816,7 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { data: (events) { return _MessageList( notifier: ref.watch(messagesProvider.notifier), - inlineTyping: personalChat - ? typingEntries - : const [], + inlineTyping: typingEntries, stickyDateHeaderState: stickyDateHeaderState, entries: timelineEntries, allMessages: timelineMessages, @@ -869,19 +866,6 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { if (!resolvedChannel.isForum && (!resolvedChannel.isMember || resolvedChannel.isArchived)) ...[ - AnimatedSize( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: personalChat || typingEntries.isEmpty - ? const SizedBox.shrink() - : ChannelTypingIndicator( - channelId: resolvedChannel.id, - entries: typingEntries, - ), - ), if (!resolvedChannel.isDm) _ReadOnlyNotice(channel: resolvedChannel), ], @@ -900,19 +884,6 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - AnimatedSize( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: personalChat || typingEntries.isEmpty - ? const SizedBox.shrink() - : ChannelTypingIndicator( - channelId: resolvedChannel.id, - entries: typingEntries, - ), - ), ?assistant.pendingNotice, ComposeBar( channelId: channel.id, diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 13d525ef2d..3f8ec5442a 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -185,7 +185,11 @@ class _MessageBubble extends HookConsumerWidget { : Alignment.centerLeft, child: Padding( padding: EdgeInsets.only( - right: ownMessage ? Grid.xxs : 0, + right: ownMessage + ? messageAvatarSize + + messageAvatarContentGap + + Grid.xxs + : 0, left: ownMessage ? 0 : messageAvatarSize + @@ -239,10 +243,8 @@ class _MessageBubble extends HookConsumerWidget { : TextDirection.ltr, crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (!ownMessage) - const SizedBox(width: messageAvatarSize), - if (!ownMessage) - const SizedBox(width: messageAvatarContentGap), + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), Expanded( child: Padding( padding: EdgeInsets.only( @@ -425,7 +427,7 @@ class _MessageBubble extends HookConsumerWidget { ), ], ), - if (showAvatar && !ownMessage) + if (showAvatar) Positioned( left: ownMessage ? null : 0, right: ownMessage ? 0 : null, diff --git a/mobile/lib/features/channels/inline_agent_typing.dart b/mobile/lib/features/channels/inline_agent_typing.dart index 3f8bd17475..712537e961 100644 --- a/mobile/lib/features/channels/inline_agent_typing.dart +++ b/mobile/lib/features/channels/inline_agent_typing.dart @@ -7,8 +7,9 @@ import '../../shared/widgets/avatar_image.dart'; import 'channel_identity_names_provider.dart'; import 'channel_typing_provider.dart'; import 'agent_typing_bubble.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; -/// Ephemeral personal-chat message rows, using desktop's three-dot work signal. +/// Ephemeral typing message rows, using desktop's three-dot work signal. class InlineAgentTyping extends ConsumerWidget { const InlineAgentTyping({ super.key, @@ -21,16 +22,21 @@ class InlineAgentTyping extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final keys = entries.map((entry) => entry.pubkey.toLowerCase()).toSet(); + // Bound layout, profile lookups, live regions, and animation controllers + // even when the relay supplies a large burst of concurrent typers. + final visibleKeys = keys.take(3).toList(); + final remaining = keys.length - visibleKeys.length; final profiles = ref.watch(userCacheProvider); - final labels = watchChannelIdentityLabels(ref, channelId, keys); + final bots = ref.watch(agentMentionPubkeysProvider(channelId)); + final labels = watchChannelIdentityLabels(ref, channelId, visibleKeys); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final key in keys) + for (final key in visibleKeys) Semantics( key: ValueKey('inline-agent-typing-$key'), liveRegion: true, - label: '${labels[key]} is working', + label: '${labels[key]} is typing', child: ExcludeSemantics( child: Padding( padding: const EdgeInsets.only(top: Grid.xs, bottom: Grid.xxs), @@ -43,7 +49,9 @@ class InlineAgentTyping extends ConsumerWidget { ref.read(userCacheProvider.notifier).get(key)) ?.avatarUrl, radius: messageAvatarSize / 2, - isAgent: true, + isAgent: + bots.contains(key) || + (profiles[key]?.isAgent ?? false), backgroundColor: context.controlSurface, fallback: Text( profiles[key]?.initial ?? @@ -76,6 +84,27 @@ class InlineAgentTyping extends ConsumerWidget { ), ), ), + if (remaining > 0) + Semantics( + liveRegion: true, + label: + '$remaining ${remaining == 1 ? 'other person is' : 'other people are'} typing', + child: ExcludeSemantics( + child: Padding( + padding: const EdgeInsets.only( + left: messageAvatarSize + messageAvatarContentGap + Grid.xs, + top: Grid.xs, + bottom: Grid.xxs, + ), + child: Text( + 'and $remaining ${remaining == 1 ? 'other' : 'others'}', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + ), ], ); } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 21202aaf83..118e49bfe9 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -5892,7 +5892,7 @@ void main() { for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { for (final multiline in [false, true]) { testWidgets( - 'outgoing group stays avatar-free throughout a local send: $platform, multiline=$multiline', + 'outgoing group keeps one trailing avatar throughout a local send: $platform, multiline=$multiline', (tester) async { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = platform; @@ -5923,7 +5923,7 @@ void main() { of: find.byKey(ValueKey('message-row-$id')), matching: find.byType(AvatarImage), ); - expect(avatar('first'), findsNothing); + expect(avatar('first'), findsOneWidget); final container = ProviderScope.containerOf( tester.element(find.byType(ChannelDetailPage)), ); @@ -5950,12 +5950,12 @@ void main() { ); expect( avatar('second'), - findsNothing, + findsOneWidget, reason: 'second avatar frame $frame', ); } await tester.pumpAndSettle(); - expect(avatar('second'), findsNothing); + expect(avatar('second'), findsOneWidget); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } @@ -11137,34 +11137,15 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Alice is typing…'), findsOneWidget); - - final indicator = tester.widget( - find.byKey(const ValueKey('channel-typing-indicator')), - ); - final decoration = indicator.decoration! as BoxDecoration; expect( - indicator.padding, - const EdgeInsets.symmetric(horizontal: Grid.xxs, vertical: Grid.xxs), - ); - expect( - decoration.color, - AppTheme.light().colorScheme.surfaceContainerHighest, - ); - expect(decoration.border, isA()); - expect( - tester.widget(find.text('Alice is typing…')).style?.color, - AppTheme.light().colorScheme.onSurfaceVariant, - ); - expect( - tester.widget(find.text('Alice is typing…')).style?.fontStyle, - isNot(FontStyle.italic), - ); - expect( - find.byKey(const ValueKey('channel-typing-shimmer')), + find.byKey(const ValueKey('inline-agent-typing-alice')), findsOneWidget, ); - expect(tester.widget(find.byType(SmallAvatar)).size, 24); + expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget); + expect( + find.byKey(const ValueKey('channel-typing-indicator')), + findsNothing, + ); }); testWidgets('shows two typers', (tester) async { @@ -11190,10 +11171,13 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Alice and Bob are typing…'), findsOneWidget); + expect( + find.byKey(const ValueKey('agent-typing-dot-2')), + findsNWidgets(2), + ); }); - testWidgets('shows N others for 3+ typers', (tester) async { + testWidgets('shows inline dots for all three typers', (tester) async { await tester.pumpWidget( _buildTestable( messages: [], @@ -11221,10 +11205,13 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Alice and 2 others are typing…'), findsOneWidget); + expect( + find.byKey(const ValueKey('agent-typing-dot-2')), + findsNWidgets(3), + ); }); - testWidgets('keeps typing text static when motion is reduced', ( + testWidgets('keeps inline dots static when motion is reduced', ( tester, ) async { await tester.pumpWidget( @@ -11245,7 +11232,7 @@ void main() { await tester.pump(); await tester.pump(); - expect(find.text('Alice is typing…'), findsOneWidget); + expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget); expect( find.byKey(const ValueKey('channel-typing-shimmer')), findsNothing, diff --git a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart index 6741a0ce95..de4bc69616 100644 --- a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart @@ -145,15 +145,13 @@ void bubbleLayoutTests() { find.byType(MessageMediaAvatar), ); expect(incomingAvatar.right, lessThan(incoming.left)); - expect( - find.descendant( - of: find.byKey(const ValueKey('message-row-outgoing')), - matching: find.byType(MessageMediaAvatar), - ), - findsNothing, + final outgoingAvatar = bounds( + 'outgoing', + find.byType(MessageMediaAvatar), ); + expect(outgoingAvatar.left, greaterThan(outgoing.right)); expect( - outgoing.right, + outgoingAvatar.right, closeTo( tester .getRect(find.byKey(const ValueKey('message-row-outgoing'))) diff --git a/mobile/test/features/channels/inline_agent_typing_test.dart b/mobile/test/features/channels/inline_agent_typing_test.dart index 2e7e1ea367..df29a9971d 100644 --- a/mobile/test/features/channels/inline_agent_typing_test.dart +++ b/mobile/test/features/channels/inline_agent_typing_test.dart @@ -1,4 +1,6 @@ import 'package:buzz/features/channels/channel_identity_names_provider.dart'; +import 'package:buzz/features/channels/agent_typing_bubble.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/features/channels/channel_typing_provider.dart'; import 'package:buzz/features/channels/inline_agent_typing.dart'; import 'package:buzz/shared/identity_names/identity_names.dart'; @@ -19,8 +21,17 @@ class Profiles extends UserCacheNotifier { Map build() => {agent: profile}; } -Widget surface({bool reduced = false, bool typing = true}) => ProviderScope( +Widget surface({ + bool reduced = false, + bool typing = true, + bool bot = true, + List? entries, + double textScale = 1, +}) => ProviderScope( overrides: [ + agentMentionPubkeysProvider( + 'chat', + ).overrideWithValue(bot ? {agent} : {}), userCacheProvider.overrideWith(Profiles.new), channelIdentityNamesProvider('chat').overrideWithValue( const IdentityNameSources(profiles: {agent: profile}).scope([agent]), @@ -29,13 +40,16 @@ Widget surface({bool reduced = false, bool typing = true}) => ProviderScope( child: MaterialApp( theme: AppTheme.light(), home: MediaQuery( - data: MediaQueryData(disableAnimations: reduced), + data: MediaQueryData( + disableAnimations: reduced, + textScaler: TextScaler.linear(textScale), + ), child: Scaffold( body: InlineAgentTyping( channelId: 'chat', - entries: [ - if (typing) const TypingEntry(pubkey: agent, expiresAtMs: 8000), - ], + entries: + entries ?? + [if (typing) const TypingEntry(pubkey: agent, expiresAtMs: 8000)], ), ), ), @@ -48,6 +62,10 @@ void main() { await tester.pumpWidget(surface()); expect(find.text('Test agent'), findsOneWidget); expect(find.byType(AvatarImage), findsOneWidget); + expect( + tester.widget(find.byType(AvatarImage)).isAgent, + isTrue, + ); expect(find.textContaining('typing'), findsNothing); expect(find.byType(Text), findsNWidgets(2)); // Name and avatar initial. expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget); @@ -72,6 +90,89 @@ void main() { expect(find.text('Working…'), findsNothing); }, ); + testWidgets('human typing keeps a round avatar', (tester) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget(surface(bot: false)); + expect(find.bySemanticsLabel('Test agent is typing'), findsOneWidget); + expect( + tester.widget(find.byType(AvatarImage)).isAgent, + isFalse, + ); + expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget); + semantics.dispose(); + }); + testWidgets('many typers keep rows and active animation owners bounded', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final entries = List.generate( + 1024, + (index) => TypingEntry( + pubkey: index.toRadixString(16).padLeft(64, '0'), + expiresAtMs: 8000, + ), + ); + // The first three stay visible while the aggregate grows. Duplicate scopes + // for the same identity must not inflate the remainder count. + for (final count in [3, 4, 1024]) { + final visibleEntries = entries.take(count).toList(); + await tester.pumpWidget( + surface( + entries: [ + ...visibleEntries, + TypingEntry( + pubkey: visibleEntries.last.pubkey.toUpperCase(), + threadHeadId: 'thread', + expiresAtMs: 8000, + ), + ], + textScale: 2, + ), + ); + await tester.pump(const Duration(milliseconds: 240)); + expect(find.byType(AvatarImage), findsNWidgets(3)); + expect(find.byType(AgentTypingBubble), findsNWidgets(3)); + // Each repeating ticker schedules one transient callback for the next + // frame: this measures active work, not just visible dot widgets. + expect(tester.binding.transientCallbackCount, 3); + for (final entry in entries.take(3)) { + expect( + find.byKey(ValueKey('inline-agent-typing-${entry.pubkey}')), + findsOneWidget, + ); + } + if (count > 3) { + final remainder = count - 3; + expect( + find.text('and $remainder ${remainder == 1 ? 'other' : 'others'}'), + findsOneWidget, + ); + expect( + find.bySemanticsLabel( + '$remainder ${remainder == 1 ? 'other person is' : 'other people are'} typing', + ), + findsOneWidget, + ); + } else { + expect(find.textContaining('and '), findsNothing); + } + expect(tester.takeException(), isNull); + } + await tester.pumpWidget(surface(entries: entries.take(1).toList())); + await tester.pump(const Duration(milliseconds: 240)); + expect(find.byType(AvatarImage), findsOneWidget); + expect(find.textContaining('and '), findsNothing); + expect(tester.binding.transientCallbackCount, 1); + await tester.pumpWidget(surface(entries: entries, reduced: true)); + await tester.pumpAndSettle(); + expect(find.byType(AgentTypingBubble), findsNWidgets(3)); + expect(tester.binding.transientCallbackCount, 0); + await tester.pumpWidget(surface(typing: false, reduced: true)); + await tester.pumpAndSettle(); + expect(find.byType(AvatarImage), findsNothing); + expect(tester.binding.transientCallbackCount, 0); + semantics.dispose(); + }); testWidgets('Reduce Motion leaves static dots and no active animation', ( tester, ) async {