diff --git a/docs/DISPUTE_CHAT_MULTIMEDIA_PLAN.md b/docs/DISPUTE_CHAT_MULTIMEDIA_PLAN.md index cba2d32d..d32b8780 100644 --- a/docs/DISPUTE_CHAT_MULTIMEDIA_PLAN.md +++ b/docs/DISPUTE_CHAT_MULTIMEDIA_PLAN.md @@ -8,13 +8,14 @@ Currently the app has two completely different chat mechanisms: | | P2P Chat (User-User) | Dispute Chat (User-Admin) | |-|----------------------|--------------------------| -| **Wrap** | `p2pWrap()` — 1 layer | `mostroWrap()` — 3 layers (Rumor→Seal→Wrap) | -| **Key** | Shared key (ECDH) | Admin pubkey (direct) | -| **Routing (`p` tag)** | Shared key pubkey | Trade key / Admin pubkey | +| **Wrap** | `p2pWrap()` — 1 layer | `p2pWrap()` — 1 layer *(Phase 1)* | +| **Key** | Shared key (ECDH) | Admin shared key (ECDH) *(Phase 1)* | +| **Routing (`p` tag)** | Shared key pubkey | Admin shared key pubkey *(Phase 1)* | | **Subscription** | `SubscriptionManager.chat` | Independent subscription | -| **Message model** | `NostrEvent` | `DisputeChat` (plain String) | -| **Content format** | Plain text / JSON | MostroMessage JSON | -| **Multimedia** | Images + files via Blossom | Not supported | +| **Message model** | `NostrEvent` | `DisputeChatMessage(NostrEvent)` *(Phase 2)* | +| **Storage** | Gift wrap (encrypted) | Gift wrap (encrypted) *(Phase 2)* | +| **Content format** | Plain text / JSON | Plain text *(Phase 1)* | +| **Multimedia** | Images + files via Blossom | Not supported yet | | **Message bubble** | `MessageBubble` (routes text/image/file) | `DisputeMessageBubble` (text only) | | **Input widget** | `MessageInput` (text + attachment) | `DisputeMessageInput` (text only) | @@ -139,7 +140,7 @@ The admin/solver needs to implement the counterpart: --- ## Phase 2: Unify Message Model -**Status:** `TODO` +**Status:** `DONE` **PR scope:** Internal refactor — model and notifier changes, no visible UI change **Depends on:** Phase 1 completed @@ -674,4 +675,4 @@ group('MediaCacheMixin') --- -*Last updated: 2026-02-16* +*Last updated: 2026-02-25* diff --git a/lib/features/disputes/notifiers/dispute_chat_notifier.dart b/lib/features/disputes/notifiers/dispute_chat_notifier.dart index b757de4a..d41f317f 100644 --- a/lib/features/disputes/notifiers/dispute_chat_notifier.dart +++ b/lib/features/disputes/notifiers/dispute_chat_notifier.dart @@ -1,8 +1,8 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/services/logger_service.dart'; -import 'package:mostro_mobile/data/models/dispute_chat.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; @@ -11,9 +11,38 @@ import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; import 'package:sembast/sembast.dart'; +/// Thin wrapper around NostrEvent with UI-only pending/error state +class DisputeChatMessage { + final NostrEvent event; + final bool isPending; + final String? error; + + const DisputeChatMessage({ + required this.event, + this.isPending = false, + this.error, + }); + + String get id => event.id ?? ''; + String get content => event.content ?? ''; + DateTime get timestamp => event.createdAt ?? DateTime.now(); + + DisputeChatMessage copyWith({ + NostrEvent? event, + bool? isPending, + String? error, + }) { + return DisputeChatMessage( + event: event ?? this.event, + isPending: isPending ?? this.isPending, + error: error ?? this.error, + ); + } +} + /// State for dispute chat messages class DisputeChatState { - final List messages; + final List messages; final bool isLoading; final String? error; @@ -24,7 +53,7 @@ class DisputeChatState { }); DisputeChatState copyWith({ - List? messages, + List? messages, bool? isLoading, String? error, }) { @@ -37,7 +66,8 @@ class DisputeChatState { } /// Notifier for dispute chat messages -/// Uses shared key encryption (p2pWrap/p2pUnwrap) with admin via ECDH +/// Uses shared key encryption (p2pWrap/p2pUnwrap) with admin via ECDH. +/// Stores gift wrap events (encrypted) on disk, same pattern as P2P chat. class DisputeChatNotifier extends StateNotifier { final String disputeId; final Ref ref; @@ -118,17 +148,14 @@ class DisputeChatNotifier extends StateNotifier { ); } - /// Handle incoming chat events via p2pUnwrap + /// Handle incoming chat events via p2pUnwrap. + /// Stores the gift wrap event (encrypted) to disk, then unwraps for display. void _onChatEvent(NostrEvent event) async { try { - if (event.kind != 1059) { - return; - } + if (event.kind != 1059) return; final session = _getSessionForDispute(); - if (session == null || session.adminSharedKey == null) { - return; - } + if (session == null || session.adminSharedKey == null) return; // Verify p tag matches admin shared key final pTag = event.tags?.firstWhere( @@ -139,80 +166,70 @@ class DisputeChatNotifier extends StateNotifier { return; } - // Check for duplicate events + // Check for duplicate gift wrap events final wrapperEventId = event.id; if (wrapperEventId == null) return; final eventStore = ref.read(eventStorageProvider); - if (await eventStore.hasItem(wrapperEventId)) { - return; - } + if (await eventStore.hasItem(wrapperEventId)) return; + + // Store the gift wrap event (encrypted) to disk — same pattern as P2P chat + await eventStore.putItem( + wrapperEventId, + { + 'id': wrapperEventId, + 'created_at': event.createdAt!.millisecondsSinceEpoch ~/ 1000, + 'kind': event.kind, + 'content': event.content, + 'pubkey': event.pubkey, + 'sig': event.sig, + 'tags': event.tags, + 'type': 'dispute_chat', + 'dispute_id': disputeId, + }, + ); // Unwrap using admin shared key (1-layer p2p decryption) final unwrappedEvent = await event.p2pUnwrap(session.adminSharedKey!); - // Content is plain text (no MostroMessage JSON wrapper) + // SECURITY: The ECDH shared key IS the authentication. + // If p2pUnwrap succeeded, the sender holds the admin's private key. + final messageText = unwrappedEvent.content ?? ''; if (messageText.isEmpty) { logger.w('Received empty message, skipping'); return; } - final senderPubkey = unwrappedEvent.pubkey; - final isFromAdmin = senderPubkey != session.tradeKey.public; - - // SECURITY: The ECDH shared key IS the authentication. - // If p2pUnwrap succeeded, the sender holds the admin's private key. - // No additional pubkey verification needed — it would introduce a race - // condition with disputeDetailsProvider resolution. - - final eventId = unwrappedEvent.id ?? event.id ?? 'chat_${DateTime.now().millisecondsSinceEpoch}_${messageText.hashCode}'; - final eventTimestamp = unwrappedEvent.createdAt ?? DateTime.now(); + final isFromAdmin = unwrappedEvent.pubkey != session.tradeKey.public; + final message = DisputeChatMessage(event: unwrappedEvent); - // Store the event - await eventStore.putItem( - eventId, - { - 'id': eventId, - 'content': messageText, - 'created_at': eventTimestamp.millisecondsSinceEpoch ~/ 1000, - 'kind': unwrappedEvent.kind, - 'pubkey': senderPubkey, - 'sig': unwrappedEvent.sig, - 'tags': unwrappedEvent.tags, - 'type': 'dispute_chat', - 'dispute_id': disputeId, - 'is_from_user': !isFromAdmin, - 'isPending': false, - }, - ); - - // Add to state - final disputeChat = DisputeChat( - id: eventId, - message: messageText, - timestamp: eventTimestamp, - isFromUser: !isFromAdmin, - adminPubkey: isFromAdmin ? senderPubkey : null, - isPending: false, - ); - - final allMessages = [...state.messages, disputeChat]; + // Dedup by inner event ID (handles relay echo of sent messages) + final allMessages = [...state.messages, message]; final deduped = {for (var m in allMessages) m.id: m}.values.toList(); deduped.sort((a, b) => a.timestamp.compareTo(b.timestamp)); state = state.copyWith(messages: deduped); - logger.i('Added dispute chat message for dispute: $disputeId (from ${isFromAdmin ? "admin" : "user"})'); + logger.i('Added dispute chat message for dispute: $disputeId ' + '(from ${isFromAdmin ? "admin" : "user"})'); } catch (e, stackTrace) { logger.e('Error processing dispute chat event: $e', stackTrace: stackTrace); } } - /// Load historical messages from storage + /// Load historical messages from storage. + /// Reconstructs gift wrap events and unwraps them with adminSharedKey. Future _loadHistoricalMessages() async { try { logger.i('Loading historical messages for dispute: $disputeId'); state = state.copyWith(isLoading: true); + final session = _getSessionForDispute(); + if (session == null || session.adminSharedKey == null) { + logger.i('Admin shared key not available, skipping historical load'); + state = state.copyWith(isLoading: false); + return; + } + final eventStore = ref.read(eventStorageProvider); // Find all dispute chat events for this dispute @@ -226,36 +243,59 @@ class DisputeChatNotifier extends StateNotifier { logger.i('Found ${chatEvents.length} historical messages for dispute: $disputeId'); - // SECURITY: Historical messages were already authenticated via ECDH - // when they were received and stored. No additional pubkey filtering needed. - final List messages = []; + final List messages = []; for (final eventData in chatEvents) { try { - messages.add(DisputeChat( - id: eventData['id'] as String, - message: eventData['content'] as String? ?? '', - timestamp: DateTime.fromMillisecondsSinceEpoch( - (eventData['created_at'] as int) * 1000, - ), - isFromUser: eventData['is_from_user'] as bool? ?? true, - adminPubkey: eventData['admin_pubkey'] as String?, - isPending: eventData['isPending'] as bool? ?? false, - error: eventData['error'] as String?, - )); + // Check if this is a complete gift wrap event (has all required fields) + final hasCompleteData = eventData.containsKey('kind') && + eventData.containsKey('content') && + eventData.containsKey('pubkey') && + eventData.containsKey('sig') && + eventData.containsKey('tags'); + + if (!hasCompleteData) { + logger.w('Event ${eventData['id']} is incomplete, skipping'); + continue; + } + + // Reconstruct the gift wrap NostrEvent from stored data + final storedEvent = NostrEventExtensions.fromMap({ + 'id': eventData['id'], + 'created_at': eventData['created_at'], + 'kind': eventData['kind'], + 'content': eventData['content'], + 'pubkey': eventData['pubkey'], + 'sig': eventData['sig'], + 'tags': eventData['tags'], + }); + + // Verify p tag matches our admin shared key + if (session.adminSharedKey!.public != storedEvent.recipient) { + continue; + } + + // Decrypt and unwrap the message + final unwrappedEvent = await storedEvent.p2pUnwrap(session.adminSharedKey!); + messages.add(DisputeChatMessage(event: unwrappedEvent)); } catch (e) { - logger.w('Failed to parse dispute chat message: $e'); + logger.w('Failed to process historical dispute event ${eventData['id']}: $e'); } } - state = state.copyWith(messages: messages, isLoading: false); + // Dedup by inner event ID + final deduped = {for (var m in messages) m.id: m}.values.toList(); + deduped.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + + state = state.copyWith(messages: deduped, isLoading: false); } catch (e, stackTrace) { logger.e('Error loading historical messages: $e', stackTrace: stackTrace); state = state.copyWith(isLoading: false, error: e.toString()); } } - /// Send a message in the dispute chat using p2pWrap with admin shared key + /// Send a message in the dispute chat using p2pWrap with admin shared key. + /// Stores the gift wrap event (encrypted) on success. Future sendMessage(String text) async { final session = _getSessionForDispute(); if (session == null) { @@ -268,12 +308,6 @@ class DisputeChatNotifier extends StateNotifier { return; } - final orderId = session.orderId; - if (orderId == null) { - logger.w('Cannot send message: Session orderId is null'); - return; - } - // Create rumor (kind 1) with plain text content FIRST to get real event ID final rumor = NostrEvent.fromPartialData( keyPairs: session.tradeKey, @@ -290,20 +324,11 @@ class DisputeChatNotifier extends StateNotifier { state = state.copyWith(error: 'Failed to prepare message'); return; } - final rumorTimestamp = rumor.createdAt ?? DateTime.now(); try { - logger.i('Sending p2pWrap DM to admin via shared key for dispute: $disputeId'); - // Add message to state with isPending=true (optimistic UI) // Uses the real rumor ID so relay echo deduplication works correctly - final pendingMessage = DisputeChat( - id: rumorId, - message: text, - timestamp: rumorTimestamp, - isFromUser: true, - isPending: true, - ); + final pendingMessage = DisputeChatMessage(event: rumor, isPending: true); final allMessages = [...state.messages, pendingMessage]; final deduped = {for (var m in allMessages) m.id: m}.values.toList(); @@ -316,110 +341,84 @@ class DisputeChatNotifier extends StateNotifier { session.adminSharedKey!.public, ); - logger.i('Sending p2pWrap from ${session.tradeKey.public} via shared key ${session.adminSharedKey!.public}'); - // Publish to network try { await ref.read(nostrServiceProvider).publishEvent(wrappedEvent); logger.i('Dispute message sent successfully for dispute: $disputeId'); } catch (publishError, publishStack) { - logger.e('Failed to publish dispute message: $publishError', stackTrace: publishStack); - - final failedMessage = pendingMessage.copyWith( - isPending: false, - error: 'Failed to publish: $publishError', - ); - final updatedMessages = state.messages.map((m) => m.id == rumorId ? failedMessage : m).toList(); - state = state.copyWith( - messages: updatedMessages, - error: 'Failed to send message: $publishError', - ); - - final eventStore = ref.read(eventStorageProvider); - try { - await eventStore.putItem( - rumorId, - { - 'id': rumorId, - 'content': text, - 'created_at': rumorTimestamp.millisecondsSinceEpoch ~/ 1000, - 'kind': rumor.kind, - 'pubkey': rumor.pubkey, - 'type': 'dispute_chat', - 'dispute_id': disputeId, - 'is_from_user': true, - 'isPending': false, - 'error': 'Failed to publish: $publishError', - }, - ); - } catch (storageError) { - logger.e('Failed to store error state: $storageError'); - } + logger.e('Failed to publish dispute message: $publishError', + stackTrace: publishStack); + _updateMessageState(rumorId, isPending: false, error: 'Failed to publish: $publishError'); return; } - // Update message to isPending=false (success) - final sentMessage = pendingMessage.copyWith(isPending: false); - final updatedMessages = state.messages.map((m) => m.id == rumorId ? sentMessage : m).toList(); - state = state.copyWith(messages: updatedMessages); - - // Store in local storage + // On success: store the gift wrap event (encrypted) to disk final eventStore = ref.read(eventStorageProvider); await eventStore.putItem( - rumorId, + wrappedEvent.id!, { - 'id': rumorId, - 'content': text, - 'created_at': rumorTimestamp.millisecondsSinceEpoch ~/ 1000, - 'kind': rumor.kind, - 'pubkey': rumor.pubkey, - 'sig': rumor.sig, - 'tags': rumor.tags, + 'id': wrappedEvent.id, + 'created_at': wrappedEvent.createdAt!.millisecondsSinceEpoch ~/ 1000, + 'kind': wrappedEvent.kind, + 'content': wrappedEvent.content, + 'pubkey': wrappedEvent.pubkey, + 'sig': wrappedEvent.sig, + 'tags': wrappedEvent.tags, 'type': 'dispute_chat', 'dispute_id': disputeId, - 'is_from_user': true, - 'isPending': false, }, ); + + // Update message to isPending=false (success) + _updateMessageState(rumorId, isPending: false); } catch (e, stackTrace) { logger.e('Failed to send dispute message: $e', stackTrace: stackTrace); + _updateMessageState(rumorId, isPending: false, error: e.toString()); + } + } - final failedMessage = state.messages - .firstWhere((m) => m.id == rumorId, orElse: () => DisputeChat( - id: rumorId, - message: text, - timestamp: rumorTimestamp, - isFromUser: true, - )) - .copyWith(isPending: false, error: e.toString()); - - final updatedMessages = state.messages.map((m) => m.id == rumorId ? failedMessage : m).toList(); - state = state.copyWith( - messages: updatedMessages, - error: 'Failed to send message: $e', - ); - - final eventStore = ref.read(eventStorageProvider); - try { - await eventStore.putItem( - rumorId, - { - 'id': rumorId, - 'content': text, - 'created_at': rumorTimestamp.millisecondsSinceEpoch ~/ 1000, - 'kind': 1, - 'pubkey': session.tradeKey.public, - 'type': 'dispute_chat', - 'dispute_id': disputeId, - 'is_from_user': true, - 'isPending': false, - 'error': e.toString(), - }, + /// Update a message's pending/error state in the current state. + /// Per-message errors stay at message level; state.error is reserved + /// for initialization/loading failures only. + void _updateMessageState(String messageId, {required bool isPending, String? error}) { + final updatedMessages = state.messages.map((m) { + if (m.id == messageId) { + return DisputeChatMessage( + event: m.event, + isPending: isPending, + error: error, ); - } catch (storageError) { - logger.e('Failed to store error state: $storageError'); } + return m; + }).toList(); + state = state.copyWith(messages: updatedMessages); + } + + /// Get the admin shared key as raw bytes for multimedia encryption + Future getAdminSharedKey() async { + final session = _getSessionForDispute(); + if (session == null || session.adminSharedKey == null) { + throw Exception('Admin shared key not available for dispute: $disputeId'); + } + + final hexKey = session.adminSharedKey!.private; + if (hexKey.length != 64) { + throw Exception('Invalid admin shared key length: expected 64 hex chars, ' + 'got ${hexKey.length}'); } + + final bytes = Uint8List(32); + for (int i = 0; i < 32; i++) { + bytes[i] = int.parse(hexKey.substring(i * 2, i * 2 + 2), radix: 16); + } + return bytes; + } + + /// Determine if a message is from the current user + bool isFromUser(DisputeChatMessage message) { + final session = _getSessionForDispute(); + if (session == null) return false; + return message.event.pubkey == session.tradeKey.public; } /// Get the session for this dispute diff --git a/lib/features/disputes/widgets/dispute_content.dart b/lib/features/disputes/widgets/dispute_content.dart index 32bd3e73..ea0d76ba 100644 --- a/lib/features/disputes/widgets/dispute_content.dart +++ b/lib/features/disputes/widgets/dispute_content.dart @@ -42,7 +42,7 @@ class _DisputeContentState extends ConsumerState { if (messages.isNotEmpty) { // Show the last message final lastMessage = messages.last; - descriptionText = lastMessage.message; + descriptionText = lastMessage.content; } } @@ -61,6 +61,9 @@ class _DisputeContentState extends ConsumerState { future: DisputeReadStatusService.hasUnreadMessages( widget.dispute.disputeId, ref.watch(disputeChatNotifierProvider(widget.dispute.disputeId)).messages, + isFromUser: ref.read( + disputeChatNotifierProvider(widget.dispute.disputeId).notifier, + ).isFromUser, ), builder: (context, snapshot) { final hasUnread = snapshot.data ?? false; diff --git a/lib/features/disputes/widgets/dispute_messages_list.dart b/lib/features/disputes/widgets/dispute_messages_list.dart index e3ce9ab9..9c5b1a8a 100644 --- a/lib/features/disputes/widgets/dispute_messages_list.dart +++ b/lib/features/disputes/widgets/dispute_messages_list.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/dispute_chat.dart'; import 'package:mostro_mobile/data/models/dispute.dart'; import 'package:mostro_mobile/features/disputes/notifiers/dispute_chat_notifier.dart'; import 'package:mostro_mobile/features/disputes/widgets/dispute_message_bubble.dart'; @@ -141,11 +140,12 @@ class _DisputeMessagesListState extends ConsumerState { case _ListItemType.message: final message = messages[itemInfo.messageIndex!]; + final notifier = ref.read( + disputeChatNotifierProvider(widget.disputeId).notifier); return DisputeMessageBubble( - message: message.message, - isFromUser: message.isFromUser, + message: message.content, + isFromUser: notifier.isFromUser(message), timestamp: message.timestamp, - adminPubkey: message.adminPubkey, ); } }, @@ -159,7 +159,7 @@ class _DisputeMessagesListState extends ConsumerState { /// Determine the type of item at the given index /// Returns a record with the item type and optional message index - ({_ListItemType type, int? messageIndex}) _getItemType(int index, List messages) { + ({_ListItemType type, int? messageIndex}) _getItemType(int index, List messages) { if (index == 0) { return (type: _ListItemType.infoCard, messageIndex: null); } @@ -175,7 +175,7 @@ class _DisputeMessagesListState extends ConsumerState { } /// Build layout for when there are no messages - with scrolling support - Widget _buildEmptyMessagesLayout(BuildContext context, List messages) { + Widget _buildEmptyMessagesLayout(BuildContext context, List messages) { final isResolvedStatus = _isResolvedStatus(widget.status); return LayoutBuilder( @@ -254,7 +254,7 @@ class _DisputeMessagesListState extends ConsumerState { ); } - Widget _buildAdminAssignmentNotification(BuildContext context, List messages) { + Widget _buildAdminAssignmentNotification(BuildContext context, List messages) { // Only show admin assignment notification for 'in-progress' status // AND only when there are no messages yet // Don't show for 'initiated' (no admin yet) or 'resolved' (dispute finished) @@ -331,7 +331,7 @@ class _DisputeMessagesListState extends ConsumerState { } /// Get the total item count for ListView (info card + messages + chat closed message if needed) - int _getItemCount(List messages) { + int _getItemCount(List messages) { int count = 1; // Always include info card count += messages.length; // Add messages diff --git a/lib/services/dispute_read_status_service.dart b/lib/services/dispute_read_status_service.dart index 992c2fd1..2de9ac14 100644 --- a/lib/services/dispute_read_status_service.dart +++ b/lib/services/dispute_read_status_service.dart @@ -1,5 +1,5 @@ import 'package:shared_preferences/shared_preferences.dart'; -import 'package:mostro_mobile/data/models/dispute_chat.dart'; +import 'package:mostro_mobile/features/disputes/notifiers/dispute_chat_notifier.dart'; class DisputeReadStatusService { static const String _keyPrefix = 'dispute_last_read_'; @@ -21,26 +21,30 @@ class DisputeReadStatusService { /// Check if there are unread messages in a dispute chat /// Returns true if any messages (from admin or peer) are newer than the last read timestamp - static Future hasUnreadMessages(String disputeId, List messages) async { + static Future hasUnreadMessages( + String disputeId, + List messages, { + required bool Function(DisputeChatMessage) isFromUser, + }) async { final lastReadTime = await getLastReadTime(disputeId); - + // If no read time is stored, consider all non-user messages as unread if (lastReadTime == null) { - return messages.any((message) => !message.isFromUser); + return messages.any((message) => !isFromUser(message)); } // Check if any non-user messages are newer than the last read time for (final message in messages) { // Skip messages from the current user - if (message.isFromUser) continue; - + if (isFromUser(message)) continue; + // Check if message timestamp is newer than last read time final messageTime = message.timestamp.millisecondsSinceEpoch; if (messageTime > lastReadTime) { return true; } } - + return false; } }