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, message: message,
mine: cubit.isMine(message), mine: cubit.isMine(message),
peerName: peerName, 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; /// One message line. Each sender gets their own coloured avatar on their side
/// the peer's align to the leading edge with their avatar (1:1 convention). /// 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 { 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 PrivateMessage message;
final bool mine; final bool mine;
final String? peerName; final String? peerName;
/// My own pubkey, so my messages carry my (distinctly coloured) avatar.
final String? selfPubkey;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (mine) { // My avatar has no name (it's me) → a glyph on my colour; the peer's shows
return Align( // their initial on theirs. Different pubkeys different colours.
alignment: AlignmentDirectional.centerEnd, final avatar = mine
child: _Bubble(message: message, mine: true), ? PeerAvatar(pubkey: selfPubkey ?? message.fromPubkey)
); : PeerAvatar(pubkey: message.fromPubkey, name: peerName);
} final bubble = Flexible(
child: _Bubble(message: message, mine: mine),
);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ mainAxisAlignment: mine
PeerAvatar(pubkey: message.fromPubkey, name: peerName), ? MainAxisAlignment.end
const SizedBox(width: 8), : MainAxisAlignment.start,
Flexible(child: _Bubble(message: message, mine: false)), 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)); expect(find.byKey(const Key('chat.daySeparator')), findsNWidgets(2));
}); });
testWidgets('the peer bubble carries an avatar, mine does not', ( testWidgets('each sender gets a distinctly coloured avatar', (tester) async {
tester,
) async {
final transport = _FakeMessageTransport(); final transport = _FakeMessageTransport();
final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)
..start(); ..start();
@ -126,8 +124,14 @@ void main() {
await tester.pumpWidget(host(cubit)); await tester.pumpWidget(host(cubit));
await tester.pump(); await tester.pump();
// One avatar, for the single peer message; my own bubble has none. // Both sides carry an avatar, and the two people are different colours
expect(find.byType(PeerAvatar), findsOneWidget); // 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', ( testWidgets('a new message lands in view at the bottom, not below the fold', (