mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
## What changes
Mobile now finds a person who has a relay-verified profile (NIP-FI, kind
30382) but never published a kind:0 profile. This happens, for example,
when someone skips profile setup. The relay's `POST /profiles/search`
already finds such people by their verified name. Each one comes back as
a kind:30382 head with no kind:0. Mobile kept only kind:0 results, so
these people did not appear.
- `RelaySessionNotifier.searchProfiles` keeps kind:30382 heads from the
endpoint.
- `VerifiedProfilesNotifier.searched` turns a search page into people:
- If the relay authority is not known yet, it first waits for NIP-11
discovery. Otherwise, a search that finished first would drop these
people for that query.
- It checks each head's signature with the directory's off-isolate proof
checker.
- Proven heads go into the verified directory, so the rows show verified
names right away.
- `searchedPeople` (in `verified_profile.dart`) builds one row per
person, in the relay's order:
- If the person has a kind:0, the newest kind:0 is used, as before.
- If not, it uses the proven head that the authority signed, provided it
parses as an active verified profile.
- These cases add no row:
- a head signed by another key;
- a head that names the relay key but has a bad signature;
- a retraction;
- a malformed subject.
- **Behavior change for all mobile mentions:** every verified person now
matches in mention ranking by their verified full name and username, as
well as by display name, including people who have a kind:0. The relay's
search matches these same three names. Without this, a person whose
display name is their username (`glilienfeld`, full name "Gabi
Lilienfeld") came back from the relay for "gabi", and then the local
filter dropped them.
- Callers:
- Mention search, which also serves Add members and New DM.
- The user rows in global search, through `directoryUsersFromSearch`.
The desktop app has the same fix for Manage members:
https://github.com/block/buzz-app/pull/2554
## Screenshots
These come from the real Add members sheet on an Android emulator. Only
the relay session, verified-profile discovery and the member actions are
faked. The names are made up. Jordan Park has a kind:0. jrivera has only
a verified profile, with the full name "Jordan Rivera". The search is
"jordan". The row shows the verified display name, which here is the
same as the username.
| Search finds jrivera | After tapping + on jrivera |
| --- | --- |
| <img width="300" alt="jrivera is found next to Jordan Park"
src="https://github.com/user-attachments/assets/d93b77b1-1bc7-4d09-afc1-f359616a192a"
/> | <img width="300" alt="jrivera is selected to add"
src="https://github.com/user-attachments/assets/ce6c7a71-b098-434e-8b0e-ed0de8d1a5a2"
/> |
## Tests
- `add_members_sheet_names_test.dart`: a journey in which Gabi has
`display_name` = `glilienfeld` and `full_name` = "Gabi Lilienfeld", and
NIP-11 discovery is slow.
- While discovery is pending, typing "gabi" shows nothing yet.
- When discovery finishes, Gabi appears with no further keystroke.
- A head from another key does not appear, and neither does a head that
names the relay key but has a bad signature.
- **Add member** adds Gabi's key.
- I removed each fix in turn: the full-name alias, the signature proof,
and the wait for discovery. The journey fails each time, for the
expected reason.
- `verified_profile_test.dart`: a table of `searchedPeople` cases.
- A kind:0 wins over a head, and the newest kind:0 counts.
- These find nobody: another signer, a retraction, an uppercase subject,
a non-profile kind, and any head when the authority is unknown.
- `relay_session_test.dart` and `channel_management_provider_test.dart`:
rows for the kept heads and the directory row.
- `flutter analyze` is clean, and the full `flutter test` passes on
6efef79cc: 4,462 tests.
**Note:** mobile on `main` does not compile at e558f15fa.
`account_identity_store.dart` calls the removed `_keys`. That fix is
https://github.com/block/buzz/pull/9085. The local runs above had that
one-line fix applied but not committed. Until it lands, mobile CI on
this PR will fail.
## Not covered
- **Search during a reconnect:** discovery starts only when the relay
session is connected. If an HTTP search succeeds while the session is
reconnecting, that search leaves these people out.
- **Discovery failure:** if NIP-11 discovery fails, the search also
leaves these people out.
- In both cases the mention cache keeps that partial page for the query,
and the next search tries discovery again. People who have a kind:0 show
as before. Showing the left-out people when the authority arrives later
would need the cached results to be rebuilt; that is a possible
follow-up.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1198 lines
40 KiB
Dart
1198 lines
40 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:crypto/crypto.dart' as crypto;
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../features/age_gate/age_signal_provider.dart';
|
|
import '../auth/auth.dart';
|
|
import '../hosted/hosted_relay_auth.dart';
|
|
import '../hosted/builderlab_account.dart';
|
|
import 'nostr_filters.dart';
|
|
import 'nostr_models.dart';
|
|
import 'relay_client.dart';
|
|
import 'relay_closed_policy.dart';
|
|
import 'relay_http_query_client.dart';
|
|
import 'relay_provider.dart';
|
|
import 'relay_query_response.dart';
|
|
import 'relay_rate_limit_gate.dart';
|
|
import 'relay_reconnect_policy.dart';
|
|
import 'read_state_snapshot.dart';
|
|
import '../verified_profiles/verified_profile.dart';
|
|
import 'relay_session_types.dart';
|
|
import 'relay_socket.dart';
|
|
import 'package:buzz/shared/crypto/event_signing.dart';
|
|
|
|
export 'relay_session_types.dart';
|
|
|
|
part 'relay_session_auth.dart';
|
|
part 'relay_session_subscriptions.dart';
|
|
part 'relay_session_snapshot.dart';
|
|
|
|
class RelaySessionNotifier extends Notifier<SessionState> {
|
|
RelaySessionNotifier({
|
|
http.Client? httpClient,
|
|
http.Client Function()? httpClientFactory,
|
|
RelaySocketFactory socketFactory = RelaySocket.new,
|
|
DateTime Function()? now,
|
|
RelayRateLimitGate? rateLimitGate,
|
|
RelayTimerFactory retryTimerFactory = Timer.new,
|
|
Future<void> Function(Duration) replayDelay = Future.delayed,
|
|
RelayTimerFactory reconnectTimerFactory = Timer.new,
|
|
Random? random,
|
|
RelayQueryDecoder queryDecoder = decodeRelayQueryResponse,
|
|
ReadStateSnapshotDecoder snapshotDecoder = decodeReadStateSnapshot,
|
|
}) : _httpQueryClient = RelayHttpQueryClient(
|
|
client: httpClient,
|
|
clientFactory: httpClientFactory,
|
|
),
|
|
_socketFactory = socketFactory,
|
|
_now = now ?? DateTime.now,
|
|
_rateLimitGate = rateLimitGate ?? RelayRateLimitGate(),
|
|
_retryTimerFactory = retryTimerFactory,
|
|
_replayDelay = replayDelay,
|
|
_reconnectTimerFactory = reconnectTimerFactory,
|
|
_random = random ?? Random(),
|
|
_queryDecoder = queryDecoder,
|
|
_snapshotDecoder = snapshotDecoder;
|
|
|
|
final RelayHttpQueryClient _httpQueryClient;
|
|
final RelaySocketFactory _socketFactory;
|
|
final DateTime Function() _now;
|
|
final RelayRateLimitGate _rateLimitGate;
|
|
final RelayTimerFactory _retryTimerFactory;
|
|
final Future<void> Function(Duration) _replayDelay;
|
|
final RelayTimerFactory _reconnectTimerFactory;
|
|
final Random _random;
|
|
final RelayQueryDecoder _queryDecoder;
|
|
final ReadStateSnapshotDecoder _snapshotDecoder;
|
|
|
|
static const _baseReconnectDelayMs = relayReconnectBaseDelayMs;
|
|
static const _maxReconnectDelayMs = relayReconnectMaxDelayMs;
|
|
static const _eventBatchMs = 16;
|
|
static const _replayBatchSize = 8;
|
|
static const _replayInterBatchDelay = Duration(milliseconds: 50);
|
|
static const _maxRecentDeliveryKeys = 5000;
|
|
static const _backgroundGraceDuration = Duration(seconds: 5);
|
|
|
|
RelaySocket? _socket;
|
|
final Map<String, _HistorySubscription> _historySubscriptions = {};
|
|
final Map<String, _LiveSubscription> _liveSubscriptions = {};
|
|
final Map<String, _ClosedRetry> _pendingClosedRetries = {};
|
|
final Map<String, _PendingEvent> _pendingEvents = {};
|
|
final List<_BufferedEvent> _eventBuffer = [];
|
|
final Set<String> _recentDeliveryKeys = {};
|
|
Timer? _reconnectTimer;
|
|
Timer? _flushTimer;
|
|
Timer? _backgroundGraceTimer;
|
|
DateTime? _backgroundedAt;
|
|
DateTime? _connectedAt;
|
|
DateTime? _lastInboundAt;
|
|
int _reconnectDelayMs = _baseReconnectDelayMs;
|
|
int _subIdCounter = 0;
|
|
bool _disposed = false;
|
|
bool _ageRestricted = false;
|
|
bool _paused = false;
|
|
bool _hasConnectedOnce = false;
|
|
int _connectionGeneration = 0;
|
|
int _contextGeneration = 0;
|
|
final Map<Object, String> _visibleChannelsByOwner = {};
|
|
final Map<Object, Future<void> Function()> _beforePauseCallbacks = {};
|
|
bool _socketConnected = false;
|
|
bool _closedRetryReplayScheduled = false;
|
|
String? _relayUrl;
|
|
|
|
/// The relay origin this session serves. Switching community rebuilds the
|
|
/// session in place, so work begun for another community compares this
|
|
/// before reading from or publishing through it.
|
|
String? get relayUrl => _relayUrl;
|
|
|
|
@visibleForTesting
|
|
set debugRelayUrl(String? value) => _relayUrl = value;
|
|
|
|
@override
|
|
SessionState build() {
|
|
final config = ref.watch(relayConfigProvider);
|
|
_relayUrl = config.baseUrl;
|
|
final authState = ref.watch(authProvider);
|
|
_ageRestricted = ref.watch(ageSignalProvider) == AgeSignalState.restricted;
|
|
|
|
// Reset disposed flag — build() may re-run on the same Notifier instance
|
|
// after a provider dependency changes (e.g. auth completing).
|
|
_disposed = false;
|
|
|
|
ref.onDispose(_dispose);
|
|
|
|
// Auto-connect when authenticated and we have a signing key (NIP-42 AUTH).
|
|
final isAuthenticated = authState.value?.status == AuthStatus.authenticated;
|
|
if (!_ageRestricted && isAuthenticated && config.nsec != null) {
|
|
// Schedule connection after build completes.
|
|
Future.microtask(() => _connect(config));
|
|
}
|
|
|
|
return const SessionState(status: SessionStatus.disconnected);
|
|
}
|
|
|
|
/// Execute a one-shot query via the relay's HTTP bridge (`POST /query`).
|
|
Future<List<NostrEvent>> queryRelay(
|
|
List<NostrFilter> filters, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) => _postForEvents(
|
|
'/query',
|
|
filters.map((filter) => filter.toJson()).toList(),
|
|
timeout: timeout,
|
|
);
|
|
|
|
RelayConfig get _snapshotConfig => ref.read(relayConfigProvider);
|
|
HostedRelayAuth get _snapshotAuth => ref.read(hostedRelayAuthProvider);
|
|
|
|
/// Fetches a complete, validated own-author kind-30078 snapshot.
|
|
/// Throws if discovery, completeness, scope or event validation fails.
|
|
Future<List<NostrEvent>> fetchReadStateSnapshot({
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) => _fetchReadStateSnapshot(timeout: timeout);
|
|
|
|
/// Finds people by profile name, for typeahead surfaces.
|
|
///
|
|
/// NIP-50 search over kind:0 matches the whole profile JSON, so text in
|
|
/// avatar data URLs, `about` or `nip05` can fill every result page and hide
|
|
/// a real name match. The relay's `POST /profiles/search` applies the same
|
|
/// prefix rule to just `name` and `display_name` and puts exact and prefix
|
|
/// name matches first.
|
|
///
|
|
/// A relay without that endpoint answers 404 or 405. The search then uses
|
|
/// NIP-50 ([NostrFilters.searchUsers]), and so do later searches on that
|
|
/// relay for [profileSearchRetry]; after that the endpoint is tried again.
|
|
/// A query or limit the endpoint does not accept also uses NIP-50. The
|
|
/// endpoint's verified heads (kind:30382) are kept: [searchedPeople] finds
|
|
/// people with no kind:0 by them.
|
|
Future<List<NostrEvent>> searchProfiles(
|
|
String query, {
|
|
int limit = 50,
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
final text = query.trim();
|
|
final relay = ref.read(relayConfigProvider).baseUrl;
|
|
final fallback = _profileSearchFallback;
|
|
if (text.isNotEmpty &&
|
|
text.runes.length <= _profileSearchQueryMax &&
|
|
limit >= 1 &&
|
|
limit <= _profileSearchLimitMax &&
|
|
(fallback == null ||
|
|
fallback.relay != relay ||
|
|
!_now().isBefore(fallback.until))) {
|
|
try {
|
|
final events = await _postForEvents('/profiles/search', {
|
|
'query': text,
|
|
'limit': limit,
|
|
}, timeout: timeout);
|
|
return [
|
|
for (final event in events)
|
|
if (event.kind == 0 || event.kind == verifiedProfileKind) event,
|
|
];
|
|
} on RelayException catch (error) {
|
|
if (error.statusCode != 404 && error.statusCode != 405) rethrow;
|
|
_profileSearchFallback = (
|
|
relay: relay,
|
|
until: _now().add(profileSearchRetry),
|
|
);
|
|
}
|
|
}
|
|
return queryRelay([
|
|
NostrFilters.searchUsers(text, limit: limit),
|
|
], timeout: timeout);
|
|
}
|
|
|
|
/// How long a relay's 404 or 405 keeps [searchProfiles] on NIP-50.
|
|
static const profileSearchRetry = Duration(minutes: 5);
|
|
static const _profileSearchQueryMax = 100;
|
|
static const _profileSearchLimitMax = 100;
|
|
({String relay, DateTime until})? _profileSearchFallback;
|
|
|
|
/// POSTs [payload] as JSON to relay [path] with NIP-98 auth and decodes
|
|
/// the event array it answers.
|
|
Future<List<NostrEvent>> _postForEvents(
|
|
String path,
|
|
Object payload, {
|
|
required Duration timeout,
|
|
}) async {
|
|
if (_disposed || _ageRestricted) {
|
|
throw StateError('Relay session is unavailable');
|
|
}
|
|
final generation = _contextGeneration;
|
|
final config = ref.read(relayConfigProvider);
|
|
final url = Uri.parse(config.baseUrl).resolve(path).toString();
|
|
final bodyBytes = utf8.encode(jsonEncode(payload));
|
|
// Reuse the session transport on success. A timeout rotates immediately
|
|
// for new queries, then closes the retired client after its peers finish.
|
|
final response = await _httpQueryClient.post(
|
|
Uri.parse(url),
|
|
headers: {
|
|
'Authorization': buildNip98AuthHeader(
|
|
method: 'POST',
|
|
url: url,
|
|
bodyBytes: bodyBytes,
|
|
nsec: config.nsec,
|
|
pubkey: ref.read(myPubkeyProvider),
|
|
),
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: bodyBytes,
|
|
timeout: timeout,
|
|
decorate: (client, active) => HostedRelayHttpClient(
|
|
ref.read(hostedRelayAuthProvider),
|
|
config,
|
|
inner: client,
|
|
canSend: () =>
|
|
active() && !_disposed && generation == _contextGeneration,
|
|
),
|
|
);
|
|
bool retired() =>
|
|
_disposed || _ageRestricted || generation != _contextGeneration;
|
|
if (retired()) {
|
|
throw StateError('Relay query belongs to a retired session');
|
|
}
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
final body = relayResponseText(
|
|
response.bodyBytes,
|
|
response.headers['content-type'],
|
|
);
|
|
_activateRateLimitGateFromHttpError(body);
|
|
throw RelayException(response.statusCode, body);
|
|
}
|
|
// Decoding runs off the UI isolate, so the session can retire meanwhile.
|
|
// A retired session reports that, not whatever its bytes decoded to.
|
|
final List<NostrEvent> events;
|
|
try {
|
|
events = await _queryDecoder(response);
|
|
} catch (_) {
|
|
if (retired()) {
|
|
throw StateError('Relay query belongs to a retired session');
|
|
}
|
|
rethrow;
|
|
}
|
|
if (retired()) {
|
|
throw StateError('Relay query belongs to a retired session');
|
|
}
|
|
return events;
|
|
}
|
|
|
|
void _activateRateLimitGateFromHttpError(String body) {
|
|
final dynamic decoded;
|
|
try {
|
|
decoded = jsonDecode(body);
|
|
} on FormatException {
|
|
return;
|
|
}
|
|
if (decoded is! Map<String, dynamic>) return;
|
|
final message = decoded['error'];
|
|
if (message is! String ||
|
|
classifyRelayClosed(message) != RelayClosedClass.rateLimited) {
|
|
return;
|
|
}
|
|
_rateLimitGate.activate(parseRateLimitRetrySeconds(message));
|
|
}
|
|
|
|
/// Fetch historical events matching [filter]. Sends REQ, collects events
|
|
/// until EOSE, then resolves. One-shot subscription.
|
|
Future<List<NostrEvent>> fetchHistory(
|
|
NostrFilter filter, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
|
|
if (_disposed) throw StateError('Relay session is disposed');
|
|
final subId = _nextSubId('h');
|
|
final completer = Completer<List<NostrEvent>>();
|
|
|
|
final timer = Timer(timeout, () {
|
|
final sub = _historySubscriptions.remove(subId);
|
|
if (sub != null && !sub.completer.isCompleted) {
|
|
sub.completer.completeError(
|
|
TimeoutException('Relay history request timed out after $timeout'),
|
|
);
|
|
}
|
|
if (sub?.heldFilter == null) _sendClose(subId);
|
|
});
|
|
|
|
final subscription = _HistorySubscription(
|
|
completer: completer,
|
|
timeout: timer,
|
|
);
|
|
_historySubscriptions[subId] = subscription;
|
|
|
|
// The relay rejects a REQ sent before NIP-42 AUTH completes. Hold it for
|
|
// the authenticated connection instead of paying a rejection and retry.
|
|
if (_socketConnected) {
|
|
_sendReq(subId, filter);
|
|
} else {
|
|
subscription.heldFilter = filter;
|
|
}
|
|
return completer.future;
|
|
}
|
|
|
|
Future<void Function()> subscribe(
|
|
NostrFilter filter,
|
|
void Function(NostrEvent) onEvent, {
|
|
void Function(String message)? onClosed,
|
|
}) => _subscribe(filter, onEvent, onClosed: onClosed);
|
|
|
|
/// Subscribe to a live stream and observe its recovery lifecycle.
|
|
///
|
|
/// The returned future completes after initial EOSE (or the existing
|
|
/// fallback timeout) and yields a cleanup callback. [onStatusChanged] emits
|
|
/// [RelaySubscriptionStatus.ready] at each EOSE after buffered replay events
|
|
/// have been delivered, and [RelaySubscriptionStatus.retrying] immediately
|
|
/// when a retryable or rate-limited CLOSED begins backoff. Terminal CLOSED
|
|
/// invokes [onClosed] and removes the subscription instead of retrying it.
|
|
/// Calling the cleanup callback cancels pending retries and sends CLOSE.
|
|
Future<void Function()> subscribeWithStatus(
|
|
NostrFilter filter,
|
|
void Function(NostrEvent) onEvent, {
|
|
void Function(String message)? onClosed,
|
|
required void Function(RelaySubscriptionStatus status) onStatusChanged,
|
|
}) => _subscribe(
|
|
filter,
|
|
onEvent,
|
|
onClosed: onClosed,
|
|
onStatusChanged: onStatusChanged,
|
|
);
|
|
|
|
Future<void Function()> _subscribe(
|
|
NostrFilter filter,
|
|
void Function(NostrEvent) onEvent, {
|
|
void Function(String message)? onClosed,
|
|
void Function(RelaySubscriptionStatus status)? onStatusChanged,
|
|
}) async {
|
|
if (_disposed) throw StateError('Relay session is disposed');
|
|
final subId = _nextSubId('l');
|
|
final readyCompleter = Completer<void>();
|
|
|
|
final subscription = _LiveSubscription(
|
|
filter: filter,
|
|
onEvent: onEvent,
|
|
onClosed: onClosed,
|
|
onStatusChanged: onStatusChanged,
|
|
readyCompleter: readyCompleter,
|
|
);
|
|
_liveSubscriptions[subId] = subscription;
|
|
|
|
// Before AUTH the relay closes it as auth-required, which is terminal.
|
|
// The replay on connect sends it instead.
|
|
if (_socketConnected) _sendLiveReq(subId, subscription);
|
|
|
|
try {
|
|
await readyCompleter.future.timeout(
|
|
const Duration(milliseconds: 500),
|
|
onTimeout: () {},
|
|
);
|
|
} catch (_) {
|
|
_liveSubscriptions.remove(subId);
|
|
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
|
|
rethrow;
|
|
}
|
|
final liveSub = _liveSubscriptions[subId];
|
|
if (liveSub != null && liveSub.readyCompleter == readyCompleter) {
|
|
liveSub.readyCompleter = null;
|
|
}
|
|
|
|
return () => _unsubscribe(subId);
|
|
}
|
|
|
|
/// Capture a fence for work whose authority must not cross a reconnect.
|
|
/// Invoke it after awaited work and immediately before publication.
|
|
void Function() captureConnectionFence() {
|
|
final generation = _connectionGeneration;
|
|
final context = _contextGeneration;
|
|
return () {
|
|
if (!_isActiveConnection(generation) || context != _contextGeneration) {
|
|
throw StateError('Relay connection changed; fresh snapshot required');
|
|
}
|
|
};
|
|
}
|
|
|
|
Future<NostrEvent> publish(
|
|
NostrEvent event, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
final generation = _connectionGeneration;
|
|
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
|
|
if (!_isActiveConnection(generation) || !_socketConnected) {
|
|
throw StateError('Relay session is not connected');
|
|
}
|
|
|
|
final completer = Completer<NostrEvent>();
|
|
|
|
final timer = Timer(timeout, () {
|
|
final pending = _pendingEvents.remove(event.id);
|
|
if (pending != null && !pending.completer.isCompleted) {
|
|
pending.completer.completeError(
|
|
TimeoutException(
|
|
'Event ${event.id} not acknowledged within $timeout',
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
_pendingEvents[event.id] = _PendingEvent(
|
|
completer: completer,
|
|
timeout: timer,
|
|
);
|
|
|
|
_socket?.send(['EVENT', event.toJson()]);
|
|
return completer.future;
|
|
}
|
|
|
|
void sendRaw(List<dynamic> payload) {
|
|
_socket?.send(payload);
|
|
}
|
|
|
|
@visibleForTesting
|
|
void debugHandleMessage(List<dynamic> data) => _handleMessage(data);
|
|
|
|
@visibleForTesting
|
|
void debugFlushEventBuffer() => _flushEventBuffer();
|
|
|
|
@visibleForTesting
|
|
Future<void> debugHandleConnected() =>
|
|
_handleConnected(_connectionGeneration);
|
|
|
|
@visibleForTesting
|
|
Future<void> debugReplayLiveSubscriptions() =>
|
|
_replayLiveSubscriptions(_connectionGeneration);
|
|
|
|
@visibleForTesting
|
|
void debugDispose() => _dispose();
|
|
|
|
@visibleForTesting
|
|
void debugSupersedeConnection() => _connectionGeneration++;
|
|
|
|
@visibleForTesting
|
|
void debugHandleDisconnected([Object? error]) {
|
|
_socketConnected = false;
|
|
_handleDisconnected(_connectionGeneration, error);
|
|
}
|
|
|
|
@visibleForTesting
|
|
void debugResetClosedRetriesForDisconnect() {
|
|
_socketConnected = false;
|
|
_resetAllClosedRetries();
|
|
}
|
|
|
|
@visibleForTesting
|
|
void debugSetSessionStatus(SessionStatus status) {
|
|
_socketConnected = status == SessionStatus.connected;
|
|
}
|
|
|
|
@visibleForTesting
|
|
void debugPauseNow() => _pauseNow();
|
|
|
|
@visibleForTesting
|
|
void debugHandleSocketMessageForTest(List<dynamic> data) =>
|
|
_handleMessage(data);
|
|
|
|
@visibleForTesting
|
|
void debugAttachSocketForTest(RelaySocket socket) {
|
|
_socket?.dispose();
|
|
_socket = socket;
|
|
_socketConnected = true;
|
|
}
|
|
|
|
/// Registers a visible channel and returns an owner-scoped release callback.
|
|
/// The most recently registered owner is prioritized during reconnect replay.
|
|
void Function() registerVisibleChannel(String channelId) {
|
|
final owner = Object();
|
|
_visibleChannelsByOwner[owner] = channelId;
|
|
return () => _visibleChannelsByOwner.remove(owner);
|
|
}
|
|
|
|
/// Force a reconnect (e.g., returning from background).
|
|
Future<void> reconnect() async {
|
|
_socketConnected = false;
|
|
await _socket?.disconnect();
|
|
_reconnectDelayMs = _baseReconnectDelayMs;
|
|
final config = ref.read(relayConfigProvider);
|
|
await _connect(config);
|
|
}
|
|
|
|
/// Registers work that must settle before the background grace disconnect.
|
|
void Function() registerBeforePause(Future<void> Function() callback) {
|
|
final owner = Object();
|
|
_beforePauseCallbacks[owner] = callback;
|
|
return () => _beforePauseCallbacks.remove(owner);
|
|
}
|
|
|
|
/// Called by the app lifecycle provider when the app goes to background.
|
|
void onAppPaused() {
|
|
_backgroundedAt = _now();
|
|
_backgroundGraceTimer?.cancel();
|
|
_backgroundGraceTimer = Timer(_backgroundGraceDuration, () {
|
|
unawaited(_pauseAfterCallbacks());
|
|
});
|
|
}
|
|
|
|
Future<void> _pauseAfterCallbacks() async {
|
|
final callbacks = _beforePauseCallbacks.values.toList();
|
|
try {
|
|
await Future.wait(callbacks.map((callback) => callback()));
|
|
} catch (error) {
|
|
debugPrint('Background cleanup failed: $error');
|
|
}
|
|
if (_backgroundedAt != null) _pauseNow();
|
|
}
|
|
|
|
void _pauseNow() {
|
|
_paused = true;
|
|
_socketConnected = false;
|
|
_reconnectTimer?.cancel();
|
|
_cancelAllHistory(Exception('App moved to background'));
|
|
_rejectAllPending(Exception('App moved to background'));
|
|
_socket?.disconnect();
|
|
state = const SessionState(status: SessionStatus.disconnected);
|
|
}
|
|
|
|
/// Called by the app lifecycle provider when the app returns to foreground.
|
|
void onAppResumed() {
|
|
_paused = false;
|
|
final backgroundedAt = _backgroundedAt;
|
|
_backgroundedAt = null;
|
|
_backgroundGraceTimer?.cancel();
|
|
_backgroundGraceTimer = null;
|
|
|
|
final backgroundedLongEnoughToRequireReconnect =
|
|
backgroundedAt != null &&
|
|
_now().difference(backgroundedAt) >= _backgroundGraceDuration;
|
|
if (!backgroundedLongEnoughToRequireReconnect &&
|
|
state.status == SessionStatus.connected) {
|
|
return;
|
|
}
|
|
|
|
// Cancel any in-flight reconnect backoff timer so we reconnect immediately
|
|
// instead of waiting for the (possibly large) exponential delay.
|
|
_reconnectTimer?.cancel();
|
|
_reconnectDelayMs = _baseReconnectDelayMs;
|
|
final config = ref.read(relayConfigProvider);
|
|
_connect(config);
|
|
}
|
|
|
|
Future<void> _connect(RelayConfig config, {bool renewed = false}) async {
|
|
if (_disposed || _ageRestricted) return;
|
|
|
|
// Whatever connection came before ends here, capped at its last frame.
|
|
_markLiveSubscriptionsClosed();
|
|
_connectedAt = null;
|
|
final generation = ++_connectionGeneration;
|
|
// A replacement socket is unauthenticated until its AUTH completes.
|
|
_socketConnected = false;
|
|
state = SessionState(
|
|
status: _hasConnectedOnce
|
|
? SessionStatus.reconnecting
|
|
: SessionStatus.connecting,
|
|
reconnectAttempt: state.reconnectAttempt,
|
|
);
|
|
|
|
_socket?.dispose();
|
|
late final RelaySocket socket;
|
|
socket = _socketFactory(
|
|
wsUrl: config.wsUrl,
|
|
nsec: config.nsec,
|
|
onMessage: (message) {
|
|
if (generation == _connectionGeneration) _handleMessage(message);
|
|
},
|
|
onConnected: () => _handleConnected(generation),
|
|
onDisconnected: (error) {
|
|
if (_disposed || generation != _connectionGeneration) return;
|
|
final auth = ref.read(hostedRelayAuthProvider);
|
|
if (auth.protects(config) && relayUpgradeRefused(error)) {
|
|
auth.rejected(
|
|
config,
|
|
socket.headers['Nostr-Federated-Identity'],
|
|
terminal: renewed,
|
|
);
|
|
if (!renewed) {
|
|
unawaited(_connect(config, renewed: true));
|
|
return;
|
|
}
|
|
error = AccountFailure.denied;
|
|
}
|
|
_handleDisconnected(generation, error, closeCode: socket.closeCode);
|
|
},
|
|
);
|
|
_socket = socket;
|
|
|
|
try {
|
|
final auth = ref.read(hostedRelayAuthProvider);
|
|
if (auth.protects(config)) socket.headers = await auth.headers(config);
|
|
if (_disposed || generation != _connectionGeneration) return;
|
|
await socket.connect();
|
|
} on AccountFailure catch (error) {
|
|
_handleDisconnected(generation, error);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleConnected(int generation) async {
|
|
if (_disposed || generation != _connectionGeneration) return;
|
|
_socketConnected = true;
|
|
ref.read(hostedRelayAuthProvider).connected(ref.read(relayConfigProvider));
|
|
_hasConnectedOnce = true;
|
|
// The backoff is not reset here: a client bounced repeatedly must keep
|
|
// backing off. [_handleDisconnected] resets it after a stable connection.
|
|
_connectedAt = _now();
|
|
_lastInboundAt = _connectedAt;
|
|
state = const SessionState(status: SessionStatus.connected);
|
|
await _replayLiveSubscriptions(generation);
|
|
}
|
|
|
|
void _handleDisconnected(int generation, Object? error, {int? closeCode}) {
|
|
if (_disposed || generation != _connectionGeneration) return;
|
|
_socketConnected = false;
|
|
final connectedAt = _connectedAt;
|
|
_connectedAt = null;
|
|
if (connectedAt != null &&
|
|
isRelayConnectionStable(_now().difference(connectedAt))) {
|
|
_reconnectDelayMs = _baseReconnectDelayMs;
|
|
}
|
|
_cancelAllHistory(error);
|
|
_rejectAllPending(error);
|
|
_resetAllClosedRetries();
|
|
_eventBuffer.clear();
|
|
_flushTimer?.cancel();
|
|
_flushTimer = null;
|
|
if (error is RelayAuthRejectedException ||
|
|
(error is AccountFailure && error != AccountFailure.unavailable)) {
|
|
_reconnectTimer?.cancel();
|
|
state = const SessionState(status: SessionStatus.disconnected);
|
|
return;
|
|
}
|
|
_scheduleReconnect(closeCode);
|
|
}
|
|
|
|
void _scheduleReconnect(int? closeCode) {
|
|
if (_disposed || _paused) return;
|
|
final attempt = state.reconnectAttempt + 1;
|
|
state = SessionState(
|
|
status: SessionStatus.reconnecting,
|
|
reconnectAttempt: attempt,
|
|
);
|
|
|
|
_reconnectTimer?.cancel();
|
|
final delay = relayReconnectDelay(
|
|
backoffMs: _reconnectDelayMs,
|
|
closeCode: closeCode,
|
|
jitterUnit: _random.nextDouble(),
|
|
spreadUnit: _random.nextDouble(),
|
|
);
|
|
_reconnectTimer = _reconnectTimerFactory(delay, () {
|
|
_reconnectDelayMs = min(_reconnectDelayMs * 2, _maxReconnectDelayMs);
|
|
final config = ref.read(relayConfigProvider);
|
|
_connect(config);
|
|
});
|
|
}
|
|
|
|
/// Records when each subscription that was live on the closing connection
|
|
/// stopped being live, so the next replay can resume from there.
|
|
///
|
|
/// Liveness ends at the last frame received, not at the close: after a
|
|
/// device sleep the loss is only noticed on wake, and nothing arrived in
|
|
/// between.
|
|
void _markLiveSubscriptionsClosed() {
|
|
final now = _now();
|
|
final inbound = _lastInboundAt;
|
|
final end = inbound == null || inbound.isAfter(now) ? now : inbound;
|
|
final closedAt = end.millisecondsSinceEpoch ~/ 1000;
|
|
for (final subscription in _liveSubscriptions.values) {
|
|
if (!subscription.liveOnConnection) continue;
|
|
subscription.liveOnConnection = false;
|
|
subscription.liveUntil = closedAt;
|
|
}
|
|
}
|
|
|
|
/// Replay all live subscriptions after a reconnect, with a time skew to
|
|
/// catch events that occurred during the disconnect.
|
|
Future<void> _replayLiveSubscriptions(int generation) async {
|
|
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
|
|
if (!_isActiveConnection(generation)) return;
|
|
_sendHeldHistory();
|
|
|
|
final entries = _liveSubscriptions.entries.toList();
|
|
final visibleChannelId = _visibleChannelsByOwner.isEmpty
|
|
? null
|
|
: _visibleChannelsByOwner.values.last;
|
|
if (visibleChannelId != null) {
|
|
entries.sort((left, right) {
|
|
final leftVisible =
|
|
left.value.filter.tags['#h']?.contains(visibleChannelId) ?? false;
|
|
final rightVisible =
|
|
right.value.filter.tags['#h']?.contains(visibleChannelId) ?? false;
|
|
if (leftVisible == rightVisible) return 0;
|
|
return leftVisible ? -1 : 1;
|
|
});
|
|
}
|
|
|
|
await _sendReplayBatches(entries, generation);
|
|
}
|
|
|
|
Future<void> _replayPendingClosedRetries(int generation) async {
|
|
if (!_isActiveConnection(generation)) return;
|
|
final entries = _pendingClosedRetries.entries
|
|
.where((entry) => entry.value.generation == generation)
|
|
.map(
|
|
(entry) => MapEntry<String, _LiveSubscription>(
|
|
entry.key,
|
|
entry.value.subscription,
|
|
),
|
|
)
|
|
.toList();
|
|
await _sendReplayBatches(entries, generation, pendingClosedRetries: true);
|
|
}
|
|
|
|
Future<void> _sendReplayBatches(
|
|
List<MapEntry<String, _LiveSubscription>> entries,
|
|
int generation, {
|
|
bool pendingClosedRetries = false,
|
|
}) async {
|
|
for (var i = 0; i < entries.length; i += _replayBatchSize) {
|
|
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
|
|
if (!_isActiveConnection(generation)) return;
|
|
final batch = entries.sublist(
|
|
i,
|
|
min(i + _replayBatchSize, entries.length),
|
|
);
|
|
for (final entry in batch) {
|
|
if (_liveSubscriptions[entry.key] != entry.value) continue;
|
|
if (pendingClosedRetries) {
|
|
final pendingRetry = _pendingClosedRetries[entry.key];
|
|
if (pendingRetry?.subscription != entry.value ||
|
|
pendingRetry?.generation != generation) {
|
|
continue;
|
|
}
|
|
_pendingClosedRetries.remove(entry.key);
|
|
}
|
|
_sendLiveReq(entry.key, entry.value);
|
|
}
|
|
if (i + _replayBatchSize < entries.length) {
|
|
await _replayDelay(_replayInterBatchDelay);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _isActiveConnection(int generation) =>
|
|
!_disposed && generation == _connectionGeneration;
|
|
|
|
NostrFilter _replayFilter(_LiveSubscription subscription) {
|
|
final filter = subscription.filter;
|
|
final since = relayResumeSince(
|
|
originalSince: filter.since,
|
|
until: filter.until,
|
|
lastSeenCreatedAt: subscription.lastSeenCreatedAt,
|
|
liveUntil: subscription.liveUntil,
|
|
);
|
|
return since == null || since == filter.since
|
|
? filter
|
|
: filter.copyWithSince(since);
|
|
}
|
|
|
|
void _handleMessage(List<dynamic> data) {
|
|
_lastInboundAt = _now();
|
|
if (data.isEmpty) return;
|
|
final type = data[0] as String;
|
|
|
|
switch (type) {
|
|
case 'EVENT':
|
|
_handleEvent(data);
|
|
case 'EOSE':
|
|
_handleEose(data);
|
|
case 'CLOSED':
|
|
_handleClosed(data);
|
|
case 'OK':
|
|
_handleOk(data);
|
|
}
|
|
}
|
|
|
|
void _handleEvent(List<dynamic> data) {
|
|
if (data.length < 3) return;
|
|
final subId = data[1] as String;
|
|
final eventJson = data[2] as Map<String, dynamic>;
|
|
final event = NostrEvent.fromJson(eventJson);
|
|
|
|
// History subscriptions accumulate immediately.
|
|
final historySub = _historySubscriptions[subId];
|
|
if (historySub != null) {
|
|
historySub.events.add(event);
|
|
return;
|
|
}
|
|
|
|
// Live subscriptions get batched. An EVENT proves the stream is active,
|
|
// but not that a retry replay is complete; only EOSE is that boundary.
|
|
final liveSub = _liveSubscriptions[subId];
|
|
if (liveSub != null) {
|
|
_resetClosedRetry(liveSub);
|
|
// Track last seen timestamp for reconnect replay.
|
|
if (liveSub.lastSeenCreatedAt == null ||
|
|
event.createdAt > liveSub.lastSeenCreatedAt!) {
|
|
liveSub.lastSeenCreatedAt = event.createdAt;
|
|
}
|
|
_eventBuffer.add(_BufferedEvent(subId, event));
|
|
_scheduleFlush();
|
|
}
|
|
}
|
|
|
|
void _handleEose(List<dynamic> data) {
|
|
if (data.length < 2) return;
|
|
final subId = data[1] as String;
|
|
|
|
// History subscription: resolve with collected events.
|
|
final historySub = _historySubscriptions.remove(subId);
|
|
if (historySub != null) {
|
|
historySub.timeout.cancel();
|
|
if (!historySub.completer.isCompleted) {
|
|
historySub.completer.complete(historySub.events);
|
|
}
|
|
_sendClose(subId);
|
|
return;
|
|
}
|
|
|
|
// Live subscription: flush replay callbacks before signaling ready. This
|
|
// ordering matters for retry replays, whose original ready completer has
|
|
// already been released.
|
|
final liveSub = _liveSubscriptions[subId];
|
|
if (liveSub != null) {
|
|
// A second REQ on this socket makes its EOSE ambiguous: it may belong
|
|
// to the REQ it replaced, so it cannot vouch for the replacement.
|
|
liveSub.liveOnConnection =
|
|
liveSub.ambiguousGeneration != _connectionGeneration;
|
|
_resetClosedRetry(liveSub);
|
|
_flushBufferedEventsNow();
|
|
liveSub.onStatusChanged?.call(RelaySubscriptionStatus.ready);
|
|
}
|
|
if (liveSub != null &&
|
|
liveSub.readyCompleter != null &&
|
|
!liveSub.readyCompleter!.isCompleted) {
|
|
liveSub.readyCompleter!.complete();
|
|
liveSub.readyCompleter = null;
|
|
}
|
|
}
|
|
|
|
void _handleClosed(List<dynamic> data) {
|
|
if (data.length < 2) return;
|
|
final subId = data[1] as String;
|
|
final message = data.length >= 3 && data[2] is String
|
|
? data[2] as String
|
|
: 'subscription closed by relay';
|
|
final closedClass = classifyRelayClosed(message);
|
|
|
|
final historySub = _historySubscriptions.remove(subId);
|
|
if (historySub != null) {
|
|
if (closedClass == RelayClosedClass.rateLimited) {
|
|
_rateLimitGate.activate(parseRateLimitRetrySeconds(message));
|
|
}
|
|
historySub.timeout.cancel();
|
|
if (!historySub.completer.isCompleted) {
|
|
historySub.completer.completeError(Exception(message));
|
|
}
|
|
return;
|
|
}
|
|
|
|
final liveSub = _liveSubscriptions[subId];
|
|
if (liveSub == null) return;
|
|
final readyCompleter = liveSub.readyCompleter;
|
|
// The stream is interrupted until the retry's EOSE.
|
|
liveSub.liveOnConnection = false;
|
|
// A deadline is terminal for one-shot history, but a live subscription
|
|
// retries it: same filter under backoff, which can keep retrying at the
|
|
// cap. `since` is not advanced, so events missed while closed aren't
|
|
// skipped.
|
|
if (closedClass == RelayClosedClass.terminal &&
|
|
!isRelayDeadlineError(message)) {
|
|
if (readyCompleter != null && !readyCompleter.isCompleted) {
|
|
readyCompleter.completeError(Exception(message));
|
|
}
|
|
liveSub.onClosed?.call(message);
|
|
_removeLiveSubscription(subId, liveSub);
|
|
return;
|
|
}
|
|
if (readyCompleter != null && !readyCompleter.isCompleted) {
|
|
readyCompleter.complete();
|
|
liveSub.readyCompleter = null;
|
|
}
|
|
liveSub.onStatusChanged?.call(RelaySubscriptionStatus.retrying);
|
|
if (liveSub.closedRetryTimer != null) return;
|
|
|
|
final attempt = liveSub.closedRetryAttempt;
|
|
final backoffMs = attempt >= 5
|
|
? _maxReconnectDelayMs
|
|
: _baseReconnectDelayMs * (1 << attempt);
|
|
var delayMs = backoffMs;
|
|
if (closedClass == RelayClosedClass.rateLimited) {
|
|
final retrySeconds = parseRateLimitRetrySeconds(message);
|
|
_rateLimitGate.activate(retrySeconds);
|
|
final fallbackMs =
|
|
(retrySeconds != null
|
|
? min(retrySeconds, RelayRateLimitGate.maxRetrySeconds)
|
|
: RelayRateLimitGate.defaultRetrySeconds) *
|
|
1000;
|
|
delayMs = max(
|
|
backoffMs,
|
|
_rateLimitGate.remainingMs() == 0
|
|
? fallbackMs
|
|
: _rateLimitGate.remainingMs(),
|
|
);
|
|
}
|
|
|
|
liveSub.closedRetryAttempt = attempt + 1;
|
|
final retryGeneration = _connectionGeneration;
|
|
liveSub.closedRetryTimer = _retryTimerFactory(
|
|
Duration(milliseconds: delayMs),
|
|
() async {
|
|
liveSub.closedRetryTimer = null;
|
|
if (!_isActiveConnection(retryGeneration) ||
|
|
_liveSubscriptions[subId] != liveSub) {
|
|
return;
|
|
}
|
|
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
|
|
if (!_isActiveConnection(retryGeneration) ||
|
|
_liveSubscriptions[subId] != liveSub ||
|
|
!_socketConnected) {
|
|
return;
|
|
}
|
|
_pendingClosedRetries[subId] = _ClosedRetry(
|
|
subscription: liveSub,
|
|
generation: retryGeneration,
|
|
);
|
|
_scheduleClosedRetryReplay(retryGeneration);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _scheduleClosedRetryReplay(int generation) {
|
|
if (_closedRetryReplayScheduled) return;
|
|
_closedRetryReplayScheduled = true;
|
|
scheduleMicrotask(() async {
|
|
try {
|
|
await _replayPendingClosedRetries(generation);
|
|
} finally {
|
|
_closedRetryReplayScheduled = false;
|
|
_pendingClosedRetries.removeWhere(
|
|
(_, retry) => retry.generation != _connectionGeneration,
|
|
);
|
|
if (_pendingClosedRetries.values.any(
|
|
(retry) => retry.generation == _connectionGeneration,
|
|
)) {
|
|
_scheduleClosedRetryReplay(_connectionGeneration);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
void _handleOk(List<dynamic> data) {
|
|
if (data.length < 3) return;
|
|
final eventId = data[1] as String;
|
|
final accepted = data[2] as bool;
|
|
final message = data.length > 3 && data[3] is String
|
|
? data[3] as String
|
|
: '';
|
|
|
|
final pending = _pendingEvents.remove(eventId);
|
|
if (pending == null) return;
|
|
pending.timeout.cancel();
|
|
|
|
if (accepted) {
|
|
// We don't have the full event here; create a minimal placeholder.
|
|
// Command kinds (e.g. 41010, 30620, 46020) return "response:{...}" in
|
|
// the OK message — preserve it in `content` so callers can parse it.
|
|
if (!pending.completer.isCompleted) {
|
|
pending.completer.complete(
|
|
NostrEvent(
|
|
id: eventId,
|
|
pubkey: '',
|
|
createdAt: 0,
|
|
kind: 0,
|
|
tags: [],
|
|
content: message,
|
|
sig: '',
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
// Back-pressure now arrives here rather than as a NOTICE: the relay
|
|
// rejects an over-quota EVENT on the OK channel so this pending publish
|
|
// can be settled at all. Without arming the gate the send would fail
|
|
// without ever backing off.
|
|
if (message.startsWith('rate-limited:')) {
|
|
_rateLimitGate.activate(parseRateLimitRetrySeconds(message));
|
|
}
|
|
if (!pending.completer.isCompleted) {
|
|
pending.completer.completeError(
|
|
Exception(message.isNotEmpty ? message : 'Event rejected'),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _scheduleFlush() {
|
|
_flushTimer ??= Timer(
|
|
const Duration(milliseconds: _eventBatchMs),
|
|
_flushEventBuffer,
|
|
);
|
|
}
|
|
|
|
void _flushBufferedEventsNow() {
|
|
_flushTimer?.cancel();
|
|
_flushTimer = null;
|
|
_flushEventBuffer();
|
|
}
|
|
|
|
void _flushEventBuffer() {
|
|
_flushTimer = null;
|
|
if (_eventBuffer.isEmpty) return;
|
|
|
|
final batch = List<_BufferedEvent>.from(_eventBuffer);
|
|
_eventBuffer.clear();
|
|
|
|
for (final buffered in batch) {
|
|
final sub = _liveSubscriptions[buffered.subId];
|
|
if (sub == null) continue;
|
|
|
|
// Deduplicate per subscription. The same relay event can legitimately
|
|
// match multiple live subscriptions, e.g. the channel list unread listener
|
|
// and the open channel message listener.
|
|
final deliveryKey = '${buffered.subId}:${buffered.event.id}';
|
|
if (_recentDeliveryKeys.contains(deliveryKey)) continue;
|
|
|
|
// Cap the dedup set to prevent unbounded memory growth.
|
|
if (_recentDeliveryKeys.length >= _maxRecentDeliveryKeys) {
|
|
_recentDeliveryKeys.clear();
|
|
}
|
|
_recentDeliveryKeys.add(deliveryKey);
|
|
|
|
sub.onEvent(buffered.event);
|
|
}
|
|
}
|
|
|
|
String _nextSubId(String prefix) {
|
|
_subIdCounter++;
|
|
return '$prefix-$_subIdCounter';
|
|
}
|
|
|
|
void _sendLiveReq(String subId, _LiveSubscription subscription) {
|
|
if (subscription.reqGeneration == _connectionGeneration) {
|
|
subscription.ambiguousGeneration = _connectionGeneration;
|
|
}
|
|
subscription.reqGeneration = _connectionGeneration;
|
|
_sendReq(subId, _replayFilter(subscription));
|
|
}
|
|
|
|
void _sendReq(String subId, NostrFilter filter) {
|
|
_socket?.send(['REQ', subId, filter.toJson()]);
|
|
}
|
|
|
|
void _sendHeldHistory() {
|
|
for (final MapEntry(key: subId, value: sub)
|
|
in _historySubscriptions.entries) {
|
|
final filter = sub.heldFilter;
|
|
if (filter == null) continue;
|
|
sub.heldFilter = null;
|
|
_sendReq(subId, filter);
|
|
}
|
|
}
|
|
|
|
void _sendClose(String subId) {
|
|
_socket?.send(['CLOSE', subId]);
|
|
}
|
|
|
|
void _unsubscribe(String subId) {
|
|
final subscription = _liveSubscriptions[subId];
|
|
if (subscription != null) {
|
|
_removeLiveSubscription(subId, subscription);
|
|
}
|
|
_sendClose(subId);
|
|
}
|
|
|
|
void _removeLiveSubscription(String subId, _LiveSubscription subscription) {
|
|
if (_liveSubscriptions[subId] != subscription) return;
|
|
_liveSubscriptions.remove(subId);
|
|
_pendingClosedRetries.remove(subId);
|
|
subscription.closedRetryTimer?.cancel();
|
|
subscription.closedRetryTimer = null;
|
|
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
|
|
}
|
|
|
|
void _resetClosedRetry(_LiveSubscription subscription) {
|
|
subscription.closedRetryAttempt = 0;
|
|
subscription.closedRetryTimer?.cancel();
|
|
subscription.closedRetryTimer = null;
|
|
}
|
|
|
|
void _cancelAllClosedRetries() {
|
|
_pendingClosedRetries.clear();
|
|
for (final subscription in _liveSubscriptions.values) {
|
|
subscription.closedRetryTimer?.cancel();
|
|
subscription.closedRetryTimer = null;
|
|
}
|
|
}
|
|
|
|
void _resetAllClosedRetries() {
|
|
_pendingClosedRetries.clear();
|
|
for (final subscription in _liveSubscriptions.values) {
|
|
_resetClosedRetry(subscription);
|
|
}
|
|
}
|
|
|
|
void _cancelAllHistory(Object? error) {
|
|
for (final entry in _historySubscriptions.values) {
|
|
entry.timeout.cancel();
|
|
if (!entry.completer.isCompleted) {
|
|
entry.completer.completeError(error ?? Exception('Connection lost'));
|
|
}
|
|
}
|
|
_historySubscriptions.clear();
|
|
}
|
|
|
|
void _rejectAllPending(Object? error) {
|
|
for (final entry in _pendingEvents.values) {
|
|
entry.timeout.cancel();
|
|
if (!entry.completer.isCompleted) {
|
|
entry.completer.completeError(error ?? Exception('Connection lost'));
|
|
}
|
|
}
|
|
_pendingEvents.clear();
|
|
}
|
|
|
|
void _dispose() {
|
|
_disposed = true;
|
|
_contextGeneration++;
|
|
_beforePauseCallbacks.clear();
|
|
_connectionGeneration++;
|
|
_reconnectTimer?.cancel();
|
|
_flushTimer?.cancel();
|
|
_backgroundGraceTimer?.cancel();
|
|
_backgroundedAt = null;
|
|
_cancelAllClosedRetries();
|
|
_rateLimitGate.reset();
|
|
_visibleChannelsByOwner.clear();
|
|
_socketConnected = false;
|
|
_cancelAllHistory(null);
|
|
_rejectAllPending(null);
|
|
final subscriptions = _liveSubscriptions.values.toList();
|
|
_liveSubscriptions.clear();
|
|
for (final subscription in subscriptions) {
|
|
subscription.closedRetryTimer?.cancel();
|
|
subscription.closedRetryTimer = null;
|
|
}
|
|
_recentDeliveryKeys.clear();
|
|
_socket?.dispose();
|
|
_socket = null;
|
|
_httpQueryClient.close();
|
|
}
|
|
}
|
|
|
|
final relaySessionProvider =
|
|
NotifierProvider<RelaySessionNotifier, SessionState>(
|
|
RelaySessionNotifier.new,
|
|
);
|