Merge branch 'claude/awesome-golick-007e7d': message unread badges + OS notifications
# Conflicts: # apps/app_seeds/lib/di/injector.dart # apps/app_seeds/lib/i18n/ast.i18n.json # apps/app_seeds/lib/i18n/en.i18n.json # apps/app_seeds/lib/i18n/es.i18n.json # apps/app_seeds/lib/i18n/pt.i18n.json # apps/app_seeds/lib/i18n/strings.g.dart # apps/app_seeds/lib/i18n/strings_ast.g.dart # apps/app_seeds/lib/i18n/strings_en.g.dart # apps/app_seeds/lib/i18n/strings_es.g.dart # apps/app_seeds/lib/i18n/strings_pt.g.dart # apps/app_seeds/lib/ui/chat_screen.dart
This commit is contained in:
commit
48db8fa7c8
27 changed files with 1238 additions and 114 deletions
|
|
@ -4,10 +4,13 @@ import 'package:commons_core/commons_core.dart';
|
|||
import 'package:connectivity_plus/connectivity_plus.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_service.dart';
|
||||
import 'social_settings.dart';
|
||||
import 'unread_service.dart';
|
||||
|
||||
/// App-wide inbox listener for private messages (NIP-17).
|
||||
///
|
||||
|
|
@ -26,15 +29,21 @@ class InboxService {
|
|||
required SocialSettings settings,
|
||||
required MessageStore store,
|
||||
ProfileCache? profileCache,
|
||||
UnreadService? unread,
|
||||
NotificationService? notifications,
|
||||
}) : _social = social,
|
||||
_settings = settings,
|
||||
_store = store,
|
||||
_profileCache = profileCache;
|
||||
_profileCache = profileCache,
|
||||
_unread = unread,
|
||||
_notifications = notifications;
|
||||
|
||||
final SocialService _social;
|
||||
final SocialSettings _settings;
|
||||
final MessageStore _store;
|
||||
final ProfileCache? _profileCache;
|
||||
final UnreadService? _unread;
|
||||
final NotificationService? _notifications;
|
||||
|
||||
final _changes = StreamController<void>.broadcast();
|
||||
SocialSession? _session;
|
||||
|
|
@ -101,7 +110,29 @@ class InboxService {
|
|||
final stored = await _store.append(message.fromPubkey, message);
|
||||
if (!stored) return; // a re-delivered duplicate
|
||||
if (!_changes.isClosed) _changes.add(null);
|
||||
await _cacheName(message.fromPubkey);
|
||||
|
||||
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, so this shouldn't happen — the
|
||||
// guard just keeps the seam honest.
|
||||
if (peer != _social.publicKeyHex) {
|
||||
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 {
|
||||
|
|
|
|||
103
apps/app_seeds/lib/services/notification_service.dart
Normal file
103
apps/app_seeds/lib/services/notification_service.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
/// Shows an OS notification when a private message arrives while the app is in
|
||||
/// the foreground. Foreground only by design — when the app is backgrounded the
|
||||
/// message listener stops, so nothing fires (background/push is a later, larger
|
||||
/// concern). Web and Windows have no local-notification support, so there this
|
||||
/// whole service is an inert no-op.
|
||||
///
|
||||
/// For privacy it never carries message text — the caller passes a generic
|
||||
/// `New message from <name>` title (see [InboxService]). The peer's pubkey rides
|
||||
/// along as the payload so a tap can open that exact chat via [onTapChat].
|
||||
class NotificationService {
|
||||
NotificationService({
|
||||
FlutterLocalNotificationsPlugin? plugin,
|
||||
bool? supported,
|
||||
}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(),
|
||||
_supported = supported ?? _platformSupported;
|
||||
|
||||
final FlutterLocalNotificationsPlugin _plugin;
|
||||
final bool _supported;
|
||||
bool _ready = false;
|
||||
|
||||
/// Called with a peer pubkey when the user taps a message notification. The
|
||||
/// app wires this to `router.push('/chat/<pubkey>')` once the router exists.
|
||||
void Function(String peerPubkey)? onTapChat;
|
||||
|
||||
static const _channelId = 'messages';
|
||||
|
||||
static bool get _platformSupported {
|
||||
if (kIsWeb) return false;
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
case TargetPlatform.linux:
|
||||
return true;
|
||||
case TargetPlatform.windows:
|
||||
case TargetPlatform.fuchsia:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialises the plugin and requests permission (Android 13+/iOS/macOS ask
|
||||
/// at runtime). Idempotent and safe to call on any platform.
|
||||
Future<void> initialize() async {
|
||||
if (!_supported || _ready) return;
|
||||
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const darwin = DarwinInitializationSettings();
|
||||
const linux = LinuxInitializationSettings(defaultActionName: 'Open');
|
||||
await _plugin.initialize(
|
||||
const InitializationSettings(
|
||||
android: android,
|
||||
iOS: darwin,
|
||||
macOS: darwin,
|
||||
linux: linux,
|
||||
),
|
||||
onDidReceiveNotificationResponse: (response) {
|
||||
final payload = response.payload;
|
||||
if (payload != null && payload.isNotEmpty) onTapChat?.call(payload);
|
||||
},
|
||||
);
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
_ready = true;
|
||||
}
|
||||
|
||||
/// Shows a notification for a new message. [title] is a generic, text-free
|
||||
/// line like "New message from Alice"; [peerPubkey] becomes the tap payload.
|
||||
/// No-op on unsupported platforms.
|
||||
Future<void> showMessage({
|
||||
required String peerPubkey,
|
||||
required String title,
|
||||
}) async {
|
||||
if (!_supported) return;
|
||||
const details = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_channelId,
|
||||
'Messages',
|
||||
channelDescription: 'New private messages',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(),
|
||||
macOS: DarwinNotificationDetails(),
|
||||
linux: LinuxNotificationDetails(),
|
||||
);
|
||||
// One notification per peer (same id replaces the previous), so a chatty
|
||||
// peer doesn't stack a wall of notifications.
|
||||
await _plugin.show(peerPubkey.hashCode, title, null, details,
|
||||
payload: peerPubkey);
|
||||
}
|
||||
}
|
||||
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