mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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  ## 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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user