Files
buzz/mobile/lib/shared/community/community.dart
Tom Brow 8f2c7710e9 fix(mobile): default community push notifications to on (#8425)
New mobile communities currently require a manual Settings opt-in before
push can start. Default that local preference to on for new communities
and stored records without a preference. Preserve every saved `true` or
`false`, including values written by older clients. Older default-off
values are indistinguishable from explicit opt-outs, so this does not
migrate them to on.

Push still requires a configured gateway, authenticated connectivity,
and a valid relay capability. iOS continues to control display
authorization through its existing permission prompt. Notification
eligibility and subscriptions are unchanged. Settings retains its
existing opt-out control for enabled communities even when capability is
unavailable.

Drafted from the 0.24 baseline; release-branch inclusion is a separate
decision.

### Related issue

Closest related issue: #3238 (synced defaults). This change is limited
to the mobile device-local community preference; no duplicate found.

### Validation

- Added or updated coverage for new and missing preferences, saved
true/false persistence, opt-out after restart, capability-gated
registration, and Settings without a configured gateway.
- Full configured mobile suite: 3,499 passed, 4 skipped. Separate
no-gateway suite: 4 passed. Flutter analysis passed.
- Existing permission-denied and unsupported-relay tests passed as part
of the mobile suite. No physical-device or end-to-end APNs validation
performed.
- Full local repository gate passed (formatting, lint, unit tests,
desktop/web builds, Tauri checks/tests, and mobile tests).



- Follow-up review edits: restored the original VISION.md wording and
shared the default in one private Community constant. All 43 focused
community/bootstrap tests passed.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-18 16:10:01 -07:00

178 lines
6.0 KiB
Dart

import 'dart:math';
import 'package:uuid/uuid.dart';
import '../push/push_subscription.dart';
const _uuid = Uuid();
const _sentinel = Object();
final _pushLeaseInstallationIdPattern = RegExp(r'^[0-9a-f]{32}$');
String _newPushLeaseInstallationId() {
final random = Random.secure();
return List.generate(
16,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
}
enum SensitiveActionPolicy { enabled, disabledByUser }
class Community {
static const _defaultPushNotificationsEnabled = true;
final String id;
final String name;
final String relayUrl;
final String? pubkey;
final String? nsec;
final SensitiveActionPolicy sensitiveActionPolicy;
final bool pushNotificationsEnabled;
final BuzzPushLeaseSubscriptionState pushSubscriptionState;
/// Stable random address component for this community's relay push lease.
///
/// Legacy records omit this value and continue using the endpoint grant's
/// installation id so their already-published lease remains addressable.
final String? pushLeaseInstallationId;
/// Whether invite-created starter channels still need to be recovered.
final bool starterSetupIncomplete;
final DateTime addedAt;
const Community({
required this.id,
required this.name,
required this.relayUrl,
this.pubkey,
this.nsec,
this.sensitiveActionPolicy = SensitiveActionPolicy.disabledByUser,
this.pushNotificationsEnabled = _defaultPushNotificationsEnabled,
this.pushSubscriptionState = const BuzzPushLeaseSubscriptionState.desired(),
this.pushLeaseInstallationId,
this.starterSetupIncomplete = false,
required this.addedAt,
});
factory Community.create({
required String name,
required String relayUrl,
String? pubkey,
String? nsec,
SensitiveActionPolicy sensitiveActionPolicy =
SensitiveActionPolicy.disabledByUser,
bool starterSetupIncomplete = false,
}) {
return Community(
id: _uuid.v4(),
name: name,
relayUrl: relayUrl,
pubkey: pubkey,
nsec: nsec,
sensitiveActionPolicy: sensitiveActionPolicy,
pushLeaseInstallationId: _newPushLeaseInstallationId(),
starterSetupIncomplete: starterSetupIncomplete,
addedAt: DateTime.now(),
);
}
Community copyWith({
String? name,
String? relayUrl,
Object? pubkey = _sentinel,
Object? nsec = _sentinel,
SensitiveActionPolicy? sensitiveActionPolicy,
bool? pushNotificationsEnabled,
BuzzPushLeaseSubscriptionState? pushSubscriptionState,
Object? pushLeaseInstallationId = _sentinel,
bool? starterSetupIncomplete,
}) {
return Community(
id: id,
name: name ?? this.name,
relayUrl: relayUrl ?? this.relayUrl,
pubkey: pubkey == _sentinel ? this.pubkey : pubkey as String?,
nsec: nsec == _sentinel ? this.nsec : nsec as String?,
sensitiveActionPolicy:
sensitiveActionPolicy ?? this.sensitiveActionPolicy,
pushNotificationsEnabled:
pushNotificationsEnabled ?? this.pushNotificationsEnabled,
pushSubscriptionState:
pushSubscriptionState ?? this.pushSubscriptionState,
pushLeaseInstallationId: pushLeaseInstallationId == _sentinel
? this.pushLeaseInstallationId
: pushLeaseInstallationId as String?,
starterSetupIncomplete:
starterSetupIncomplete ?? this.starterSetupIncomplete,
addedAt: addedAt,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'relayUrl': relayUrl,
if (pubkey != null) 'pubkey': pubkey,
if (nsec != null) 'nsec': nsec,
'sensitiveActionPolicy': sensitiveActionPolicy.name,
'pushNotificationsEnabled': pushNotificationsEnabled,
'pushSubscriptionState': pushSubscriptionState.toJson(),
if (pushLeaseInstallationId != null)
'pushLeaseInstallationId': pushLeaseInstallationId,
'starterSetupIncomplete': starterSetupIncomplete,
'addedAt': addedAt.toIso8601String(),
};
factory Community.fromJson(Map<String, dynamic> json) {
final pushLeaseInstallationId = json['pushLeaseInstallationId'] as String?;
if (pushLeaseInstallationId != null &&
!_pushLeaseInstallationIdPattern.hasMatch(pushLeaseInstallationId)) {
throw const FormatException(
'Push lease installation id must be 16 random bytes encoded as lowercase hex',
);
}
final pushNotificationsEnabled =
json['pushNotificationsEnabled'] as bool? ??
_defaultPushNotificationsEnabled;
var pushSubscriptionState = json['pushSubscriptionState'] == null
? const BuzzPushLeaseSubscriptionState.desired()
: BuzzPushLeaseSubscriptionState.fromJson(
Map<String, dynamic>.from(json['pushSubscriptionState'] as Map),
);
if (!pushNotificationsEnabled &&
pushSubscriptionState.pendingTombstoneGeneration == null) {
pushSubscriptionState = pushSubscriptionState
.withPendingTombstoneAtCursor();
}
return Community(
id: json['id'] as String,
name: json['name'] as String,
relayUrl: json['relayUrl'] as String,
pubkey: json['pubkey'] as String?,
nsec: json['nsec'] as String?,
sensitiveActionPolicy: SensitiveActionPolicy.values.firstWhere(
(value) => value.name == json['sensitiveActionPolicy'],
orElse: () => SensitiveActionPolicy.disabledByUser,
),
pushNotificationsEnabled: pushNotificationsEnabled,
pushSubscriptionState: pushSubscriptionState,
pushLeaseInstallationId: pushLeaseInstallationId,
starterSetupIncomplete: json['starterSetupIncomplete'] as bool? ?? false,
addedAt: DateTime.parse(json['addedAt'] as String),
);
}
/// Derive a human-friendly community name from a relay URL.
static String nameFromUrl(String url) {
try {
final host = Uri.parse(url).host;
if (host.contains('localhost') || host == '127.0.0.1') return 'Local Dev';
final parts = host.split('.');
if (parts.length > 2) return parts.first;
return host;
} catch (_) {
return 'Community';
}
}
}