mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
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>
138 lines
5.0 KiB
Dart
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;
|
|
});
|