Files
buzz/mobile/lib/shared/hosted/device_identity_source.dart
klopez4212 f3a7bab098 Clarify mobile community onboarding and saved identity selection (#8729)
## Summary

Make community onboarding easier to scan: shorten hosted hostname
fallbacks, clarify Add Community instructions, and replace raw saved-key
buttons with identity cards. Use white pages, grey cards, ghost
secondary actions, standard page transitions, and a “Getting ready”
screen during joining. Native profile setup keeps a horizontal modal
transition. Identity cards load signed name/avatar metadata from saved
communities without switching accounts; unavailable profiles use a
neutral fallback. Cards show only the avatar, a 16px medium-weight name,
and muted npub; community lists are omitted. Retiring profile setup
closes its avatar editor too.

### Related issue

Follow-up to #8687 and #8599; no duplicate PR found.

### Testing

- Formatting and analysis passed. Full suite with hosted-auth defines:
4,019 passed, 5 skipped; includes profile-fetch and cancellation
regressions.
- Prior iOS device preview and native profile tests passed; hosted CI
native Swift checks passed on the preceding head. Latest Dart fixes are
not yet reinstalled on the phone.
- Simulator walkthrough and owner-tested iPhone preview use sample
identities and simulated admission.

Sample-data previews; the identity snapshot shows the
unavailable-profile fallback rendered by Flutter. Media is attached here
only.

| Community picker | Add Community | Saved identity |
| --- | --- | --- |
| ![Shortened community
name](https://github.com/user-attachments/assets/cf0af6aa-3a3d-4ffe-b469-e26f9bb92c30)
| ![White Add Community
page](https://github.com/user-attachments/assets/b2f71c77-1aa0-465a-b302-7eb5f1e92819)
| ![Saved identity card and ghost import
action](https://github.com/user-attachments/assets/47f20f34-f047-4ce5-befa-f56834096e95)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-30 19:00:42 +00:00

131 lines
4.6 KiB
Dart

import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../storage/secure_storage.dart';
import 'account_identity_store.dart';
import '../profile/user_profile.dart';
import '../relay/relay_provider.dart';
/// Public identity choices; never contains private signing material.
class DeviceIdentityChoice {
const DeviceIdentityChoice(this.pubkey, this.label, {this.fromCloud = false});
final String pubkey;
final String label;
final bool fromCloud;
}
/// Read-only inventory, including legacy records hidden by a newer community list.
/// Do not call CommunityStorage.loadAll here: it migrates and deletes records.
class DeviceIdentitySource {
DeviceIdentitySource({FlutterSecureStorage? storage})
: _storage = storage ?? communitySecureStorage;
final FlutterSecureStorage _storage;
Future<List<DeviceIdentityChoice>> choices() async => [
for (final entry in (await _read()).entries)
DeviceIdentityChoice(entry.key, entry.value.label),
];
/// Read profile metadata only from communities already saved for this key.
/// A failed/offline community does not prevent trying another saved source.
Future<UserProfile?> loadProfile(
String pubkey,
Future<UserProfile?> Function(RelayConfig config) fetch,
) async {
final seen = <String>{};
for (final key in ['buzz_communities', 'buzz_workspaces']) {
final raw = await _storage.read(key: key);
if (raw == null) continue;
for (final record in jsonDecode(raw) as List) {
try {
final value = record as Map<String, dynamic>;
final nsec = value['nsec'] as String?;
final relay = value['relayUrl'] as String?;
if (nsec == null ||
relay == null ||
AccountIdentityStore.publicKeyForNsec(nsec) != pubkey ||
(value['pubkey'] != null && value['pubkey'] != pubkey)) {
continue;
}
final config = RelayConfig(baseUrl: relay, nsec: nsec);
if (!seen.add(config.baseUrl)) continue;
if (seen.length > 3) return null;
final profile = await fetch(config);
if (profile != null) return profile;
} catch (_) {
// Metadata is optional; never alter keys or block identity selection.
}
}
}
return null;
}
/// Reread and validate the chosen key immediately before create-only adoption.
Future<AccountIdentitySnapshot> adopt(
String pubkey,
AccountIdentityStore store, {
required void Function() checkCurrent,
}) async {
final candidate = (await _read())[pubkey];
if (candidate == null) throw AccountIdentityFailure.absent;
checkCurrent();
return store.create(candidate.nsec, checkCurrent: checkCurrent);
}
/// Supplies a freshly validated saved identity only to an admission service.
Future<T> withIdentity<T>(
String pubkey,
Future<T> Function(String nsec) operation,
) async {
final candidate = (await _read())[pubkey];
if (candidate == null) throw AccountIdentityFailure.absent;
return operation(candidate.nsec);
}
Future<Map<String, ({String nsec, String label})>> _read() async {
try {
final result = <String, ({String nsec, String label})>{};
final labels = <String, Set<String>>{};
void add(dynamic nsec, dynamic advertised, String label) {
if (nsec == null) {
// Token-only legacy communities legitimately have no private key.
return;
}
if (nsec is! String) throw AccountIdentityFailure.unreadable;
final pubkey = AccountIdentityStore.publicKeyForNsec(nsec);
if (advertised != null && advertised != pubkey) {
throw AccountIdentityFailure.unreadable;
}
final names = labels.putIfAbsent(pubkey, () => <String>{})..add(label);
result[pubkey] = (nsec: nsec, label: names.join(', '));
}
for (final key in ['buzz_communities', 'buzz_workspaces']) {
final raw = await _storage.read(key: key);
if (raw == null) continue;
final records = jsonDecode(raw) as List;
for (final record in records) {
final value = record as Map<String, dynamic>;
add(
value['nsec'],
value['pubkey'],
(value['name'] as String?)?.trim().isNotEmpty == true
? (value['name'] as String).trim()
: 'Saved community',
);
}
}
add(
await _storage.read(key: 'buzz_nsec'),
await _storage.read(key: 'buzz_pubkey'),
'Previous device identity',
);
return result;
} catch (_) {
throw AccountIdentityFailure.unreadable;
}
}
}