feat(messages): unread badges + OS notification for private messages
Private messages (NIP-17) arrived in the foreground with no signal: no unread count, no badge, no OS notification. Hook both into the single InboxService.ingest choke point. - UnreadService: per-peer "last read" in the keystore (timestamps+pubkey only, no message text), live count via a changes stream, active-peer suppression so the open chat never badges or notifies. - UnreadBadge: reusable, RTL-aware (Flutter Badge) — on the home hamburger, the drawer "Chat" item, and per-conversation in the messages list. - Mark-read on chat open (ChatScreen). - NotificationService over flutter_local_notifications: generic "New message from <name>" (no message text, for privacy), payload = pubkey, tap opens /chat/:pubkey. No-op on web/Windows; Android POST_NOTIFICATIONS. - i18n notifications.newMessageFrom (en/es/pt/ast). Background/push stays out of scope (foreground-only by design). Tests: UnreadService, NotificationService (mock plugin), InboxService hooks (new peer notifies/counts; duplicate, own, and open-chat do not), and a UnreadBadge widget test. dart analyze clean; commons_core green.
This commit is contained in:
parent
63f48db5c2
commit
5fe0f4540e
27 changed files with 1238 additions and 114 deletions
96
apps/app_seeds/lib/services/unread_service.dart
Normal file
96
apps/app_seeds/lib/services/unread_service.dart
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
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<void>.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<void> 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<void> 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<void> 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<int> 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<int> totalUnreadCount() async {
|
||||
var total = 0;
|
||||
for (final c in await _store.conversations()) {
|
||||
total += await unreadCount(c.peerPubkey);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
Future<DateTime> _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<void> dispose() async {
|
||||
if (!_changes.isClosed) await _changes.close();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue