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.broadcast(); StreamSubscription? _sessionsSub; StreamSubscription? _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 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 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 _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 _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 stop() async { _started = false; await _sessionsSub?.cancel(); _sessionsSub = null; await _inboxSub?.cancel(); _inboxSub = null; _session = null; if (!_changes.isClosed) await _changes.close(); } }