Files
buzz/mobile/lib/shared/relay/relay_provider.dart
Tom Brow 4af2324d85 fix(mobile): keep relay sessions stable during push lease updates (#7745)
Enabling push notifications could repeatedly reset the relay session:
reserving a lease generation updates community metadata, which rebuilt
the relay configuration even though the connection identity was
unchanged. This made the avatar and inbox flash and prevented the new
lease from publishing.

Select only the community ID, relay URL, and signing identity as
configuration dependencies. Push bookkeeping keeps the session intact;
real community, URL, and identity changes still notify listeners.

### Validation
The production-provider regression test failed before the fix and passes
afterward; 48 focused mobile tests and static analysis pass.

A personal signed profile build on a physical iPhone published a fresh
lease and received a background notification with the actual message
text. The user confirmed the flashing stopped and tapping the
notification opened the correct community/channel and highlighted the
exact message.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-18 12:10:26 -07:00

138 lines
5.0 KiB
Dart

import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../community/community_provider.dart';
import 'relay_client.dart';
/// Relay connection configuration.
///
/// In the pure-nostr world the only secrets the app cares about are:
/// - `baseUrl` — where the relay lives (used for WS + media upload)
/// - `nsec` — the user's signing key (drives NIP-42 AUTH and event sigs)
class RelayConfig {
const RelayConfig({required String baseUrl, this.nsec}) : _baseUrl = baseUrl;
/// Relay origin exactly as the active community stored it.
final String _baseUrl;
/// Nostr secret key (bech32 nsec) for signing events and NIP-42 AUTH.
final String? nsec;
/// The origin as persisted, before scheme canonicalization.
///
/// Exists solely so identity-scoped storage keys written before [baseUrl]
/// was canonicalized stay reachable — see [readMigratedPref]. Never use it
/// for network I/O; [baseUrl] and [wsUrl] are the addresses to connect to.
String get storedOrigin => _baseUrl;
/// Relay origin as an HTTP(S) URL.
///
/// Communities are persisted with whichever scheme their onboarding flow
/// used: device pairing stores `https://` (it rejects anything else), while
/// an invite join stores the `wss://` relay URL carried by the invite link.
/// Every consumer treats this as an HTTP origin — [wsUrl], the `/query`
/// endpoint, media upload and Blossom auth — so a `wss://` base silently
/// degrades all of them. Folding the websocket schemes back here keeps both
/// onboarding paths equivalent, including for already-persisted communities.
///
/// Derived rather than normalized in the constructor so that the constructor
/// stays `const`: the compile-time fallback below relies on canonicalization
/// to keep its identity stable across rebuilds, and Riverpod's default
/// `updateShouldNotify` is `previous != next`, which falls back to identity
/// here. A fresh instance per rebuild would resubscribe every listener.
String get baseUrl {
final uri = Uri.tryParse(_baseUrl);
if (uri == null) return _baseUrl;
final scheme = switch (uri.scheme) {
'wss' => 'https',
'ws' => 'http',
_ => null,
};
return scheme == null ? _baseUrl : uri.replace(scheme: scheme).toString();
}
/// Derive the websocket URL from the HTTP base URL.
String get wsUrl {
final uri = Uri.parse(baseUrl);
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
return uri.replace(scheme: scheme).toString();
}
}
/// Compile-time environment config via --dart-define.
///
/// Run with:
/// flutter run --dart-define=BUZZ_RELAY_URL=http://localhost:3000 \
/// --dart-define=BUZZ_PUSH_GATEWAY_URL=http://localhost:8080
///
/// Or create a `.env.json` and use --dart-define-from-file=.env.json
class Env {
static const relayUrl = String.fromEnvironment(
'BUZZ_RELAY_URL',
defaultValue: 'http://localhost:3000',
);
/// Optional gateway origin. Without it this artifact has no push capability.
static const pushGatewayUrl = String.fromEnvironment('BUZZ_PUSH_GATEWAY_URL');
/// Whether this artifact can offer push notification enrollment.
static const pushGatewayConfigured = pushGatewayUrl != '';
}
class RelayConfigNotifier extends Notifier<RelayConfig> {
@override
RelayConfig build() {
// Push lease bookkeeping updates the community while using its live
// session. Only connection-context changes may restart that session.
final context = ref.watch(
activeCommunityProvider.select((value) {
final active = value.value;
return (id: active?.id, relayUrl: active?.relayUrl, nsec: active?.nsec);
}),
);
if (context.relayUrl != null) {
return RelayConfig(baseUrl: context.relayUrl!, nsec: context.nsec);
}
// Fallback to compile-time env config (dev mode).
return const RelayConfig(baseUrl: Env.relayUrl);
}
void update({required String baseUrl, String? nsec}) {
state = RelayConfig(baseUrl: baseUrl, nsec: nsec);
}
}
final relayConfigProvider = NotifierProvider<RelayConfigNotifier, RelayConfig>(
RelayConfigNotifier.new,
);
/// Derive the hex pubkey from a bech32 nsec, or null on any failure.
String? pubkeyFromNsec(String? nsec) {
if (nsec == null || nsec.isEmpty) return null;
try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
} catch (_) {
return null;
}
}
/// The current user's hex pubkey, derived from the active community nsec.
final myPubkeyProvider = Provider<String?>((ref) {
final config = ref.watch(relayConfigProvider);
return pubkeyFromNsec(config.nsec);
});
/// Provides a [RelayClient] that reacts to config changes.
///
/// Only used for the media upload HTTP endpoint now — all data flow goes
/// through the relay WebSocket session.
final relayClientProvider = Provider<RelayClient>((ref) {
final config = ref.watch(relayConfigProvider);
final client = RelayClient(baseUrl: config.baseUrl);
ref.onDispose(client.dispose);
return client;
});