diff --git a/.specify/v1-reference/DISPUTE_SYSTEM.md b/.specify/v1-reference/DISPUTE_SYSTEM.md new file mode 100644 index 00000000..3eea218e --- /dev/null +++ b/.specify/v1-reference/DISPUTE_SYSTEM.md @@ -0,0 +1,208 @@ +# Dispute System Specification (Mostro Mobile v1) + +> Reference for section 8 (DISPUTES) covering the in-app dispute list, admin chat, and dispute creation pipeline. + +## Scope + +- Route `/dispute_details/:disputeId` +- Screens & widgets: `ChatRoomsScreen` (disputes tab), `DisputesList`, `DisputeListItem`, `DisputeChatScreen`, `DisputeMessagesList`, `DisputeInfoCard`, `DisputeMessageInput` +- Providers & services: `chatTabProvider`, `userDisputeDataProvider`, `disputeDetailsProvider`, `disputeChatNotifierProvider`, `disputeReadStatusProvider`, `DisputeRepository`, `DisputeReadStatusService` +- Data models: `Dispute`, `DisputeData`, `Session.adminSharedKey` +- Storage & transport: Sembast event store (`type: dispute_chat`), NIP-59 gift wrap, admin shared-key ECDH + +## Source files reviewed + +- `lib/features/chat/screens/chat_rooms_list.dart` +- `lib/features/chat/widgets/chat_tabs.dart` +- `lib/features/disputes/widgets/disputes_list.dart` +- `lib/features/disputes/widgets/dispute_list_item.dart` +- `lib/features/disputes/widgets/dispute_content.dart` +- `lib/features/disputes/widgets/dispute_info_card.dart` +- `lib/features/disputes/widgets/dispute_messages_list.dart` +- `lib/features/disputes/widgets/dispute_message_input.dart` +- `lib/features/disputes/widgets/dispute_message_bubble.dart` +- `lib/features/disputes/screens/dispute_chat_screen.dart` +- `lib/features/disputes/notifiers/dispute_chat_notifier.dart` +- `lib/features/disputes/providers/dispute_providers.dart` +- `lib/features/disputes/providers/dispute_read_status_provider.dart` +- `lib/services/dispute_read_status_service.dart` +- `lib/data/repositories/dispute_repository.dart` +- `lib/data/models/dispute.dart` +- `lib/features/order/models/order_state.dart` +- `lib/data/models/session.dart` +- `lib/features/trades/screens/trade_detail_screen.dart` + +--- + +## 1) Entry points & navigation + +### Chat tab integration (`/chat_list`) +- `ChatRoomsScreen` renders a two-tab layout via `ChatTabs`. Tabs map to `ChatTabType.messages` (P2P chat) and `ChatTabType.disputes`. +- Switching tabs updates `chatTabProvider`; horizontal swipes also toggle tabs. +- The disputes tab swaps the main list for `DisputesList`, so users can reach disputes without leaving the chat module. +- A short contextual description is displayed under the tabs ("Aquí están tus disputas" vs "Aquí están tus chats"). + +### Trade Detail → Dispute button +- `TradeDetailScreen` surfaces a **Dispute** button whenever the action set contains `actions.Action.dispute` and no dispute is already in progress. +- Tapping Dispute prompts a confirmation dialog; if confirmed, it calls `DisputeRepository.createDispute(orderId)` (see §2) and shows a snackbar on success/failure. +- Once a dispute exists (`tradeState.dispute?.disputeId != null`), the CTA switches to **View dispute** which links to `/dispute_details/:disputeId`. + +### Automatic navigation via Mostro events +- `AbstractMostroNotifier` listens to Mostro DM actions: + - `disputeInitiatedByYou`, `disputeInitiatedByPeer`, `adminTookDispute`, `adminSettled`, `adminCanceled` all push `/trade_detail/:orderId` to keep both parties on the trade screen when a dispute state changes. + - Admin assignment (`adminTookDispute`) also updates the session's admin shared key (see §3), enabling the dispute chat to decrypt admin messages. + +--- + +## 2) Repository, data model, and dispute creation + +### `DisputeRepository` +- `createDispute(orderId)` builds a `MostroMessage(action: Action.dispute, id: orderId)` and wraps it using NIP-59 gift wrap with the user's `tradeKey` and the configured Mostro pubkey (`settingsProvider.mostroPublicKey`). +- Proof-of-work difficulty uses `MostroInstance.pow`; if unavailable it logs a warning and sends with difficulty 0. +- `NostrService.publishEvent` broadcasts the wrapped event; the repository returns `true/false` to show snackbars. +- `getUserDisputes()` and `getDispute(disputeId)` never hit a remote endpoint. They walk all `sessionNotifierProvider` sessions, read each `orderNotifierProvider(orderId)` and collect `OrderState.dispute` objects. + +### Data types +- `Dispute` carries protocol-level fields (IDs, status, admin pubkey, timestamps, action) and implements `Payload` so Mostro DMs can embed it. +- `DisputeData` is the UI view model (order ID, counterparty, user role, `DisputeDescriptionKey`, etc.). It derives `descriptionKey` from normalized statuses and stores whether the current user initiated the dispute. +- `DisputeDescriptionKey` drives localized copy ("Abriste una disputa", "Esperando asignación de admin", "Admin cerró la disputa"...). + +### Status & action normalization (from `OrderState`) +- Actions `disputeInitiatedByYou`, `disputeInitiatedByPeer`, `dispute`, `adminTakeDispute`, `adminTookDispute` map to `Status.dispute`. +- `OrderState.updateWith()` enriches disputes: + - Stamps `createdAt` from the DM timestamp for sorting. + - `adminTookDispute` sets `status: in-progress`, saves `adminPubkey`, and triggers `Session.setAdminPeer`. + - `adminSettled` ⇒ `status: resolved`, `action: admin-settled`. + - `adminCanceled` ⇒ `status: seller-refunded`, `action: admin-canceled`. + - User-completed or cooperative-cancel terminal states auto-close the dispute (`status: closed`, `action: user-completed` or `cooperative-cancel`). + +--- + +## 3) Session & admin shared key handshake + +- Sessions (`lib/data/models/session.dart`) store both the counterparty shared key (`peer`) and an optional `adminSharedKey`. +- When `adminTookDispute` arrives, `AbstractMostroNotifier` extracts the admin pubkey from the event payload (`Peer`) or existing `Dispute.adminPubkey`, calls `sessionNotifier.updateSession(orderId, setAdminPeer)` and recomputes the shared key via ECDH. +- `DisputeChatNotifier` requires `session.adminSharedKey` before subscribing. Until the key exists, it listens to `sessionNotifierProvider` and retries subscription automatically. +- Attachments (`ChatFileUploadHelper`, `EncryptedImageUploadService`, `EncryptedFileUploadService`) call `DisputeChatNotifier.getAdminSharedKey()` to fetch raw ChaCha20 keys for encryption/decryption. + +--- + +## 4) Dispute list UX & unread state + +### `DisputesList` +- Driven by `userDisputeDataProvider`, which memoizes `DisputeData` list, sorts by `createdAt` DESC, and rebuilds whenever sessions or order states change. +- Loading state: centered spinner. Error state: icon + "Retry" button that invalidates the provider. +- Empty state: gavel icon + helper text ("Tus disputas aparecerán aquí"). + +### `DisputeListItem` +- Wraps `DisputeContent` and `DisputeIcon`; tapping marks the dispute as read via `DisputeReadStatusService.markDisputeAsRead(disputeId)` then pushes `/dispute_details/:id`. +- `DisputeContent` pulls: + - `DisputeHeader` (status badge color-coded via `DisputeStatusBadge`). + - Order ID (`DisputeOrderId`). + - Description text (`DisputeDescription`) which, for `status == in-progress`, shows the last admin/user message fetched from `disputeChatNotifierProvider`. + - Unread dot: `FutureBuilder` calls `DisputeReadStatusService.hasUnreadMessages(...)`, comparing message timestamps against the stored `SharedPreferences` key (`dispute_last_read_{id}`). +- `disputeReadStatusProvider` is a `StateProvider.family` used solely to trigger rebuilds when a dispute is marked as read (timestamp bump). + +### Tab description copy +- When `ChatTabType.disputes` is active, `ChatRoomsScreen` shows `S.disputesDescription` ("Aquí hablas con los admins"), reinforcing that this is a separate space from P2P chat. + +--- + +## 5) Dispute detail & chat screen + +### `DisputeChatScreen` +- Receives `disputeId` from GoRouter, `watch(disputeDetailsProvider(disputeId))` to load the latest `Dispute` (or show "not found"). +- On `initState`, it marks the dispute as read and updates `disputeReadStatusProvider`. +- Converts the domain model (`Dispute`) into `DisputeData` using both `sessionNotifierProvider` and `orderNotifierProvider` to pull role/counterparty data. +- Layout: + 1. `DisputeCommunicationSection` → `DisputeMessagesList` + 2. `DisputeMessageInput` rendered **only** when `DisputeData.status == 'in-progress'`. Initiated / resolved disputes become read-only logs. + +### `DisputeMessagesList` +- Combines informational UI and chat messages into a single scroll view: + - `SliverToBoxAdapter` shows a blue "Admin assigned" card if status = `in-progress` and there are no messages yet. + - First list item is always `DisputeInfoCard` (order ID, dispute ID, user role, counterparty nickname via `nickNameProvider`). + - Remainder: `DisputeMessageBubble` entries sorted by timestamp, deduped by message ID. + - For resolved statuses (`resolved`, `seller-refunded`, `closed`), an extra "Chat closed" lock banner is appended. +- Handles empty chat gracefully: waiting-for-admin copy, no-messages-yet placeholders, etc. +- Auto-scrolls to the bottom whenever new messages arrive and the user is near the bottom. + +### `DisputeMessageInput` +- Shares the same UX pattern as P2P chat: attach button (wired to `ChatFileUploadHelper.selectAndUploadFile`), text box, send button. +- Attachment workflow uses the admin shared key instead of the counterparty shared key. +- While a file upload is in progress, the attach icon is replaced by a `CircularProgressIndicator`. + +--- + +## 6) Messaging pipeline & storage + +### Provider & state +- `disputeChatNotifierProvider` is a `StateNotifierProvider.family`. +- `DisputeChatState` stores `messages`, `isLoading`, and `error` (global error, e.g., failed history fetch). `DisputeChatMessage` wraps `NostrEvent` with `isPending` and per-message `error` fields for optimistic UI. + +### Initialization & history +- `initialize()` loads history and subscribes once (idempotent guard `_isInitialized`). +- Historic events are stored in Sembast with `type: 'dispute_chat'` and `dispute_id: `. Each record keeps the full gift wrap payload (`kind`, `content`, `tags`, etc.). +- `_loadHistoricalMessages()` filters by `type` + `dispute_id`, unwraps each gift wrap with `session.adminSharedKey`, converts to `DisputeChatMessage`, deduplicates by inner event ID, and sorts ascending. + +### Live subscription +- `_subscribe()` builds a `NostrRequest` for kind `1059` events where the `p` tag matches `session.adminSharedKey.public`. +- `NostrService.subscribeToEvents()` feeds `_onChatEvent`: + 1. Verify event kind & `p` tag. + 2. Skip duplicates using `eventStore.hasItem(wrapperEventId)`. + 3. Persist the encrypted wrapper (`type: dispute_chat`). + 4. `p2pUnwrap` with the admin shared key (single-layer gift wrap). + 5. Skip empty content; create `DisputeChatMessage` and append to state (dedupe, sort). + 6. Fire-and-forget `_processMessageContent()` to pre-download Blossom files/images. + +### Sending messages +- `sendMessage(text)` creates the inner rumor event (kind 1) **before** wrapping so the optimistic UI uses the final message ID. +- The UI appends a pending bubble, wraps the rumor with `session.adminSharedKey.public`, publishes via `nostrService.publishEvent`, and persists the wrapper to Sembast. +- On failure, the pending bubble flips `error` and `isPending=false`. +- The relay echo eventually arrives via `_onChatEvent`, which dedupes by ID. + +### Multimedia support +- `DisputeMessageBubble` inspects the message via `MessageTypeUtils` (text, encrypted image, encrypted file). +- Image/file widgets reuse the shared cache mixin (`MediaCacheMixin`) provided by `DisputeChatNotifier`. +- Attachment download requires `getAdminSharedKey()`; missing shared keys throw an exception and display errors in logs. + +### Read state & badges +- `DisputeReadStatusService` stores timestamps in `SharedPreferences` and exposes `hasUnreadMessages(disputeId, messages, isFromUser)` to check for any admin messages newer than the last read time. +- `DisputeChatScreen` marks the dispute as read on `initState`; `DisputeListItem` also marks as read when tapping from the list to keep both entry points in sync. + +--- + +## 7) Dispute lifecycle & order status integration + +### Initiation +1. User taps **Dispute** in Trade Detail. +2. `DisputeRepository.createDispute` sends the gift wrap event. +3. Mostro responds with `disputeInitiatedByYou` (for initiator) and `disputeInitiatedByPeer` (for the counterpart). +4. `OrderState` stores the `Dispute` payload, `status` transitions to `Status.dispute`, and UI shows the red "Dispute" chip. + +### Admin assignment +1. When an admin takes the dispute, Mostro sends `adminTookDispute` with a `Peer` payload representing the admin. +2. `OrderState.updateWith()` marks the dispute `status: in-progress` and copies the admin pubkey. +3. `Session.setAdminPeer` computes `adminSharedKey`, enabling dispute chat encryption. +4. `DisputeMessagesList` shows the blue "Admin assigned" card until the first message arrives. + +### Resolution paths +- **Admin settled** (`adminSettled`): `Dispute.status = resolved`, `action = admin-settled`. `DisputeMessagesList` hides the input and shows the lock banner. `DisputeStatusContent` renders "Admin devolvió los sats". +- **Admin canceled** (`adminCanceled`): `status = seller-refunded`, `action = admin-canceled`. +- **User completed** (Lightning payment succeeded) or **cooperative cancel**: auto-close logic in `OrderState` sets `status = closed`, `action = user-completed / cooperative-cancel`. No admin involvement required. + +### Unread + notification flows +- Because `DisputeReadStatusService` works off timestamps, any new message (admin or user) increments the badge automatically until the user opens the chat. +- The P2P chat badge (`chatCountProvider`) is independent; disputes currently rely on the red dot inside the list items. + +--- + +## 8) Cross references + +| Topic | Document | +|-------|----------| +| Trade Detail actions & dispute button | [TRADE_EXECUTION.md](./TRADE_EXECUTION.md) | +| Order status transitions & dispute closure | [ORDER_STATUS_HANDLING.md](./ORDER_STATUS_HANDLING.md) | +| P2P chat architecture (shared widgets, media pipeline) | [P2P_CHAT_SYSTEM.md](./P2P_CHAT_SYSTEM.md) | +| Session & key management (admin shared key storage) | [SESSION_AND_KEY_MANAGEMENT.md](./SESSION_AND_KEY_MANAGEMENT.md) | +| Navigation routes table | [NAVIGATION_ROUTES.md](./NAVIGATION_ROUTES.md) | diff --git a/.specify/v1-reference/NAVIGATION_ROUTES.md b/.specify/v1-reference/NAVIGATION_ROUTES.md index 5247e406..c631e4df 100644 --- a/.specify/v1-reference/NAVIGATION_ROUTES.md +++ b/.specify/v1-reference/NAVIGATION_ROUTES.md @@ -49,7 +49,7 @@ MostroApp | `/trade_detail/:orderId` | `TradeDetailScreen` | `trade_detail_screen.dart` | Trade detail (see `.specify/v1-reference/TRADE_EXECUTION.md`) | | `/chat_list` | `ChatRoomsScreen` | `chat_rooms_list.dart` | Chat hub (see `P2P_CHAT_SYSTEM.md`) | | `/chat_room/:orderId` | `ChatRoomScreen` | `chat_room_screen.dart` | Trade chat room (see `P2P_CHAT_SYSTEM.md`) | -| `/dispute_details/:disputeId` | `DisputeChatScreen` | `dispute_chat_screen.dart` | Chat de disputa | +| `/dispute_details/:disputeId` | `DisputeChatScreen` | `dispute_chat_screen.dart` | Chat de disputa (ver `DISPUTE_SYSTEM.md`) | | `/register` | `RegisterScreen` | `register_screen.dart` | Registro de identidad | | `/relays` | `RelaysScreen` | `relays_screen.dart` | Gestión de relays | | `/key_management` | `KeyManagementScreen` | `key_management_screen.dart` | Cuenta, mnemónicos | diff --git a/.specify/v1-reference/ORDER_STATUS_HANDLING.md b/.specify/v1-reference/ORDER_STATUS_HANDLING.md index c9776c85..d6e7e153 100644 --- a/.specify/v1-reference/ORDER_STATUS_HANDLING.md +++ b/.specify/v1-reference/ORDER_STATUS_HANDLING.md @@ -139,6 +139,8 @@ This is a pending state. Once the other party accepts, the status changes to `ca | `adminTakeDispute` | `dispute` | Admin took the dispute | | `adminTookDispute` | `dispute` | Admin took the dispute (confirmation) | +_For UI behavior (list badges, dispute chat, admin messaging) see [DISPUTE_SYSTEM.md](./DISPUTE_SYSTEM.md)._ + ### Admin Resolution | Action | Status | When | diff --git a/.specify/v1-reference/P2P_CHAT_SYSTEM.md b/.specify/v1-reference/P2P_CHAT_SYSTEM.md index 5b9cc128..c65fe195 100644 --- a/.specify/v1-reference/P2P_CHAT_SYSTEM.md +++ b/.specify/v1-reference/P2P_CHAT_SYSTEM.md @@ -417,7 +417,7 @@ With 2+ active trades, counterpart messages disappear after closing and reopenin | Trade detail actions (chat button) | [.specify/v1-reference/TRADE_EXECUTION.md](./TRADE_EXECUTION.md) | | My Trades list (source of chat sessions) | [.specify/v1-reference/MY_TRADES.md](./MY_TRADES.md) | -*Dispute conversations reuse the same chat widgets; see `lib/features/disputes/*` until a dedicated spec is published.* +*Dispute conversations reuse the same chat widgets; see [DISPUTE_SYSTEM.md](./DISPUTE_SYSTEM.md) for the dedicated specification.* --- diff --git a/.specify/v1-reference/README.md b/.specify/v1-reference/README.md index 803403fd..ce9961bf 100644 --- a/.specify/v1-reference/README.md +++ b/.specify/v1-reference/README.md @@ -56,6 +56,7 @@ |----------|-------------|--------------| | [ORDER_STATUS_HANDLING.md](./ORDER_STATUS_HANDLING.md) | Order state machine, transitions | Move state machine to Rust | | [P2P_CHAT_SYSTEM.md](./P2P_CHAT_SYSTEM.md) | Encrypted chat, sharedKey, `/chat_list` & `/chat_room` flows | **CRITICAL** - Move to Rust | +| [DISPUTE_SYSTEM.md](./DISPUTE_SYSTEM.md) | Dispute list, admin chat, creation/resolution pipeline | **CRITICAL** - Move dispute logic to Rust | | [NWC_ARCHITECTURE.md](./NWC_ARCHITECTURE.md) | Nostr Wallet Connect integration | Move to Rust | | [ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md](./ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md) | ChaCha20-Poly1305, Blossom | Crypto in Rust, upload in Flutter | | [AUTHENTICATION.md](./AUTHENTICATION.md) | Auth flow, biometric unlock | Adapt for Rust key management | diff --git a/.specify/v1-reference/TRADE_EXECUTION.md b/.specify/v1-reference/TRADE_EXECUTION.md index 559f258e..4a39ab88 100644 --- a/.specify/v1-reference/TRADE_EXECUTION.md +++ b/.specify/v1-reference/TRADE_EXECUTION.md @@ -228,10 +228,12 @@ Derived from `OrderState._getStatusFromAction()` and `MostroFSM`: | Maker-side expiration | `OrderNotifier._subscribeToPublicEvents` watches `orderEventsProvider`; when a pending maker order turns `canceled`, it deletes the session and posts `orderCanceled`. | | Taker timeout (no response within 10s) | `startSessionTimeoutCleanup` fires, shows `sessionTimeoutMessage`, and navigates home. | | Hold invoice payment failure | Mostro sends `payment-failed`; status switches to `paymentFailed`, buyers only see **Add invoice**, sellers only **Pay invoice** until a new request arrives. | -| Disputes | "Dispute" button triggers `disputeRepositoryProvider.createDispute`. Once a dispute exists, the UI shows "View dispute" and `/chat_room` includes admins after `admin-took-dispute`. | +| Disputes | "Dispute" button triggers `disputeRepositoryProvider.createDispute`. Once a dispute exists, "View dispute" links to `/dispute_details/:id` and the chat switches to admin shared keys (see [DISPUTE_SYSTEM.md](./DISPUTE_SYSTEM.md)). | | Cooperative cancel | Pending cancel renders grey button (disabled) + "Contact" button to coordinate. | | Invoice/payment errors | UI surfaces `SnackBarHelper.showTopSnackBar` messages but keeps the user on the same screen. | +**Dispute flow recap:** trade participants can file a dispute once the order is `active`/`fiat-sent`; the repository sends an encrypted `MostroMessage(Action.dispute)`, `OrderState` transitions to `Status.dispute`, and admins may later take the case (`adminTookDispute`), settle (`adminSettled`), or refund (`adminCanceled`). UI details, unread badges, and the dedicated dispute chat live in [DISPUTE_SYSTEM.md](./DISPUTE_SYSTEM.md). + --- ## 8) Cross-references