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 |
| --- | --- |
|
![Before](https://raw.githubusercontent.com/block/buzz/cc8df55d3a8bbccee4205593fbcff6760a7d1143/pr-8505--before.png)
|
![After](https://raw.githubusercontent.com/block/buzz/cc8df55d3a8bbccee4205593fbcff6760a7d1143/pr-8505--after.png)
|

### 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:
klopez4212
2026-10-20 21:59:41 -04:00
committed by GitHub
parent 76d792f288
commit 0768397092
6 changed files with 175 additions and 87 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
),
),
),
),
),
],
);
}

View File

@@ -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,

View File

@@ -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')))

View File

@@ -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 {