Profiles had only a coloured-initial disc. Now each person can set an avatar — a real photo OR one of our own seed illustrations (so pseudonymity stays the default; no photo required). - ui/avatar.dart: the one-string value scheme carried in the kind:0 'picture' — a 'data:' photo thumbnail, a 'tane:seed:<glyph>' token, or empty. The Nostr ProfileTransport already published 'picture'. - Photos ride inline as a tiny thumbnail (reuses offerThumbnailDataUri, 24 KB cap) — no media server, like offer photos. - avatar_edit.dart: pick/take a photo, choose a seed illustration, or remove; ProfileStore stores it; ProfileScreen shows a big editable avatar and publishes it. - PeerAvatar renders photo / illustration / initial fallback. - ProfileCache gains picture/setPicture; the inbox caches peers' avatars alongside their names. - i18n avatar block (en/es/pt/ast). Tests: value scheme + PeerAvatar render modes, store + cache round-trips. Follow-up: render peers' avatars in chat/market/your-people (thread the cached picture into PeerAvatar at each call site).
149 lines
5.5 KiB
Dart
149 lines
5.5 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:commons_core/commons_core.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../i18n/strings.g.dart';
|
|
import 'message_store.dart';
|
|
import 'notification_service.dart';
|
|
import 'profile_cache.dart';
|
|
import 'social_connection.dart';
|
|
import 'social_service.dart' show SocialSession;
|
|
import 'unread_service.dart';
|
|
|
|
/// App-wide inbox listener for private messages (NIP-17).
|
|
///
|
|
/// Without this, a message only arrived while its specific chat screen was open
|
|
/// — so a first message from a new peer was invisible. This keeps one inbox
|
|
/// subscription over the SHARED [SocialConnection] for the whole app while
|
|
/// online: it persists every incoming message to the [MessageStore], updates
|
|
/// unread counts, fires an OS notification, and fires [changes] so the inbox
|
|
/// list refreshes. It re-subscribes automatically each time the connection
|
|
/// reconnects (and stops when it drops), because it listens to the connection's
|
|
/// session stream rather than owning a socket itself.
|
|
///
|
|
/// Foreground only — background/push delivery is a later concern.
|
|
class InboxService {
|
|
InboxService({
|
|
required SocialConnection connection,
|
|
required String selfPubkey,
|
|
required MessageStore store,
|
|
ProfileCache? profileCache,
|
|
UnreadService? unread,
|
|
NotificationService? notifications,
|
|
}) : _connection = connection,
|
|
_selfPubkey = selfPubkey,
|
|
_store = store,
|
|
_profileCache = profileCache,
|
|
_unread = unread,
|
|
_notifications = notifications;
|
|
|
|
final SocialConnection _connection;
|
|
final String _selfPubkey;
|
|
final MessageStore _store;
|
|
final ProfileCache? _profileCache;
|
|
final UnreadService? _unread;
|
|
final NotificationService? _notifications;
|
|
|
|
final _changes = StreamController<void>.broadcast();
|
|
StreamSubscription<SocialSession?>? _sessionsSub;
|
|
StreamSubscription<PrivateMessage>? _inboxSub;
|
|
SocialSession? _session;
|
|
bool _started = false;
|
|
|
|
/// Fires (no payload) after a NEW message is persisted — the inbox list
|
|
/// listens to reload. Broadcast, so several screens can listen.
|
|
Stream<void> get changes => _changes.stream;
|
|
|
|
/// Begins listening. Subscribe to the connection's sessions BEFORE the
|
|
/// connection starts connecting, so the first session is caught too.
|
|
void start() {
|
|
if (_started) return;
|
|
_started = true;
|
|
_sessionsSub = _connection.sessions.listen(_onSession);
|
|
// In case the connection is already up (a screen connected first).
|
|
final current = _connection.current;
|
|
if (current != null) _onSession(current);
|
|
}
|
|
|
|
/// (Re)binds the inbox subscription to [session] — the connection handing us a
|
|
/// fresh session (reconnect) or null (dropped).
|
|
void _onSession(SocialSession? session) {
|
|
if (identical(session, _session) && _inboxSub != null) return;
|
|
unawaited(_inboxSub?.cancel());
|
|
_inboxSub = null;
|
|
_session = session;
|
|
if (session != null) {
|
|
_inboxSub = session.messages.inbox().listen(
|
|
ingest,
|
|
onError: (_) {}, // drop handled by the connection's reconnect
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Persists one incoming [message] (deduped) and, when new, fires [changes],
|
|
/// updates unread, notifies, and best-effort caches the sender's name. A
|
|
/// testable seam (no relay).
|
|
@visibleForTesting
|
|
Future<void> ingest(PrivateMessage message) async {
|
|
final stored = await _store.append(message.fromPubkey, message);
|
|
if (!stored) return; // a re-delivered duplicate
|
|
if (!_changes.isClosed) _changes.add(null);
|
|
|
|
final peer = message.fromPubkey;
|
|
// Defensive: never badge or notify for your own message. NIP-17 doesn't
|
|
// loop a sent gift wrap back to its author; the guard keeps the seam honest.
|
|
if (peer != _selfPubkey) {
|
|
await _unread?.onMessageReceived(peer, message);
|
|
await _maybeNotify(peer);
|
|
}
|
|
await _cacheName(peer);
|
|
}
|
|
|
|
/// Fires a text-free OS notification for a new message from [peer], unless its
|
|
/// chat is the one already on screen (then the message is visible anyway).
|
|
Future<void> _maybeNotify(String peer) async {
|
|
final notifications = _notifications;
|
|
if (notifications == null) return;
|
|
if (_unread?.activePeer == peer) return;
|
|
final name = await _profileCache?.name(peer) ?? shortPubkey(peer);
|
|
await notifications.showMessage(
|
|
peerPubkey: peer,
|
|
title: t.notifications.newMessageFrom(name: name),
|
|
);
|
|
}
|
|
|
|
Future<void> _cacheName(String peerPubkey) async {
|
|
final cache = _profileCache;
|
|
final session = _session;
|
|
if (cache == null || session == null) return;
|
|
// Fetch once we're missing either the name or the avatar for this peer.
|
|
final haveName = await cache.name(peerPubkey) != null;
|
|
final havePic = await cache.picture(peerPubkey) != null;
|
|
if (haveName && havePic) return;
|
|
try {
|
|
final profile = await session.profile.fetch(peerPubkey);
|
|
if (profile == null) return;
|
|
if (profile.name.isNotEmpty) await cache.setName(peerPubkey, profile.name);
|
|
if (profile.picture.isNotEmpty) {
|
|
await cache.setPicture(peerPubkey, profile.picture);
|
|
}
|
|
if ((profile.name.isNotEmpty || profile.picture.isNotEmpty) &&
|
|
!_changes.isClosed) {
|
|
_changes.add(null); // re-render with the name/avatar
|
|
}
|
|
} catch (_) {
|
|
// best effort — the short key shows meanwhile
|
|
}
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
_started = false;
|
|
await _sessionsSub?.cancel();
|
|
_sessionsSub = null;
|
|
await _inboxSub?.cancel();
|
|
_inboxSub = null;
|
|
_session = null;
|
|
if (!_changes.isClosed) await _changes.close();
|
|
}
|
|
}
|