import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'nostr_event.dart'; /// The ONE piece shared by every Nostr-backed transport (Q2 recommendation): a /// single relay socket + the signing identity + the EVENT/OK and REQ/EOSE /// lifecycle. `OfferTransport`, `MessageTransport` and a future `TrustTransport` /// each sit on top of this — they share this *connection*, not a *contract*. class NostrConnection { NostrConnection._(this._socket, this.privateKeyHex, this.publicKeyHex) { _socket.listen(_onData, onDone: _onDone); } final WebSocket _socket; /// The derived (Q1) identity this connection signs with. final String privateKeyHex; final String publicKeyHex; final _incoming = StreamController>.broadcast(); int _subCounter = 0; static Future connect( String relayUrl, { required String privateKeyHex, required String publicKeyHex, }) async { final socket = await WebSocket.connect(relayUrl); return NostrConnection._(socket, privateKeyHex, publicKeyHex); } void _onData(dynamic d) => _incoming.add(jsonDecode(d as String) as List); void _onDone() { if (!_incoming.isClosed) _incoming.close(); } /// Publishes an already-signed [event]; resolves with the relay's OK verdict. Future<({bool accepted, String message})> publish(NostrEvent event) async { final ok = _incoming.stream.firstWhere( (m) => m[0] == 'OK' && m[1] == event.id, ); _socket.add(jsonEncode(['EVENT', event.toJson()])); final r = await ok.timeout(const Duration(seconds: 5)); return (accepted: r[2] as bool, message: r.length > 3 ? r[3] as String : ''); } /// Streams events matching [filter] (live), until the caller cancels. Stream subscribe(Map filter) { final subId = 'sub${_subCounter++}'; late StreamController controller; StreamSubscription? sub; controller = StreamController( onListen: () { sub = _incoming.stream.listen((m) { if (m[0] == 'EVENT' && m[1] == subId) { controller.add(NostrEvent.fromJson(m[2] as Map)); } }); _socket.add(jsonEncode(['REQ', subId, filter])); }, onCancel: () async { _socket.add(jsonEncode(['CLOSE', subId])); await sub?.cancel(); }, ); return controller.stream; } /// Collects stored events matching [filter] up to EOSE, then closes the sub. Future> reqOnce(Map filter) async { final subId = 'once${_subCounter++}'; final results = []; final done = Completer(); final sub = _incoming.stream.listen((m) { if (m[1] != subId) return; if (m[0] == 'EVENT') { results.add(NostrEvent.fromJson(m[2] as Map)); } else if (m[0] == 'EOSE' && !done.isCompleted) { done.complete(); } }); _socket.add(jsonEncode(['REQ', subId, filter])); await done.future.timeout(const Duration(seconds: 5)); _socket.add(jsonEncode(['CLOSE', subId])); await sub.cancel(); return results; } Future close() async { await _socket.close(); if (!_incoming.isClosed) await _incoming.close(); } }