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
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue