feat(profile): show other people's avatars in chat, chat list, your people

Follow-up to the avatar feature: peers' published photos/illustrations
now actually render where people appear.

- CachedAvatar: a PeerAvatar that resolves the person's picture from the
  ProfileCache (FutureBuilder), falling back to the coloured-initial disc.
  For list/row sites — one avatar each.
- Chat bubbles: the peer's and my own avatar are resolved once into state
  (_peerPicture/_selfPicture — from the cache / ProfileStore, freshened
  from the peer's published profile) and threaded to each bubble, so many
  bubbles of two people don't each hit the cache.
- Chat list + your-people rows: swapped the static person-icon disc for
  CachedAvatar, so photos/illustrations show there too.
- Tests: CachedAvatar render modes (cached illustration / initial / no
  cache). analyze clean; chat + your-people suites green.

Market offers show no author avatar today (would be a new element, not a
swap) — left out of scope.
This commit is contained in:
vjrj 2026-07-11 22:50:10 +02:00
parent ec56819fbf
commit 51d6924464
5 changed files with 150 additions and 16 deletions

View file

@ -7,6 +7,7 @@ import '../i18n/strings.g.dart';
import '../services/inbox_service.dart';
import '../services/message_store.dart';
import '../services/profile_cache.dart';
import 'peer_avatar.dart';
import '../services/social_connection.dart';
import 'theme.dart';
import 'unread_badge.dart';
@ -119,9 +120,11 @@ class _ChatListScreenState extends State<ChatListScreen> {
return ListTile(
leading: UnreadBadge(
peer: c.peerPubkey,
child: const CircleAvatar(
backgroundColor: seedAvatar,
child: Icon(Icons.person, color: seedOnAvatar),
child: CachedAvatar(
pubkey: c.peerPubkey,
name: _names[c.peerPubkey],
cache: widget.profileCache,
radius: 20,
),
),
title: Text(_names[c.peerPubkey] ??

View file

@ -60,6 +60,8 @@ class _ChatScreenState extends State<ChatScreen> {
bool _loading = true;
String? _peerName;
String? _selfName;
String? _peerPicture;
String? _selfPicture;
String? _peerG1;
final _input = TextEditingController();
@ -86,10 +88,13 @@ class _ChatScreenState extends State<ChatScreen> {
// connected / unreachable) null transports and the screen degrades.
final session = await widget.connection.session();
final cachedName = await widget.profileCache?.name(widget.peerPubkey);
// My own display name, so my avatar shows my initial (not a bare glyph).
final selfName = getIt.isRegistered<ProfileStore>()
? await getIt<ProfileStore>().name()
: '';
final cachedPicture = await widget.profileCache?.picture(widget.peerPubkey);
// My own name + avatar, so my side shows me (not a bare glyph).
final selfStore = getIt.isRegistered<ProfileStore>()
? getIt<ProfileStore>()
: null;
final selfName = selfStore == null ? '' : await selfStore.name();
final selfAvatar = selfStore == null ? '' : await selfStore.avatar();
if (!mounted) return; // shared session is owned by the connection, not us
final self = widget.social.publicKeyHex;
final messages = MessagesCubit(
@ -117,6 +122,8 @@ class _ChatScreenState extends State<ChatScreen> {
_rating = rating;
_peerName = cachedName;
_selfName = selfName.isEmpty ? null : selfName;
_peerPicture = cachedPicture;
_selfPicture = selfAvatar.isEmpty ? null : selfAvatar;
_loading = false;
});
@ -133,8 +140,15 @@ class _ChatScreenState extends State<ChatScreen> {
profile.name,
);
}
if (profile.picture.isNotEmpty) {
await widget.profileCache!.setPicture(
widget.peerPubkey,
profile.picture,
);
}
setState(() {
if (profile.name.isNotEmpty) _peerName = profile.name;
if (profile.picture.isNotEmpty) _peerPicture = profile.picture;
if (profile.g1.isNotEmpty) _peerG1 = profile.g1;
});
}
@ -230,6 +244,8 @@ class _ChatScreenState extends State<ChatScreen> {
onSend: _send,
peerName: _peerName,
selfName: _selfName,
peerPicture: _peerPicture,
selfPicture: _selfPicture,
),
),
);
@ -242,12 +258,16 @@ class _ChatBody extends StatelessWidget {
required this.onSend,
this.peerName,
this.selfName,
this.peerPicture,
this.selfPicture,
});
final TextEditingController controller;
final Future<void> Function() onSend;
final String? peerName;
final String? selfName;
final String? peerPicture;
final String? selfPicture;
@override
Widget build(BuildContext context) {
@ -278,7 +298,12 @@ class _ChatBody extends StatelessWidget {
const _TrustBanner(),
const _RatingStrip(),
Expanded(
child: ChatMessageList(peerName: peerName, selfName: selfName),
child: ChatMessageList(
peerName: peerName,
selfName: selfName,
peerPicture: peerPicture,
selfPicture: selfPicture,
),
),
_Composer(controller: controller, onSend: onSend),
],
@ -292,7 +317,13 @@ class _ChatBody extends StatelessWidget {
/// in place instead of landing below the fold. Reads the [MessagesCubit] from
/// context.
class ChatMessageList extends StatelessWidget {
const ChatMessageList({this.peerName, this.selfName, super.key});
const ChatMessageList({
this.peerName,
this.selfName,
this.peerPicture,
this.selfPicture,
super.key,
});
/// The peer's display name, for their avatar's initial (null a glyph).
final String? peerName;
@ -300,6 +331,10 @@ class ChatMessageList extends StatelessWidget {
/// My own display name, for my avatar's initial (null → a glyph).
final String? selfName;
/// The peer's / my published avatar (null → the coloured-initial disc).
final String? peerPicture;
final String? selfPicture;
@override
Widget build(BuildContext context) {
final t = context.t;
@ -329,6 +364,8 @@ class ChatMessageList extends StatelessWidget {
mine: cubit.isMine(message),
peerName: peerName,
selfName: selfName,
peerPicture: peerPicture,
selfPicture: selfPicture,
selfPubkey: cubit.selfPubkey,
),
};
@ -494,6 +531,8 @@ class _MessageRow extends StatelessWidget {
required this.mine,
this.peerName,
this.selfName,
this.peerPicture,
this.selfPicture,
this.selfPubkey,
});
@ -501,17 +540,27 @@ class _MessageRow extends StatelessWidget {
final bool mine;
final String? peerName;
final String? selfName;
final String? peerPicture;
final String? selfPicture;
/// My own pubkey, so my messages carry my (distinctly coloured) avatar.
final String? selfPubkey;
@override
Widget build(BuildContext context) {
// Each avatar shows its owner's initial (falling back to a glyph when no
// name is known) on a colour derived from their pubkey.
// Each avatar shows its owner's photo/illustration when set, else their
// initial (or a glyph) on a colour derived from their pubkey.
final avatar = mine
? PeerAvatar(pubkey: selfPubkey ?? message.fromPubkey, name: selfName)
: PeerAvatar(pubkey: message.fromPubkey, name: peerName);
? PeerAvatar(
pubkey: selfPubkey ?? message.fromPubkey,
name: selfName,
picture: selfPicture,
)
: PeerAvatar(
pubkey: message.fromPubkey,
name: peerName,
picture: peerPicture,
);
final bubble = Flexible(
child: _Bubble(message: message, mine: mine),
);

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../services/offer_thumbnail.dart' show decodeDataUri;
import '../services/profile_cache.dart';
import 'avatar.dart';
import 'seed_glyph.dart';
@ -71,6 +72,43 @@ class PeerAvatar extends StatelessWidget {
}
}
/// A [PeerAvatar] that looks the person's published avatar up from the
/// [ProfileCache] (their kind:0 `picture`), falling back to the coloured-initial
/// disc while it loads or when none is cached / no cache is available. Use at
/// list/row sites (one avatar each); for many avatars of the same few people
/// (chat bubbles) resolve the picture once into state instead.
class CachedAvatar extends StatelessWidget {
const CachedAvatar({
required this.pubkey,
this.name,
this.cache,
this.radius = 14,
super.key,
});
final String pubkey;
final String? name;
final ProfileCache? cache;
final double radius;
@override
Widget build(BuildContext context) {
final c = cache;
if (c == null) {
return PeerAvatar(pubkey: pubkey, name: name, radius: radius);
}
return FutureBuilder<String?>(
future: c.picture(pubkey),
builder: (context, snap) => PeerAvatar(
pubkey: pubkey,
name: name,
picture: snap.data,
radius: radius,
),
);
}
}
/// A deterministic, readable avatar colour derived from [pubkey]. Hashes the
/// key to a hue, then fixes saturation/lightness so white text stays legible on
/// top. Pure and stable exposed for testing.

View file

@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import '../i18n/strings.g.dart';
import '../services/profile_cache.dart';
import 'peer_avatar.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart';
import 'theme.dart';
@ -151,7 +152,9 @@ class _YourPeopleScreenState extends State<YourPeopleScreen> {
for (final peer in _youVouchFor)
_PersonTile(
key: Key('yourPeople.given.$peer'),
pubkey: peer,
name: _names[peer] ?? shortPubkey(peer),
cache: widget.profileCache,
onOpen: () => context.push('/chat/$peer'),
trailing: TextButton(
onPressed: _busy ? null : () => _revoke(peer),
@ -166,7 +169,9 @@ class _YourPeopleScreenState extends State<YourPeopleScreen> {
for (final peer in _vouchForYou)
_PersonTile(
key: Key('yourPeople.received.$peer'),
pubkey: peer,
name: _names[peer] ?? shortPubkey(peer),
cache: widget.profileCache,
onOpen: () => context.push('/chat/$peer'),
),
],
@ -217,20 +222,26 @@ class _PersonTile extends StatelessWidget {
const _PersonTile({
required this.name,
required this.onOpen,
required this.pubkey,
this.cache,
this.trailing,
super.key,
});
final String pubkey;
final String name;
final ProfileCache? cache;
final VoidCallback onOpen;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return ListTile(
leading: const CircleAvatar(
backgroundColor: seedAvatar,
child: Icon(Icons.person, color: seedOnAvatar),
leading: CachedAvatar(
pubkey: pubkey,
name: name,
cache: cache,
radius: 20,
),
title: Text(name),
trailing: trailing,

View file

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/profile_cache.dart';
import 'package:tane/ui/avatar.dart';
import 'package:tane/ui/peer_avatar.dart';
import 'package:tane/ui/seed_glyph.dart';
import '../support/test_support.dart';
void main() {
group('avatar value scheme', () {
test('every offered illustration maps to a glyph; unknown → null', () {
@ -56,4 +59,34 @@ void main() {
expect(find.text('R'), findsNothing); // the initial isn't used
});
});
group('CachedAvatar', () {
Widget host(Widget child) => MaterialApp(
home: Scaffold(body: Center(child: child)),
);
testWidgets('paints the cached illustration for the peer', (tester) async {
final cache = ProfileCache(InMemorySecretStore());
await cache.setPicture('peer1', 'tane:seed:sack');
await tester.pumpWidget(host(
CachedAvatar(pubkey: 'peer1', name: 'Bea', cache: cache)));
await tester.pumpAndSettle();
expect(find.byType(SeedGlyph), findsOneWidget);
});
testWidgets('falls back to the initial when nothing is cached', (
tester,
) async {
final cache = ProfileCache(InMemorySecretStore());
await tester.pumpWidget(host(
CachedAvatar(pubkey: 'peer2', name: 'Bea', cache: cache)));
await tester.pumpAndSettle();
expect(find.text('B'), findsOneWidget);
});
testWidgets('no cache → the coloured-initial disc', (tester) async {
await tester.pumpWidget(host(const CachedAvatar(pubkey: 'x', name: 'Bea')));
expect(find.text('B'), findsOneWidget);
});
});
}