Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions mobile/lib/shared/community/community.dart
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,37 @@ class Community {
);
}

@override
bool operator ==(Object other) =>
other is Community &&
other.id == id &&
other.name == name &&
other.relayUrl == relayUrl &&
other.pubkey == pubkey &&
other.nsec == nsec &&
other.sensitiveActionPolicy == sensitiveActionPolicy &&
other.pushNotificationsEnabled == pushNotificationsEnabled &&
other.pushLeaseInstallationId == pushLeaseInstallationId &&
other.starterSetupIncomplete == starterSetupIncomplete &&
other.addedAt == addedAt &&
buzzPushSubscriptionStateFingerprint(other.pushSubscriptionState) ==
buzzPushSubscriptionStateFingerprint(pushSubscriptionState);

@override
int get hashCode => Object.hash(
id,
name,
relayUrl,
pubkey,
nsec,
sensitiveActionPolicy,
pushNotificationsEnabled,
pushLeaseInstallationId,
starterSetupIncomplete,
addedAt,
buzzPushSubscriptionStateFingerprint(pushSubscriptionState),
);

Map<String, dynamic> toJson() => {
'id': id,
'name': name,
Expand Down
9 changes: 8 additions & 1 deletion mobile/lib/shared/read_state/read_state_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,14 @@ class ReadStateNotifier extends Notifier<ReadStateState> {

final relayConfig = ref.watch(relayConfigProvider);
ref.watch(relaySessionProvider);
final activeCommunity = ref.watch(activeCommunityProvider).value;
// Selected, not watched whole: activeCommunityProvider is a FutureProvider,
// so every recompute passes through AsyncLoading before AsyncData. Watching
// the AsyncValue rebuilds on that transition alone, however equal the
// resulting Community is. Selecting the value compares Community to
// Community, which is why it also needs Community to define ==.
final activeCommunity = ref.watch(
activeCommunityProvider.select((community) => community.value),
);

final nsec = relayConfig.nsec?.trim();
if (nsec == null || nsec.isEmpty) {
Expand Down
21 changes: 19 additions & 2 deletions mobile/lib/shared/relay/relay_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ class RelayConfig {
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
return uri.replace(scheme: scheme).toString();
}

/// Value equality, because the identity fallback documented on [baseUrl] only
/// holds for the const fallback. The community-derived config is built fresh
/// on every rebuild, so identity made it a new value every time — and
/// RelaySessionNotifier watches this provider, disposes its socket on rebuild
/// and reconnects, so an unchanged config was tearing the relay session down.
@override
bool operator ==(Object other) =>
other is RelayConfig && other._baseUrl == _baseUrl && other.nsec == nsec;

@override
int get hashCode => Object.hash(_baseUrl, nsec);
}

/// Compile-time environment config via --dart-define.
Expand All @@ -82,8 +94,13 @@ class RelayConfigNotifier extends Notifier<RelayConfig> {
RelayConfig build() {
// Watch the active community so that when it changes (community switch),
// the config rebuilds, triggering the full provider cascade.
final activeAsync = ref.watch(activeCommunityProvider);
final active = activeAsync.value;
// Selected, not watched whole: activeCommunityProvider is a FutureProvider,
// so every recompute passes through AsyncLoading and would rebuild this
// config — and with it the relay session — even when the community is
// unchanged.
final active = ref.watch(
activeCommunityProvider.select((community) => community.value),
);
if (active != null) {
return RelayConfig(baseUrl: active.relayUrl, nsec: active.nsec);
}
Expand Down
12 changes: 12 additions & 0 deletions mobile/lib/shared/relay/relay_session_types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ class SessionState {
final int reconnectAttempt;

const SessionState({required this.status, this.reconnectAttempt = 0});

@override
bool operator ==(Object other) =>
other is SessionState &&
other.status == status &&
other.reconnectAttempt == reconnectAttempt;

@override
int get hashCode => Object.hash(status, reconnectAttempt);

@override
String toString() => 'SessionState($status, attempt: $reconnectAttempt)';
}

/// Recovery lifecycle for a live relay subscription.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:buzz/shared/read_state/read_state_format.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme_provider.dart';
Expand Down Expand Up @@ -121,6 +122,59 @@ void main() {
expect(state().isForcedUnread(msgKey), isTrue);
expect(state().locallyForcedChannelIds, {channelId});
});

test('a re-emitted equal community does not rebuild read state', () async {
// The production loop this pins: reservePushLeaseGeneration saves the
// community on every publish attempt, so activeCommunityProvider re-emits a
// NEW Community object carrying identical values. ReadStateNotifier.build()
// disposes and recreates its manager, so without Community.== every publish
// attempt tore down the relay work that attempt depended on.
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final nsec = nostr.Keys.generate().nsec;
var communityName = 'Buzz';

final localContainer = ProviderContainer(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
relayConfigProvider.overrideWith(() => _FakeRelayConfig(nsec)),
relaySessionProvider.overrideWith(_FakeRelaySession.new),
activeCommunityProvider.overrideWith(
(ref) async => Community(
id: 'community-1',
name: communityName,
relayUrl: 'https://relay.test',
addedAt: DateTime.utc(2026, 8, 5),
),
),
appLifecycleProvider.overrideWith(_FakeAppLifecycle.new),
],
);
addTearDown(localContainer.dispose);

var rebuilds = 0;
final sub = localContainer.listen(
readStateProvider,
(_, _) => rebuilds += 1,
);
addTearDown(sub.close);
await localContainer.read(activeCommunityProvider.future);
await Future<void>.delayed(Duration.zero);
rebuilds = 0;

// Force a fresh Community object carrying identical values.
localContainer.invalidate(activeCommunityProvider);
await localContainer.read(activeCommunityProvider.future);
await Future<void>.delayed(Duration.zero);
expect(rebuilds, 0);

// A genuine change must still rebuild.
communityName = 'Renamed';
localContainer.invalidate(activeCommunityProvider);
await localContainer.read(activeCommunityProvider.future);
await Future<void>.delayed(Duration.zero);
expect(rebuilds, greaterThan(0));
});
}

class _FakeRelayConfig extends RelayConfigNotifier {
Expand Down
58 changes: 58 additions & 0 deletions mobile/test/shared/community/community_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,62 @@ void main() {
throwsFormatException,
);
});

