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:
vjrj 2026-07-10 21:12:00 +02:00
parent 63f48db5c2
commit 5fe0f4540e
27 changed files with 1238 additions and 114 deletions

View file

@ -76,6 +76,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$chatList$en chatList = Translations$chatList$en.internal(_root);
late final Translations$chat$en chat = Translations$chat$en.internal(_root);
late final Translations$trust$en trust = Translations$trust$en.internal(_root);
late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root);
}
// Path: app
@ -1401,6 +1402,18 @@ class Translations$trust$en {
String get circle => 'In your circle';
}
// Path: notifications
class Translations$notifications$en {
Translations$notifications$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'New message from {name}'
String newMessageFrom({required Object name}) => 'New message from ${name}';
}
// Path: intro.slides
class Translations$intro$slides$en {
Translations$intro$slides$en.internal(this._root);
@ -2247,6 +2260,7 @@ extension on Translations {
'trust.vouch' => 'I know this person',
'trust.vouched' => 'You vouch for them',
'trust.circle' => 'In your circle',
'notifications.newMessageFrom' => ({required Object name}) => 'New message from ${name}',
_ => null,
};
}