Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 5 additions & 2 deletions lib/features/disputes/screens/dispute_chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ class _DisputeChatScreenState extends ConsumerState<DisputeChatScreen> {
}

void _onSendText(String text) {
// TODO(bridge): Encrypt with adminSharedKey and publish via Rust bridge.
// Dispute messaging requires an adminSharedKey derived from the trade key
// and the admin's pubkey (Phase 12). The Rust bridge will expose
// `send_dispute_message(dispute_id, text, admin_shared_key)` when ready.
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
Expand All @@ -54,7 +56,8 @@ class _DisputeChatScreenState extends ConsumerState<DisputeChatScreen> {
}

void _onAttachFile() {
// TODO(bridge): Open file picker, encrypt with adminSharedKey, upload.
// File attachments in disputes require the same adminSharedKey as text
// messages (Phase 12). Will use file picker + encrypt + upload flow.
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
Expand Down
23 changes: 23 additions & 0 deletions lib/features/settings/providers/log_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'package:mostro/src/rust/api/logging.dart' as logging_api;
import 'package:mostro/src/rust/api/types.dart';

/// Live log entries from the Rust backend, newest first.
///
/// Caps at 500 entries to bound memory usage. The stream is cancelled
/// when the provider is disposed (e.g. when LogReportScreen is popped).
final logEntriesProvider = StreamProvider.autoDispose<List<LogEntry>>((ref) async* {
var cancelled = false;
ref.onDispose(() => cancelled = true);

final stream = await logging_api.onLogEntry();
final entries = <LogEntry>[];
while (!cancelled) {
final entry = await stream.next();
if (entry == null || cancelled) break;
entries.insert(0, entry); // newest first
if (entries.length > 500) entries.removeLast();
yield List.unmodifiable(entries);
}
});
83 changes: 18 additions & 65 deletions lib/features/settings/screens/log_report_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:share_plus/share_plus.dart';

import 'package:mostro/core/app_theme.dart';
import 'package:mostro/features/settings/providers/log_provider.dart';
import 'package:mostro/features/settings/providers/settings_provider.dart';

// ── Local log level enum (mirrors Rust LogLevel) ──────────────────────────────

enum _LogLevel { debug, info, warning, error }

// ── Local log entry model ─────────────────────────────────────────────────────

class _LogEntry {
const _LogEntry({
required this.id,
required this.level,
required this.tag,
required this.message,
required this.timestamp,
});

final int id;
final _LogLevel level;
final String tag;
final String message;
final int timestamp; // Unix seconds
}
import 'package:mostro/src/rust/api/types.dart';

// ── Screen ────────────────────────────────────────────────────────────────────

Expand All @@ -37,52 +17,25 @@ class LogReportScreen extends ConsumerStatefulWidget {
}

class _LogReportScreenState extends ConsumerState<LogReportScreen> {
// Sample data shown when no bridge is connected.
// TODO(bridge): replace _mockEntries with a live stream from the Rust log
// sink once the log bridge API is implemented (Phase 18+). The bridge
// should expose an on_log_entry() stream that emits LogEntry structs; consume
// it here via a StreamBuilder or a Riverpod StreamProvider and remove the
// static list below.
static final List<_LogEntry> _mockEntries = List.unmodifiable([
_LogEntry(
id: 1,
level: _LogLevel.info,
tag: 'App',
message: 'Application started successfully',
timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120,
),
_LogEntry(
id: 2,
level: _LogLevel.warning,
tag: 'Relay',
message: 'Connection to wss://nos.lol timed out, retrying…',
timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60,
),
_LogEntry(
id: 3,
level: _LogLevel.debug,
tag: 'Order',
message: 'Fetched 42 orders from relay',
timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 10,
),
]);

@override
Widget build(BuildContext context) {
final loggingEnabled = ref.watch(settingsProvider).loggingEnabled;
final colorsRaw = Theme.of(context).extension<AppColors>();
if (colorsRaw == null) throw StateError('AppColors theme extension must be registered');
final colors = colorsRaw;

final logAsync = ref.watch(logEntriesProvider);
final entries = logAsync.valueOrNull ?? const [];

return Scaffold(
appBar: AppBar(
title: const Text('Log Report'),
actions: [
// Share logs — disabled when no entries exist.
IconButton(
icon: const Icon(Icons.share_outlined),
tooltip: _mockEntries.isNotEmpty ? 'Share logs' : 'No logs to share',
onPressed: _mockEntries.isNotEmpty ? _shareLogs : null,
tooltip: entries.isNotEmpty ? 'Share logs' : 'No logs to share',
onPressed: entries.isNotEmpty ? () => _shareLogs(entries) : null,
),
// Toggle logging
IconButton(
Expand Down Expand Up @@ -138,7 +91,7 @@ class _LogReportScreenState extends ConsumerState<LogReportScreen> {
),
// Log entries
Expanded(
child: _mockEntries.isEmpty
child: entries.isEmpty
? Center(
child: Text(
'No log entries',
Expand All @@ -147,10 +100,10 @@ class _LogReportScreenState extends ConsumerState<LogReportScreen> {
)
: ListView.builder(
padding: const EdgeInsets.all(AppSpacing.md),
itemCount: _mockEntries.length,
itemCount: entries.length,
itemBuilder: (context, index) {
return _LogEntryTile(
entry: _mockEntries[index],
entry: entries[index],
colors: colors,
);
},
Expand All @@ -161,8 +114,8 @@ class _LogReportScreenState extends ConsumerState<LogReportScreen> {
);
}

Future<void> _shareLogs() async {
final lines = _mockEntries.map((e) {
Future<void> _shareLogs(List<LogEntry> entries) async {
final lines = entries.map((e) {
final time = _formatTimestamp(e.timestamp);
final level = e.level.name.toUpperCase().padRight(7);
final tag = _sanitizeForShare(e.tag);
Expand Down Expand Up @@ -192,7 +145,7 @@ class _LogReportScreenState extends ConsumerState<LogReportScreen> {
class _LogEntryTile extends StatelessWidget {
const _LogEntryTile({required this.entry, required this.colors});

final _LogEntry entry;
final LogEntry entry;
final AppColors colors;

@override
Expand Down Expand Up @@ -258,12 +211,12 @@ class _LogEntryTile extends StatelessWidget {
);
}

(Color, Color) _levelColors(_LogLevel level) {
(Color, Color) _levelColors(LogLevel level) {
return switch (level) {
_LogLevel.debug => (const Color(0xFF374151), const Color(0xFFD1D5DB)),
_LogLevel.info => (const Color(0xFF1E3A8A), const Color(0xFF93C5FD)),
_LogLevel.warning => (const Color(0xFF854D0E), const Color(0xFFFCD34D)),
_LogLevel.error => (const Color(0xFF7F1D1D), const Color(0xFFFCA5A5)),
LogLevel.debug => (const Color(0xFF374151), const Color(0xFFD1D5DB)),
LogLevel.info => (const Color(0xFF1E3A8A), const Color(0xFF93C5FD)),
LogLevel.warning => (const Color(0xFF854D0E), const Color(0xFFFCD34D)),
LogLevel.error => (const Color(0xFF7F1D1D), const Color(0xFFFCA5A5)),
};
}
}
Expand Down
45 changes: 32 additions & 13 deletions lib/features/settings/widgets/mostro_node_selector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'package:mostro/core/app_theme.dart';
import 'package:mostro/core/mostro_defaults.dart';
import 'package:mostro/src/rust/api/settings.dart' as settings_api;

// ── Provider for current Mostro node pubkey ───────────────────────────────────

const _defaultMostroPubkey = defaultMostroPubkey;

/// In-memory override of the Mostro node pubkey.
///
/// **UI-only placeholder** — this value is not yet passed to the Rust bridge.
/// TODO(bridge): read mostroPubkeyProvider when constructing outgoing Nostr
/// events so order routing uses the selected node (Phase 18+).
/// Active Mostro node pubkey — synced to the Rust bridge so outgoing events
/// are routed to the selected node.
final mostroPubkeyProvider = StateProvider<String>(
(ref) => _defaultMostroPubkey,
);
Comment on lines +12 to 16

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if getMostroPubkey is called anywhere during app initialization
rg -n 'getMostroPubkey|get_mostro_pubkey' --type dart --type rust

Repository: MostroP2P/app

Length of output: 550


🏁 Script executed:

# Find main.dart and related initialization code
fd -n "main\.dart" --type f

Repository: MostroP2P/app

Length of output: 287


🏁 Script executed:

# Search for AppSettingsState definition
rg -n "class AppSettingsState|AppSettingsState\.fromPrefs" --type dart -A 10

Repository: MostroP2P/app

Length of output: 2841


🏁 Script executed:

# Check if getMostroPubkey is called in Dart code (binding bridge)
rg -n "getMostroPubkey|settingsApi" --type dart -B 2 -A 2

Repository: MostroP2P/app

Length of output: 39


🏁 Script executed:

# Examine the mostro_node_selector.dart file for the full provider context
cat -n lib/features/settings/widgets/mostro_node_selector.dart

Repository: MostroP2P/app

Length of output: 9291


🏁 Script executed:

# Search for getMostroPubkey in ALL Dart files (case-insensitive)
rg -i "getmostroPubkey" --type dart

Repository: MostroP2P/app

Length of output: 39


🏁 Script executed:

# Check main.dart initialization section more comprehensively
sed -n '45,80p' lib/main.dart

Repository: MostroP2P/app

Length of output: 1557


🏁 Script executed:

# Search for any references to mostroPubkeyProvider initialization or hydration
rg "mostroPubkeyProvider" --type dart -B 2 -A 2

Repository: MostroP2P/app

Length of output: 3948


🏁 Script executed:

# Check if settings_api.setMostroPubkey/getMostroPubkey are mentioned in Rust settings.rs
cat -n rust/src/api/settings.rs | grep -A 5 -B 5 "get_mostro_pubkey\|set_mostro_pubkey" | head -50

Repository: MostroP2P/app

Length of output: 922


Custom Mostro node selection is ephemeral—will reset on app restart.

The mostroPubkeyProvider initializes with _defaultMostroPubkey on every app launch. Although the Rust bridge persists the value via set_mostro_pubkey(), Dart never restores it: main.dart does not call getMostroPubkey() on startup, and AppSettingsState.fromPrefs() does not include mostroPubkey.

To persist across restarts, either:

  1. Add mostroPubkey to AppSettingsState and SharedPreferences persistence, or
  2. Call settings_api.getMostroPubkey() during app initialization to hydrate the provider from Rust's persisted value.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/settings/widgets/mostro_node_selector.dart` around lines 12 -
16, mostroPubkeyProvider currently always initializes from _defaultMostroPubkey
so a user-selected pubkey is lost on restart; fix by persisting/restoring it:
either add a mostroPubkey field to AppSettingsState and include it in
fromPrefs()/SharedPreferences read/write (and ensure set_mostro_pubkey() writes
to prefs when changed), or hydrate the provider at app startup by calling
settings_api.getMostroPubkey() from main.dart and setting mostroPubkeyProvider
accordingly so the Dart state matches the Rust bridge persisted value; reference
symbols: mostroPubkeyProvider, _defaultMostroPubkey, AppSettingsState.fromPrefs,
set_mostro_pubkey(), getMostroPubkey(), settings_api.getMostroPubkey(),
main.dart.

Expand Down Expand Up @@ -61,25 +59,46 @@ class _MostroNodeSelectorState extends ConsumerState<MostroNodeSelector> {
super.dispose();
}

void _useDefault() {
Future<void> _useDefault() async {
final previous = ref.read(mostroPubkeyProvider);
ref.read(mostroPubkeyProvider.notifier).state = _defaultMostroPubkey;
_controller.clear();
setState(() => _errorText = null);
Navigator.of(context).pop();
try {
await settings_api.setMostroPubkey(pubkey: null);
if (!mounted) return;
_controller.clear();
setState(() => _errorText = null);
Navigator.of(context).pop();
} catch (e) {
debugPrint('[MostroNodeSelector] setMostroPubkey(null) failed: $e');
ref.read(mostroPubkeyProvider.notifier).state = previous;
if (!mounted) return;
setState(() => _errorText = 'Failed to reset node');
}
}

void _confirm() {
Future<void> _confirm() async {
final input = _controller.text.trim();
if (input.isEmpty) {
_useDefault();
await _useDefault();
return;
}
if (!_hexRegex.hasMatch(input)) {
setState(() => _errorText = 'Must be a 64-character hex string');
return;
}
ref.read(mostroPubkeyProvider.notifier).state = input.toLowerCase();
Navigator.of(context).pop();
final pubkey = input.toLowerCase();
final previous = ref.read(mostroPubkeyProvider);
ref.read(mostroPubkeyProvider.notifier).state = pubkey;
try {
await settings_api.setMostroPubkey(pubkey: pubkey);
if (!mounted) return;
Navigator.of(context).pop();
} catch (e) {
debugPrint('[MostroNodeSelector] setMostroPubkey failed: $e');
ref.read(mostroPubkeyProvider.notifier).state = previous;
if (!mounted) return;
setState(() => _errorText = 'Invalid pubkey or bridge error');
}
}

@override
Expand Down
26 changes: 22 additions & 4 deletions lib/features/settings/widgets/relay_management_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,6 @@ class _RelayManagementCardState extends ConsumerState<RelayManagementCard> {
// Defaults mirror rust/src/config.rs — imported from core/mostro_defaults.dart.
static const _defaultRelays = defaultMostroRelays;

// TODO(bridge): replace _relays local state with a Riverpod provider backed
// by the Rust bridge (get_relays / add_relay / remove_relay) so configuration
// persists across navigations and stays in sync with the backend (Phase 18+).
late List<_RelayEntry> _relays;
bool _loading = false;

Expand Down Expand Up @@ -73,8 +70,29 @@ class _RelayManagementCardState extends ConsumerState<RelayManagementCard> {
}
}

void _toggleRelay(int index, bool value) {
Future<void> _toggleRelay(int index, bool value) async {
final url = _relays[index].url;
setState(() => _relays[index].isActive = value);
try {
if (value) {
await nostr_api.addRelay(url: url);
} else {
await nostr_api.removeRelay(url: url);
}
} catch (e) {
debugPrint('[RelayManagement] toggleRelay failed: $e');
if (!mounted) return;
setState(() {
final currentIndex = _relays.indexWhere((r) => r.url == url);
if (currentIndex != -1) {
_relays[currentIndex].isActive = !value;
}
Comment on lines +82 to +89

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle typed relay errors and stale rollback explicitly.

Lines 82-89 revert with !value for every exception. With non-idempotent relay ops (RelayAlreadyExists, LastRelay) and rapid re-toggles, an older failed request can overwrite a newer successful state and desync UI/backend state.

Proposed hardening
   } catch (e) {
     debugPrint('[RelayManagement] toggleRelay failed: $e');
     if (!mounted) return;
+    final err = e.toString();
+    // Enabling an already-present relay is effectively desired state.
+    if (value && err.contains('RelayAlreadyExists')) {
+      return;
+    }
     setState(() {
       final currentIndex = _relays.indexWhere((r) => r.url == url);
-      if (currentIndex != -1) {
+      // Avoid stale rollback if user already toggled again.
+      if (currentIndex != -1 && _relays[currentIndex].isActive == value) {
         _relays[currentIndex].isActive = !value;
       }
     });
+    await _loadRelays(); // resync from source of truth after failure
     final l10n = AppLocalizations.of(context);
     ScaffoldMessenger.of(context).showSnackBar(
       SnackBar(content: Text(value ? l10n.relayAddFailed : l10n.relayRemoveFailed)),
     );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (e) {
debugPrint('[RelayManagement] toggleRelay failed: $e');
if (!mounted) return;
setState(() {
final currentIndex = _relays.indexWhere((r) => r.url == url);
if (currentIndex != -1) {
_relays[currentIndex].isActive = !value;
}
} catch (e) {
debugPrint('[RelayManagement] toggleRelay failed: $e');
if (!mounted) return;
final err = e.toString();
// Enabling an already-present relay is effectively desired state.
if (value && err.contains('RelayAlreadyExists')) {
return;
}
setState(() {
final currentIndex = _relays.indexWhere((r) => r.url == url);
// Avoid stale rollback if user already toggled again.
if (currentIndex != -1 && _relays[currentIndex].isActive == value) {
_relays[currentIndex].isActive = !value;
}
});
await _loadRelays(); // resync from source of truth after failure
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(value ? l10n.relayAddFailed : l10n.relayRemoveFailed)),
);
}

});
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(value ? l10n.relayAddFailed : l10n.relayRemoveFailed)),
);
}
}

Future<void> _removeRelay(int index) async {
Expand Down
4 changes: 2 additions & 2 deletions rust/src/api/disputes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ pub async fn open_dispute(trade_id: String, reason: Option<String>) -> Result<Di
let sender_keys =
crate::api::identity::get_active_trade_keys(trade_index).await?;
let mostro_pubkey =
nostr_sdk::PublicKey::from_hex(crate::config::DEFAULT_MOSTRO_PUBKEY)
.map_err(|e| anyhow!("invalid DEFAULT_MOSTRO_PUBKEY: {e}"))?;
nostr_sdk::PublicKey::from_hex(&crate::config::active_mostro_pubkey())
.map_err(|e| anyhow!("invalid mostro pubkey: {e}"))?;
crate::mostro::actions::dispute(&sender_keys, &mostro_pubkey, &trade_id, trade_index)
.await
}
Expand Down
Loading