Relay-strategy hardening (network-trust §3): don't hang on one relay. - NostrChannel interface: what the transports need (sign, publish, subscribe, reqOnce). Implemented by NostrConnection (single relay) and the new RelayPool. - RelayPool: connects to all configured relays (skips unreachable ones, throws only when none are reachable), publishes to every live relay (accepted if any accepts), merges discovery deduped by event id, and survives a relay dropping. - Transports now depend on NostrChannel, not a concrete connection. - SocialService.openSession takes ALL configured relays (was relays.first); createOffersCubit passes the whole list. commons_core 63 tests green (3 new pool tests over two in-process relays); app_seeds social/market 18 green.
180 lines
5.8 KiB
Dart
180 lines
5.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:commons_core/commons_core.dart';
|
|
import 'package:equatable/equatable.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
|
|
import '../data/variety_repository.dart';
|
|
import '../services/offer_mapper.dart';
|
|
import '../services/social_service.dart';
|
|
import '../services/social_settings.dart';
|
|
|
|
/// State of the offer discovery/publish screen. Transport-agnostic — it holds
|
|
/// only what the UI shows, never a relay handle.
|
|
class OffersState extends Equatable {
|
|
const OffersState({
|
|
this.offers = const [],
|
|
this.areaGeohash = '',
|
|
this.searching = false,
|
|
this.publishing = false,
|
|
this.hasSearched = false,
|
|
this.error,
|
|
});
|
|
|
|
/// Offers discovered so far for [areaGeohash], newest appended as they arrive.
|
|
final List<Offer> offers;
|
|
|
|
/// The coarse area currently being browsed.
|
|
final String areaGeohash;
|
|
|
|
final bool searching;
|
|
final bool publishing;
|
|
|
|
/// True once a discovery has been started, so the UI can tell "no search yet"
|
|
/// from "searched, found nothing".
|
|
final bool hasSearched;
|
|
|
|
/// Last error, in human terms for the UI (null when fine).
|
|
final String? error;
|
|
|
|
OffersState copyWith({
|
|
List<Offer>? offers,
|
|
String? areaGeohash,
|
|
bool? searching,
|
|
bool? publishing,
|
|
bool? hasSearched,
|
|
String? Function()? error,
|
|
}) {
|
|
return OffersState(
|
|
offers: offers ?? this.offers,
|
|
areaGeohash: areaGeohash ?? this.areaGeohash,
|
|
searching: searching ?? this.searching,
|
|
publishing: publishing ?? this.publishing,
|
|
hasSearched: hasSearched ?? this.hasSearched,
|
|
error: error != null ? error() : this.error,
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props =>
|
|
[offers, areaGeohash, searching, publishing, hasSearched, error];
|
|
}
|
|
|
|
/// Drives offer discovery and publishing over an [OfferTransport]. Depends on
|
|
/// the interface, not the Nostr backend, so it unit-tests with a fake and the
|
|
/// UI stays offline-tolerant (a null transport = the social layer is unavailable
|
|
/// and the screen degrades gracefully).
|
|
class OffersCubit extends Cubit<OffersState> {
|
|
OffersCubit(this._transport, {Future<void> Function()? onDispose})
|
|
: _onDispose = onDispose,
|
|
super(const OffersState());
|
|
|
|
final OfferTransport? _transport;
|
|
|
|
/// Closes the owning [SocialSession]/connection when the cubit is disposed
|
|
/// (null in tests, where the transport is a fake with nothing to close).
|
|
final Future<void> Function()? _onDispose;
|
|
StreamSubscription<Offer>? _sub;
|
|
|
|
/// Whether a live transport is available (relay configured and reachable).
|
|
bool get isOnline => _transport != null;
|
|
|
|
/// Starts (or restarts) discovery for [geohashPrefix]. Results stream in.
|
|
Future<void> discover(String geohashPrefix) async {
|
|
final transport = _transport;
|
|
if (transport == null) {
|
|
emit(state.copyWith(error: () => 'offline', hasSearched: true));
|
|
return;
|
|
}
|
|
await _sub?.cancel();
|
|
emit(OffersState(
|
|
areaGeohash: geohashPrefix,
|
|
searching: true,
|
|
hasSearched: true,
|
|
));
|
|
_sub = transport.discover(DiscoveryQuery(geohashPrefix: geohashPrefix)).listen(
|
|
(offer) =>
|
|
emit(state.copyWith(offers: [...state.offers, offer], searching: false)),
|
|
onError: (Object e) =>
|
|
emit(state.copyWith(searching: false, error: () => '$e')),
|
|
);
|
|
}
|
|
|
|
/// Publishes [offer]; returns the transport's verdict. No-op result when
|
|
/// offline.
|
|
Future<PublishResult> publish(Offer offer) async {
|
|
final transport = _transport;
|
|
if (transport == null) {
|
|
return const PublishResult(accepted: false, transportRef: '', message: 'offline');
|
|
}
|
|
emit(state.copyWith(publishing: true, error: () => null));
|
|
try {
|
|
final result = await transport.publish(offer);
|
|
emit(state.copyWith(
|
|
publishing: false,
|
|
error: () => result.accepted ? null : result.message,
|
|
));
|
|
return result;
|
|
} catch (e) {
|
|
emit(state.copyWith(publishing: false, error: () => '$e'));
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Publishes the user's [lots] as offers, each tagged with the coarse
|
|
/// [areaGeohash] and signed by [authorPubkeyHex]. Returns how many the relay
|
|
/// accepted. No-op (returns 0) when offline or with no area set.
|
|
Future<int> publishLots(
|
|
List<ShareableLot> lots, {
|
|
required String authorPubkeyHex,
|
|
required String areaGeohash,
|
|
}) async {
|
|
final transport = _transport;
|
|
if (transport == null || areaGeohash.isEmpty || lots.isEmpty) return 0;
|
|
emit(state.copyWith(publishing: true, error: () => null));
|
|
var accepted = 0;
|
|
try {
|
|
for (final lot in lots) {
|
|
final offer = OfferMapper.fromSharedLot(
|
|
lotId: lot.lotId,
|
|
authorPubkeyHex: authorPubkeyHex,
|
|
summary: lot.summary,
|
|
sharing: lot.offerStatus,
|
|
areaGeohash: areaGeohash,
|
|
category: lot.category,
|
|
);
|
|
final result = await transport.publish(offer);
|
|
if (result.accepted) accepted++;
|
|
}
|
|
emit(state.copyWith(publishing: false));
|
|
} catch (e) {
|
|
emit(state.copyWith(publishing: false, error: () => '$e'));
|
|
rethrow;
|
|
}
|
|
return accepted;
|
|
}
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
await _sub?.cancel();
|
|
await _onDispose?.call();
|
|
return super.close();
|
|
}
|
|
}
|
|
|
|
/// Opens an [OffersCubit] wired to the social layer, or an offline one that
|
|
/// degrades gracefully. Local-first: when no relay is configured (or connecting
|
|
/// fails), the cubit is created with a null transport and the screen still opens.
|
|
Future<OffersCubit> createOffersCubit(
|
|
SocialService social,
|
|
SocialSettings settings,
|
|
) async {
|
|
final relays = await settings.relayUrls();
|
|
if (relays.isEmpty) return OffersCubit(null);
|
|
try {
|
|
final session = await social.openSession(relays);
|
|
return OffersCubit(session.offers, onDispose: session.close);
|
|
} catch (_) {
|
|
return OffersCubit(null); // offline / no relay reachable
|
|
}
|
|
}
|