feat(chat): sender avatar on both sides, coloured per person

Feedback: avatars only on the left, all the same colour, so it was hard
to tell who said what. In a 1:1 only the peer had an avatar (one colour).
Now each message carries its sender's avatar on their own side — mine
trailing, the peer's leading — each tinted by a deterministic colour from
their pubkey. Two sides, two colours: who-said-what is obvious at a glance.
This commit is contained in:
vjrj 2026-07-11 06:59:24 +02:00
parent a5f50314e4
commit 73e96a3ce5
2 changed files with 36 additions and 19 deletions

View file

@ -302,6 +302,7 @@ class ChatMessageList extends StatelessWidget {
message: message,
mine: cubit.isMine(message),
peerName: peerName,
selfPubkey: cubit.selfPubkey,
),
};
},
@ -408,32 +409,44 @@ class _DaySeparator extends StatelessWidget {
}
}
/// One message line: my messages align to the trailing edge with no avatar;
/// the peer's align to the leading edge with their avatar (1:1 convention).
/// One message line. Each sender gets their own coloured avatar on their side
/// mine on the trailing edge, the peer's on the leading edge — so at a glance
/// the two sides are two people, not one column of look-alike bubbles.
class _MessageRow extends StatelessWidget {
const _MessageRow({required this.message, required this.mine, this.peerName});
const _MessageRow({
required this.message,
required this.mine,
this.peerName,
this.selfPubkey,
});
final PrivateMessage message;
final bool mine;
final String? peerName;
/// My own pubkey, so my messages carry my (distinctly coloured) avatar.
final String? selfPubkey;
@override
Widget build(BuildContext context) {
if (mine) {
return Align(
alignment: AlignmentDirectional.centerEnd,
child: _Bubble(message: message, mine: true),
);
}
// My avatar has no name (it's me) → a glyph on my colour; the peer's shows
// their initial on theirs. Different pubkeys different colours.
final avatar = mine
? PeerAvatar(pubkey: selfPubkey ?? message.fromPubkey)
: PeerAvatar(pubkey: message.fromPubkey, name: peerName);
final bubble = Flexible(
child: _Bubble(message: message, mine: mine),
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
PeerAvatar(pubkey: message.fromPubkey, name: peerName),
const SizedBox(width: 8),
Flexible(child: _Bubble(message: message, mine: false)),
],
mainAxisAlignment: mine
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: mine
? [bubble, const SizedBox(width: 8), avatar]
: [avatar, const SizedBox(width: 8), bubble],
),
);
}

View file

@ -113,9 +113,7 @@ void main() {
expect(find.byKey(const Key('chat.daySeparator')), findsNWidgets(2));
});
testWidgets('the peer bubble carries an avatar, mine does not', (
tester,
) async {
testWidgets('each sender gets a distinctly coloured avatar', (tester) async {
final transport = _FakeMessageTransport();
final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)
..start();
@ -126,8 +124,14 @@ void main() {
await tester.pumpWidget(host(cubit));
await tester.pump();
// One avatar, for the single peer message; my own bubble has none.
expect(find.byType(PeerAvatar), findsOneWidget);
// Both sides carry an avatar, and the two people are different colours
// so you can tell who said what at a glance.
expect(find.byType(PeerAvatar), findsNWidgets(2));
final discs = tester
.widgetList<CircleAvatar>(find.byType(CircleAvatar))
.toList();
expect(discs, hasLength(2));
expect(discs.first.backgroundColor, isNot(discs.last.backgroundColor));
});
testWidgets('a new message lands in view at the bottom, not below the fold', (