Every feature (offers, messaging, trust, profile, the inbox listener) used to open its OWN RelayPool via social.openSession — several sockets to the same relays, more battery, and reconnection logic living only in the inbox listener. Add SocialConnection: ONE shared session per identity, lazily connected and reused by all. It watches connectivity — dropping the session when the network goes and reconnecting when it returns, announcing each change on a sessions stream so the inbox listener re-subscribes automatically. Callers get the session via connection.session() and never close it (the connection owns its lifecycle); recreated/disposed on an identity switch. - InboxService now consumes the shared connection (subscribes on its sessions stream) instead of owning its own socket + connectivity code. - createOffersCubit + the chat/market/profile screens use the shared connection; dead createMessagesCubit/createTrustCubit factories removed. - DI registers SocialConnection per identity; Bootstrap starts it (after the inbox subscribes); switchSocialAccount disposes + recreates it. Tests: SocialConnection lifecycle (reuse, concurrent connect, offline drop + reconnect, unreachable retry) with a fake opener + online stream; inbox test updated. nostr added as a dev_dependency for the channel fake.
141 lines
5.1 KiB
Dart
141 lines
5.1 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;
|
|
if (await cache.name(peerPubkey) != null) return; // already known
|
|
try {
|
|
final profile = await session.profile.fetch(peerPubkey);
|
|
if (profile != null && profile.name.isNotEmpty) {
|
|
await cache.setName(peerPubkey, profile.name);
|
|
if (!_changes.isClosed) _changes.add(null); // re-render with the name
|
|
}
|
|
} 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();
|
|
}
|
|
}
|