mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(mobile): show inline human typing and outgoing avatars (#8505)
## Summary People typing in a mobile channel now appear beside their avatar in an inline three-dot bubble, using the existing agent typing animation instead of the separate composer status strip. Human avatars stay round; agent identities retain their agent shape. Reduced Motion keeps the dots still, and thread-scoped typing remains inside its thread. Restore the viewer’s avatar on the right of their own messages in channel and Me timelines. Grouped messages show the avatar on the final bubble, with reactions and reply footers aligned to the bubble. ### Related issue No duplicate found. Related: #3668 tracks observer-backed agent working status; this PR changes presentation only and does not add observer lifecycle behavior. ### Testing - `just mobile-install`; final pre-push `just mobile-check && just mobile-test` passed (3,728 tests passed, 16 skipped; 4 unconfigured-push tests passed). Repository file-size gate and commit formatting passed. - Regression checks fail against the original channel typing and outgoing-avatar implementation. - Pixel 10 / Honeycomb Studios: the recording build exercised inline human/agent typing, avatar shapes, own-message avatars, and message arrival. That build predates this fresh-main PR; the PR additionally uses the shared agent identity lookup. - Before/after widget-rendered previews use generic sample messages. No recording identity, app-package override, or demo scripts are included. ### Before / after Rendered from the production channel widgets using generic sample messages; Reduce Motion enabled for the stills. | Before | After | | --- | --- | |  |  | ### Manual check Open a channel and have another person type: their round avatar and animated dots should appear at the bottom of the conversation and disappear when the message arrives. Send two consecutive messages: your avatar should appear once beside the final bubble. Repeat with Reduce Motion and larger text. Draft pending human testing of this PR snapshot. Do not merge based on the earlier recording alone. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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.onSurfaceVariant,
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(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<SmallAvatar>(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,
|
||||
|
||||
@@ -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')))
|
||||
|
||||
@@ -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<String, UserProfile> build() => {agent: profile};
|
||||
}
|
||||
|
||||
Widget surface({bool reduced = false, bool typing = true}) => ProviderScope(
|
||||
Widget surface({
|
||||
bool reduced = false,
|
||||
bool typing = true,
|
||||
bool bot = true,
|
||||
List<TypingEntry>? entries,
|
||||
double textScale = 1,
|
||||
}) => ProviderScope(
|
||||
overrides: [
|
||||
agentMentionPubkeysProvider(
|
||||
'chat',
|
||||
).overrideWithValue(bot ? {agent} : <String>{}),
|
||||
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<AvatarImage>(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<AvatarImage>(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 {
|
||||
|
||||
Reference in New Issue
Block a user