Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions lib/core/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,12 @@ class _MostroAppState extends ConsumerState<MostroApp> {
if (!_notificationLaunchHandled && _router != null) {
_notificationLaunchHandled = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
final orderId = await getNotificationLaunchOrderId();
final payload = await getNotificationLaunchOrderId();
if (!mounted) return;
if (orderId != null && orderId.isNotEmpty) {
debugPrint('App launched from notification tap, navigating to order: $orderId');
_router!.push('/trade_detail/$orderId');
if (payload != null && payload.isNotEmpty) {
final route = resolveNotificationRoute(payload);
debugPrint('App launched from notification tap, navigating to: $route');
_router!.push(route);
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import 'package:mostro_mobile/features/key_manager/key_derivator.dart';
import 'package:mostro_mobile/features/key_manager/key_manager.dart';
import 'package:mostro_mobile/features/key_manager/key_storage.dart';
import 'package:mostro_mobile/features/notifications/utils/notification_data_extractor.dart';
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';
import 'package:mostro_mobile/features/notifications/utils/notification_message_mapper.dart';
import 'package:mostro_mobile/generated/l10n.dart';
import 'package:mostro_mobile/generated/l10n_de.dart';
Expand Down Expand Up @@ -57,6 +58,39 @@ Future<String?> getNotificationLaunchOrderId() async {
return null;
}

/// Resolves the navigation route from a notification payload string.
///
/// Returns the route path to navigate to. Pure function, no side effects.
String resolveNotificationRoute(String? payload) {
if (payload == null || payload.isEmpty) {
return '/notifications';
}

try {
final decoded = jsonDecode(payload);
if (decoded is! Map<String, dynamic>) {
throw FormatException('Payload is not a JSON object');
}

final type = decoded['type'] as String?;
final orderId = decoded['orderId'] as String?;
final disputeId = decoded['disputeId'] as String?;

if (type == 'admin_dm' && orderId != null) {
if (disputeId != null) {
return '/dispute_details/$disputeId';
}
return '/trade_detail/$orderId';
}
} on FormatException {
// Not JSON — treat as plain orderId (legacy format)
} catch (e) {
logger.e('Unexpected error parsing notification payload: $e');
}

return '/trade_detail/$payload';
}

void _onNotificationTap(NotificationResponse response) {
try {
final context = MostroApp.navigatorKey.currentContext;
Expand All @@ -65,14 +99,9 @@ void _onNotificationTap(NotificationResponse response) {
return;
}

final orderId = response.payload;
if (orderId != null && orderId.isNotEmpty) {
context.push('/trade_detail/$orderId');
logger.i('Navigated to trade detail for order: $orderId');
} else {
context.push('/notifications');
logger.i('Navigated to notifications screen');
}
final route = resolveNotificationRoute(response.payload);
context.push(route);
logger.i('Navigated to: $route');
} catch (e) {
logger.e('Navigation error: $e');
}
Expand Down Expand Up @@ -129,13 +158,18 @@ Future<void> showLocalNotification(NostrEvent event) async {
),
);

// Build payload: JSON for admin DMs, plain orderId for standard notifications
final notificationPayload = notificationData.action == mostro_action.Action.sendDm
? jsonEncode({'type': 'admin_dm', 'orderId': mostroMessage.id})
: mostroMessage.id;

// Use fixed ID (0) with tag for replacement - Android uses tag+id combo
await flutterLocalNotificationsPlugin.show(
0, // Fixed ID - tag 'mostro-trade' makes it unique and replaceable
notificationText.title,
notificationText.body,
details,
payload: mostroMessage.id,
payload: notificationPayload,
);

logger.i('Shown: ${notificationText.title} - ${notificationText.body}');
Expand All @@ -153,6 +187,17 @@ Future<MostroMessage?> _decryptAndProcessEvent(NostrEvent event) async {

final sessions = await _loadSessionsFromDatabase();

// Try matching by adminSharedKey first (dispute chat DMs)
final adminSession = sessions.cast<Session?>().firstWhere(
(s) => s?.adminSharedKey?.public == event.recipient,
orElse: () => null,
);

if (adminSession != null) {
return _processAdminDm(event, adminSession);
}

// Standard Mostro message: match by tradeKey
final matchingSession = sessions.cast<Session?>().firstWhere(
(s) => s?.tradeKey.public == event.recipient,
orElse: () => null,
Expand All @@ -172,6 +217,20 @@ Future<MostroMessage?> _decryptAndProcessEvent(NostrEvent event) async {
return null;
}

// Detect admin/dispute DM format that arrived via tradeKey
final firstItem = result[0];
if (NostrUtils.isDmPayload(firstItem)) {
if (matchingSession.orderId == null) {
logger.w('DM received but session has no orderId (recipient: ${event.recipient}), skipping notification');
return null;
}
return MostroMessage(
action: mostro_action.Action.sendDm,
id: matchingSession.orderId,
timestamp: event.createdAt?.millisecondsSinceEpoch,
);
}

final mostroMessage = MostroMessage.fromJson(result[0]);
mostroMessage.timestamp = event.createdAt?.millisecondsSinceEpoch;

Expand All @@ -182,6 +241,29 @@ Future<MostroMessage?> _decryptAndProcessEvent(NostrEvent event) async {
}
}

Future<MostroMessage?> _processAdminDm(NostrEvent event, Session session) async {
try {
final unwrapped = await event.p2pUnwrap(session.adminSharedKey!);
if (unwrapped.content == null || unwrapped.content!.isEmpty) {
return null;
}

if (session.orderId == null) {
logger.w('Admin DM received but session has no orderId (recipient: ${event.recipient}), skipping notification');
return null;
}

return MostroMessage(
action: mostro_action.Action.sendDm,
id: session.orderId,
timestamp: event.createdAt?.millisecondsSinceEpoch,
);
} catch (e) {
logger.e('Admin DM decrypt error: $e');
return null;
}
}

Future<List<Session>> _loadSessionsFromDatabase() async {
try {
final db = await openMostroDatabase('mostro.db');
Expand Down
14 changes: 11 additions & 3 deletions lib/features/notifications/utils/notification_data_extractor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -177,20 +177,28 @@ class NotificationDataExtractor {
// No additional values needed
break;

case Action.sendDm:
// Admin/dispute DM — no payload extraction needed, generic message
break;

case Action.cooperativeCancelAccepted:
// No additional values needed
break;

case Action.cantDo:
final cantDo = event.getPayload<CantDo>();
values['reason'] = cantDo?.cantDoReason.toString();
isTemporary = true; // cantDo notifications are temporary
break;

case Action.rate:
// No additional values needed
break;

case Action.rateReceived:
// This action doesn't generate notifications
return null;

default:
// Unknown actions generate temporary notifications
isTemporary = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ class NotificationMessageMapper {
case mostro.Action.cooperativeCancelAccepted:
return 'notification_cooperative_cancel_accepted_title';
case mostro.Action.sendDm:
return 'notification_new_message_title';
return 'notification_admin_message_title';
case mostro.Action.cancel:
case mostro.Action.adminCancel:
case mostro.Action.adminCanceled:
Expand Down Expand Up @@ -166,7 +166,7 @@ class NotificationMessageMapper {
case mostro.Action.cooperativeCancelAccepted:
return 'notification_cooperative_cancel_accepted_message';
case mostro.Action.sendDm:
return 'notification_new_message_message';
return 'notification_admin_message_message';
case mostro.Action.cancel:
case mostro.Action.adminCancel:
case mostro.Action.adminCanceled:
Expand Down Expand Up @@ -330,6 +330,10 @@ class NotificationMessageMapper {
return s.notification_new_message_title;
case 'notification_new_message_message':
return s.notification_new_message_message;
case 'notification_admin_message_title':
return s.notification_admin_message_title;
case 'notification_admin_message_message':
return s.notification_admin_message_message;
case 'notification_order_update_title':
return s.notification_order_update_title;
case 'notification_order_update_message':
Expand Down
18 changes: 18 additions & 0 deletions lib/features/subscriptions/subscription_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ class SubscriptionManager {

final _ordersController = StreamController<NostrEvent>.broadcast();
final _chatController = StreamController<NostrEvent>.broadcast();
final _disputeChatController = StreamController<NostrEvent>.broadcast();
final _relayListController = StreamController<RelayListEvent>.broadcast();

Stream<NostrEvent> get orders => _ordersController.stream;
Stream<NostrEvent> get chat => _chatController.stream;
Stream<NostrEvent> get disputeChat => _disputeChatController.stream;
Stream<RelayListEvent> get relayList => _relayListController.stream;

SubscriptionManager(this.ref) {
Expand Down Expand Up @@ -141,6 +143,16 @@ class SubscriptionManager {
.map((s) => s.sharedKey!.public)
.toList(),
);
case SubscriptionType.disputeChat:
final adminKeys = sessions
.where((s) => s.adminSharedKey?.public != null)
.map((s) => s.adminSharedKey!.public)
.toList();
if (adminKeys.isEmpty) return null;
return NostrFilter(
kinds: [1059],
p: adminKeys,
);
case SubscriptionType.relayList:
// Relay list subscriptions are handled separately via subscribeToMostroRelayList
return null;
Expand All @@ -156,6 +168,9 @@ class SubscriptionManager {
case SubscriptionType.chat:
_chatController.add(event);
break;
case SubscriptionType.disputeChat:
_disputeChatController.add(event);
break;
case SubscriptionType.relayList:
final relayListEvent = RelayListEvent.fromEvent(event);
if (relayListEvent != null) {
Expand Down Expand Up @@ -207,6 +222,8 @@ class SubscriptionManager {
return orders;
case SubscriptionType.chat:
return chat;
case SubscriptionType.disputeChat:
return disputeChat;
case SubscriptionType.relayList:
// RelayList subscriptions should use subscribeToMostroRelayList() instead
throw UnsupportedError('Use subscribeToMostroRelayList() for relay list subscriptions');
Expand Down Expand Up @@ -328,6 +345,7 @@ class SubscriptionManager {
unsubscribeAll();
_ordersController.close();
_chatController.close();
_disputeChatController.close();
_relayListController.close();
}
}
1 change: 1 addition & 0 deletions lib/features/subscriptions/subscription_type.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
enum SubscriptionType {
chat,
orders,
disputeChat,
relayList,
}
2 changes: 2 additions & 0 deletions lib/l10n/intl_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,8 @@
"notification_cant_do_message": "Die angeforderte Aktion kann nicht ausgeführt werden",
"notification_new_message_title": "Neue Nachricht",
"notification_new_message_message": "Du hast eine neue Nachricht erhalten",
"notification_admin_message_title": "Nachricht vom Administrator",
"notification_admin_message_message": "Du hast eine Nachricht vom Streitadministrator erhalten",
"notification_order_update_title": "Order-Update",
"notification_order_update_message": "Deine Order wurde aktualisiert",
"@_comment_notification_details": "Labels für Benachrichtigungsdetails",
Expand Down
2 changes: 2 additions & 0 deletions lib/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,8 @@
"notification_cant_do_message": "The requested action cannot be performed",
"notification_new_message_title": "New message",
"notification_new_message_message": "You have received a new message",
"notification_admin_message_title": "Message from admin",
"notification_admin_message_message": "You have received a message from the dispute admin",
"notification_order_update_title": "Order update",
"notification_order_update_message": "Your order has been updated",
"@_comment_notification_details": "Notification details labels",
Expand Down
2 changes: 2 additions & 0 deletions lib/l10n/intl_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,8 @@
"notification_cant_do_message": "La acción solicitada no se puede realizar",
"notification_new_message_title": "Nuevo mensaje",
"notification_new_message_message": "Has recibido un nuevo mensaje",
"notification_admin_message_title": "Mensaje del administrador",
"notification_admin_message_message": "Has recibido un mensaje del administrador de la disputa",
"notification_order_update_title": "Actualización de orden",
"notification_order_update_message": "Tu orden ha sido actualizada",
"@_comment_notification_details": "Etiquetas de detalles de notificaciones",
Expand Down
2 changes: 2 additions & 0 deletions lib/l10n/intl_fr.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,8 @@
"notification_cant_do_message": "L'action demandée ne peut pas être effectuée",
"notification_new_message_title": "Nouveau message",
"notification_new_message_message": "Vous avez reçu un nouveau message",
"notification_admin_message_title": "Message de l'administrateur",
"notification_admin_message_message": "Vous avez reçu un message de l'administrateur du litige",
"notification_order_update_title": "Mise à jour de commande",
"notification_order_update_message": "Votre commande a été mise à jour",
"@_comment_notification_details": "Notification details labels",
Expand Down
2 changes: 2 additions & 0 deletions lib/l10n/intl_it.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,8 @@
"notification_cant_do_message": "L'azione richiesta non può essere eseguita",
"notification_new_message_title": "Nuovo messaggio",
"notification_new_message_message": "Hai ricevuto un nuovo messaggio",
"notification_admin_message_title": "Messaggio dall'amministratore",
"notification_admin_message_message": "Hai ricevuto un messaggio dall'amministratore della disputa",
"notification_order_update_title": "Aggiornamento ordine",
"notification_order_update_message": "Il tuo ordine è stato aggiornato",
"@_comment_notification_details": "Etichette dei dettagli delle notifiche",
Expand Down
10 changes: 10 additions & 0 deletions lib/services/mostro_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'package:mostro_mobile/features/settings/settings_provider.dart';
import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart';
import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart';
import 'package:mostro_mobile/features/mostro/mostro_instance.dart';
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';

class MostroService {
final Ref ref;
Expand Down Expand Up @@ -105,6 +106,8 @@ class MostroService {
final eventStore = ref.read(eventStorageProvider);

if (await eventStore.hasItem(event.id!)) return;

// Reserve event ID immediately to prevent duplicate processing from multiple relays
await eventStore.putItem(event.id!, {
'id': event.id,
'created_at': event.createdAt!.millisecondsSinceEpoch ~/ 1000,
Expand Down Expand Up @@ -133,6 +136,13 @@ class MostroService {
return;
}

// Skip dispute chat DMs — DisputeChatNotifier handles these
// via its own adminSharedKey subscription
if (NostrUtils.isDmPayload(result[0])) {
logger.i('Skipping dispute chat message (handled by DisputeChatNotifier)');
return;
}

// Skip restore-specific payloads that arrive as historical events due to temporary subscription
if (result[0] is Map &&
_isRestorePayload(result[0] as Map<String, dynamic>)) {
Expand Down
13 changes: 13 additions & 0 deletions lib/shared/utils/nostr_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,19 @@ class NostrUtils {
}
}

/// Checks if a decoded Mostro payload item is a DM (dispute/admin chat) message.
/// DM messages use the format: {"dm": {"action": "send-dm", "payload": {...}}}
/// Validates shape strictly to avoid false positives from other payloads.
static bool isDmPayload(dynamic item) {
if (item is! Map) return false;
final dm = item['dm'];
if (dm is! Map) return false;
if (dm['action'] != 'send-dm') return false;
final payload = dm['payload'];
if (payload is! Map) return false;
return true;
}

static Future<String> encryptNIP44(
String content,
String privkey,
Expand Down
Loading
Loading