import 'dart:async'; import 'package:commons_core/commons_core.dart'; import '../security/secret_store.dart'; import 'message_store.dart'; /// Tracks which private messages are still unread, so the UI can show a badge. /// /// There is no read/unread flag on a message; instead we keep a per-peer /// "last read at" timestamp in the keystore (just a timestamp and a pubkey — no /// message text, so it honours "no plaintext at rest"). A conversation's unread /// count is simply the peer's messages newer than that mark. Reading history /// from the [MessageStore] keeps a single source of truth. /// /// Only inbound messages count: a message whose author is the peer. Your own /// sent messages (stored with your pubkey) are never counted. /// /// [activePeer] is the chat currently on screen. A message that arrives for it /// is treated as already read (you're looking at it), and the inbox listener /// also skips its notification — so the chat you're in never badges or beeps. class UnreadService { UnreadService(this._store, this._secrets); final MessageStore _store; final SecretStore _secrets; final _changes = StreamController.broadcast(); /// The peer whose chat is currently open, or null. Set by [ChatScreen]. String? activePeer; static const _prefix = 'tane.social.unread.'; String _key(String peer) => '$_prefix$peer'; /// Fires (no payload) whenever an unread count may have changed — on a new /// inbound message and on [markRead]. Broadcast, so several badges can listen. Stream get changes => _changes.stream; /// Records that a new [message] for [peer] was just persisted. If its chat is /// open it is marked read immediately; otherwise badges are asked to refresh. Future onMessageReceived(String peer, PrivateMessage message) async { if (peer == activePeer) { await markRead(peer); } else { _fire(); } } /// Marks the conversation with [peer] read up to its latest message, so its /// unread count drops to zero. Called when the chat opens (and when a message /// arrives while it's open). Future markRead(String peer) async { final history = await _store.history(peer); final upTo = history.isEmpty ? DateTime.now() : history .map((m) => m.at) .reduce((a, b) => a.isAfter(b) ? a : b); await _secrets.write(_key(peer), '${upTo.millisecondsSinceEpoch}'); _fire(); } /// Unread inbound messages from [peer] (those newer than its last-read mark). Future unreadCount(String peer) async { final lastRead = await _lastRead(peer); final history = await _store.history(peer); return history .where((m) => m.fromPubkey == peer && m.at.isAfter(lastRead)) .length; } /// Unread messages across every conversation (the number for the app badge). Future totalUnreadCount() async { var total = 0; for (final c in await _store.conversations()) { total += await unreadCount(c.peerPubkey); } return total; } Future _lastRead(String peer) async { final raw = await _secrets.read(_key(peer)); final ms = raw == null ? null : int.tryParse(raw); return DateTime.fromMillisecondsSinceEpoch(ms ?? 0); } void _fire() { if (!_changes.isClosed) _changes.add(null); } Future dispose() async { if (!_changes.isClosed) await _changes.close(); } }