From 60a6ccbef9ff8c4721d5e29fb13fd00fbcb706a1 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 13:37:37 +0100 Subject: [PATCH] Polish mobile typing indicator Signed-off-by: kenny lopez --- .../channels/channel_detail_page.dart | 14 ++- .../channels/channel_detail_page/app_bar.dart | 64 ------------- .../channels/channel_typing_indicator.dart | 89 +++++++++++++++++++ .../features/channels/thread_detail_page.dart | 77 +++------------- .../channels/channel_detail_page_test.dart | 19 ++++ 5 files changed, 131 insertions(+), 132 deletions(-) create mode 100644 mobile/lib/features/channels/channel_typing_indicator.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 6bbb60c0d1..4304705f90 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -27,6 +27,7 @@ import 'agent_activity/working_bots_provider.dart'; import 'channel_management_provider.dart'; import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; +import 'channel_typing_indicator.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; import 'date_formatters.dart'; @@ -368,8 +369,17 @@ class ChannelDetailPage extends HookConsumerWidget { ), ), ), - if (!resolvedChannel.isForum && typingEntries.isNotEmpty) - _TypingIndicator(entries: typingEntries), + if (!resolvedChannel.isForum) + AnimatedSize( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: Alignment.bottomCenter, + child: typingEntries.isEmpty + ? const SizedBox.shrink() + : ChannelTypingIndicator(entries: typingEntries), + ), if (!resolvedChannel.isForum && resolvedChannel.isMember && !resolvedChannel.isArchived) diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index fbf5abfb3b..406d68b4f5 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -19,70 +19,6 @@ double _dmAppBarTitleContentHeight(BuildContext context) { return textHeight > 30 ? textHeight : 30; } -class _TypingIndicator extends ConsumerWidget { - final List entries; - - const _TypingIndicator({required this.entries}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final userCache = ref.watch(userCacheProvider); - final names = entries.map((e) { - final profile = - userCache[e.pubkey.toLowerCase()] ?? - ref.read(userCacheProvider.notifier).get(e.pubkey.toLowerCase()); - return profile?.label ?? shortPubkey(e.pubkey); - }).toList(); - final text = switch (names.length) { - 1 => '${names[0]} is typing…', - 2 => '${names[0]} and ${names[1]} are typing…', - _ => '${names[0]} and ${names.length - 1} others are typing…', - }; - - final visibleEntries = entries.take(3).toList(); - final avatarCount = visibleEntries.length; - - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.quarter + 2, - ), - child: Row( - children: [ - SizedBox( - width: 20.0 + (avatarCount - 1) * 12.0, - height: 20, - child: Stack( - children: [ - for (var i = 0; i < avatarCount; i++) - Positioned( - left: i * 12.0, - child: SmallAvatar( - pubkey: visibleEntries[i].pubkey, - userCache: userCache, - ), - ), - ], - ), - ), - const SizedBox(width: Grid.xxs), - Flexible( - child: Text( - text, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.outline, - fontStyle: FontStyle.italic, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} - class _MembersButton extends ConsumerWidget { final String channelId; final Channel channel; diff --git a/mobile/lib/features/channels/channel_typing_indicator.dart b/mobile/lib/features/channels/channel_typing_indicator.dart new file mode 100644 index 0000000000..d021b9e287 --- /dev/null +++ b/mobile/lib/features/channels/channel_typing_indicator.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/theme/theme.dart'; +import '../../shared/utils/string_utils.dart'; +import '../profile/user_cache_provider.dart'; +import 'channel_typing_provider.dart'; +import 'small_avatar.dart'; + +/// Composer-adjacent status for people currently typing in a channel or thread. +class ChannelTypingIndicator extends ConsumerWidget { + final List entries; + + const ChannelTypingIndicator({super.key, required this.entries}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userCache = ref.watch(userCacheProvider); + final names = entries.map((entry) { + final profile = + userCache[entry.pubkey.toLowerCase()] ?? + ref.read(userCacheProvider.notifier).get(entry.pubkey.toLowerCase()); + return profile?.label ?? shortPubkey(entry.pubkey); + }).toList(); + final text = switch (names.length) { + 1 => '${names[0]} is typing…', + 2 => '${names[0]} and ${names[1]} are typing…', + _ => '${names[0]} and ${names.length - 1} others are typing…', + }; + final visibleEntries = entries.take(3).toList(); + final avatarCount = visibleEntries.length; + + return Padding( + padding: const EdgeInsets.only( + left: Grid.twelve, + right: Grid.twelve, + bottom: Grid.xxs, + ), + child: Container( + key: const ValueKey('channel-typing-indicator'), + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.xxs, + ), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), + ), + child: Row( + children: [ + SizedBox( + width: 24.0 + (avatarCount - 1) * 14.0, + height: 24, + child: Stack( + children: [ + for (var i = 0; i < avatarCount; i++) + Positioned( + left: i * 14.0, + child: SmallAvatar( + pubkey: visibleEntries[i].pubkey, + userCache: userCache, + size: 24, + ), + ), + ], + ), + ), + const SizedBox(width: Grid.xxs), + Flexible( + child: Text( + text, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.primary, + fontStyle: FontStyle.italic, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 0babba2394..167b111f4f 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -12,6 +12,7 @@ import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import 'channel_link_navigation.dart'; import 'channel_typing_provider.dart'; +import 'channel_typing_indicator.dart'; import 'thread_replies_provider.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; @@ -280,8 +281,16 @@ class ThreadDetailPage extends HookConsumerWidget { }, ), ), - if (threadTyping.isNotEmpty) - _ThreadTypingIndicator(entries: threadTyping), + AnimatedSize( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: Alignment.bottomCenter, + child: threadTyping.isEmpty + ? const SizedBox.shrink() + : ChannelTypingIndicator(entries: threadTyping), + ), if (isMember && !isArchived) ComposeBar( channelId: channelId, @@ -663,70 +672,6 @@ class _ThreadMessage extends ConsumerWidget { } } -class _ThreadTypingIndicator extends ConsumerWidget { - final List entries; - - const _ThreadTypingIndicator({required this.entries}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final userCache = ref.watch(userCacheProvider); - final names = entries.map((e) { - final profile = - userCache[e.pubkey.toLowerCase()] ?? - ref.read(userCacheProvider.notifier).get(e.pubkey.toLowerCase()); - return profile?.label ?? shortPubkey(e.pubkey); - }).toList(); - final text = switch (names.length) { - 1 => '${names[0]} is typing...', - 2 => '${names[0]} and ${names[1]} are typing...', - _ => '${names[0]} and ${names.length - 1} others are typing...', - }; - - final visibleEntries = entries.take(3).toList(); - final avatarCount = visibleEntries.length; - - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.quarter + 2, - ), - child: Row( - children: [ - SizedBox( - width: 20.0 + (avatarCount - 1) * 12.0, - height: 20, - child: Stack( - children: [ - for (var i = 0; i < avatarCount; i++) - Positioned( - left: i * 12.0, - child: SmallAvatar( - pubkey: visibleEntries[i].pubkey, - userCache: userCache, - ), - ), - ], - ), - ), - const SizedBox(width: Grid.xxs), - Flexible( - child: Text( - text, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.outline, - fontStyle: FontStyle.italic, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} - class _Avatar extends StatelessWidget { final UserProfile? profile; final String pubkey; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 48ad861d3a..127e6851e6 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1808,6 +1808,25 @@ void main() { await tester.pumpAndSettle(); 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.primary, + ); + expect(tester.widget(find.byType(SmallAvatar)).size, 24); }); testWidgets('shows two typers', (tester) async {