group('value equality', () {
Community sample() => Community(
id: 'one',
name: 'Buzz',
relayUrl: 'https://relay.test',
pubkey: 'a' * 64,
nsec: 'nsec1test',
pushNotificationsEnabled: true,
pushLeaseInstallationId: '0' * 32,
addedAt: DateTime.utc(2026, 8, 5),
);

test('two separately built identical communities compare equal', () {
final a = sample();
final b = sample();

expect(identical(a, b), isFalse);
expect(a, b);
expect(a.hashCode, b.hashCode);
});

test('a copyWith that changes nothing compares equal', () {
// This is the regression. reservePushLeaseGeneration saves the community
// on every publish attempt; without ==, each save emitted a new object,
// rebuilt every watcher of activeCommunityProvider — including
// ReadStateNotifier, which disposes and recreates its manager in build()
// — and the resulting relay churn failed the publish that triggered it.
final a = sample();
expect(a.copyWith(), a);
});

test('each field participates in equality', () {
final a = sample();

expect(a.copyWith(name: 'Other'), isNot(a));
expect(a.copyWith(relayUrl: 'https://other.test'), isNot(a));
expect(a.copyWith(pubkey: 'b' * 64), isNot(a));
expect(a.copyWith(nsec: 'nsec1other'), isNot(a));
expect(a.copyWith(pushNotificationsEnabled: false), isNot(a));
expect(a.copyWith(starterSetupIncomplete: true), isNot(a));
expect(
a.copyWith(sensitiveActionPolicy: SensitiveActionPolicy.enabled),
isNot(a),
);
});

test('a changed push subscription state is not equal', () {
final a = sample();
final b = a.copyWith(
pushSubscriptionState: a.pushSubscriptionState.withReservedGeneration(
(a.pushSubscriptionState.generationCursor ?? 0) + 1,
),
);

expect(b, isNot(a));
});
});
}
36 changes: 36 additions & 0 deletions mobile/test/shared/relay/relay_config_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,40 @@ void main() {
expect(config.wsUrl, 'wss://relay.example.com:8443');
});
});

group('RelayConfig value equality', () {
test('configs with the same origin and nsec compare equal', () {
final a = RelayConfig(baseUrl: 'https://relay.example', nsec: 'nsec1x');
final b = RelayConfig(baseUrl: 'https://relay.example', nsec: 'nsec1x');

expect(identical(a, b), isFalse);
expect(a, b);
expect(a.hashCode, b.hashCode);
});

test('a different origin or nsec is not equal', () {
final a = RelayConfig(baseUrl: 'https://relay.example', nsec: 'nsec1x');

expect(
a,
isNot(RelayConfig(baseUrl: 'https://other.example', nsec: 'nsec1x')),
);
expect(
a,
isNot(RelayConfig(baseUrl: 'https://relay.example', nsec: 'nsec1y')),
);
expect(a, isNot(RelayConfig(baseUrl: 'https://relay.example')));
});

test('equality compares the stored origin, not the canonical one', () {
// baseUrl folds wss:// to https://. Two configs that canonicalize alike
// but were stored differently must stay distinct, because storedOrigin
// keys identity-scoped preferences.
final stored = RelayConfig(baseUrl: 'wss://relay.example');
final canonical = RelayConfig(baseUrl: 'https://relay.example');

expect(stored.baseUrl, canonical.baseUrl);
expect(stored, isNot(canonical));
});
});
}
Loading