Polish mobile typing indicator (#3528)

## Summary

- Present channel and thread typing status in a composer-matched
container.
- Animate the strip so the message list moves smoothly as typing begins
and ends.
- Increase typing-label contrast and avatar/padding for readability.

## Pixel 10 snapshot

![Typing indicator above the
composer](https://raw.githubusercontent.com/block/buzz/31de9f86a76fe61498bc7f2931d9e574827a9aa2/pr-3528--typing-indicator.png)

## Validation

- `flutter test test/features/channels/channel_detail_page_test.dart`
- `flutter analyze`

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-07-29 15:35:41 +01:00
committed by GitHub
parent c405ad1d4b
commit ce01e930ed
5 changed files with 131 additions and 132 deletions
@@ -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)
@@ -19,70 +19,6 @@ double _dmAppBarTitleContentHeight(BuildContext context) {
return textHeight > 30 ? textHeight : 30;
}
class _TypingIndicator extends ConsumerWidget {
final List<TypingEntry> 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;
@@ -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<TypingEntry> 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,
),
),
],
),
),
);
}
}
@@ -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<TypingEntry> 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;
@@ -1808,6 +1808,25 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Alice is typing…'), findsOneWidget);
final indicator = tester.widget<Container>(
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<Border>());
expect(
tester.widget<Text>(find.text('Alice is typing…')).style?.color,
AppTheme.light().colorScheme.primary,
);
expect(tester.widget<SmallAvatar>(find.byType(SmallAvatar)).size, 24);
});
testWidgets('shows two typers', (tester) async {