fix(mobile): show hosted community names consistently (#8872)

When a hosted relay has no metadata name, the hosted add paths save its
host (`remote-agent-pilot.communities.buzz.xyz`) as the community name.
Onboarding shortens that fallback to `remote-agent-pilot`. After
joining, though, the community switcher, the remove dialog, the theme
preview and the identity export showed the full host, so the same
community appeared under two names.

This change shortens the name in the model instead of at each call site.
`Community` and `AddCommunityDeepLink` keep the saved value private, and
their public `name` getter returns the display form, using the existing
`HostedEnvironment.communityDisplayName` rule. Existing and future
readers get the short name without opting in. Serialization still writes
the saved value, so stored data is unchanged and no migration is needed.
Custom names and non-hosted hosts pass through unchanged, as before.
Community identity is unaffected because it uses `id` and `relayUrl`,
never the name.

Beyond the captures below, the same getter shortens the name in the iOS
push notification subtitle, onboarding's "Adding …" progress text, the
"You joined …" invite sheet, and the VoiceOver labels for removing and
loading a community.

### Before and after

| | Before (`main`) | After |
|---|---|---|
| Community switcher |
![01a-before-switcher](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--01a-before-switcher.png)
|
![01b-after-switcher](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--01b-after-switcher.png)
|
| Remove dialog |
![02a-before-remove](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--02a-before-remove.png)
|
![02b-after-remove](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--02b-after-remove.png)
|
| Theme preview |
![03a-before-theme](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--03a-before-theme.png)
|
![03b-after-theme](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--03b-after-theme.png)
|
| Identity export |
![04a-before-nsec](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--04a-before-nsec.png)
|
![04b-after-nsec](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--04b-after-nsec.png)
|

### Before merge

- [x] Kenny Lopez reviews the visible changes ([approved in Buzz
#buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d)).

### Validation

- Captures come from iPhone 17 simulators (iOS 27) running `main` and
this branch. Both builds used the same scratch-only seed with two
communities whose saved names are their hosted hosts. The seed is not
part of this PR. The relays were unreachable from the simulator, so the
screens show loading placeholders.
- Changed tests: the community switcher journey now uses a hosted
host-name fallback for one community. It asserts the grid label, the
remove tooltip and the dialog text show the short name, and it fails
when the getter returns the saved value. The onboarding label table now
asserts the model's `name` itself is shortened. The hosted-flow fixture
records relay hosts instead of names, and the auth-domain tests look up
communities by relay URL.
- The full mobile test suite and `flutter analyze` pass. The CI step
that runs with configured authentication domains also passes locally
(113 tests).
- Not captured: the push notification subtitle, onboarding's "Adding …"
text and the invite sheet. Reaching them needs hosted sign-in or push,
which the simulator doesn't support.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-01 07:30:09 -08:00
committed by GitHub
parent e71fbe23df
commit 83ec9c0e2b
9 changed files with 52 additions and 31 deletions

View File

@@ -3,7 +3,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/hosted/hosted_flow.dart';
import '../../shared/hosted/hosted_environment.dart';
import '../../shared/theme/grid.dart';
import '../../shared/community/community_grid.dart';
import '../../shared/community/community_grid_option.dart';
@@ -75,10 +74,7 @@ class HostedCommunitySelection extends HookConsumerWidget {
for (final community in flow.communities)
CommunityGridOption(
id: community.relayUrl,
name: HostedEnvironment.communityDisplayName(
community.name,
community.relayUrl,
),
name: community.name,
relayUrl: community.relayUrl,
onSelect: (_) async {
flow.chooseLandingCommunity(community.relayUrl);

View File

@@ -2,6 +2,7 @@ import 'dart:math';
import 'package:uuid/uuid.dart';
import '../hosted/hosted_environment.dart';
import '../push/push_subscription.dart';
const _uuid = Uuid();
@@ -24,7 +25,14 @@ class Community {
static const _pushNotificationsEnabledKey = 'pushNotificationsEnabledV2';
final String id;
final String name;
/// Saved label, which may be a hosted relay's full host name.
final String _name;
/// User-facing label. Every reader goes through here so hosted host-name
/// fallbacks always appear in the short form onboarding shows.
String get name => HostedEnvironment.communityDisplayName(_name, relayUrl);
final String relayUrl;
final String? pubkey;
final String? nsec;
@@ -44,7 +52,7 @@ class Community {
const Community({
required this.id,
required this.name,
required String name,
required this.relayUrl,
this.pubkey,
this.nsec,
@@ -54,7 +62,7 @@ class Community {
this.pushLeaseInstallationId,
this.starterSetupIncomplete = false,
required this.addedAt,
});
}) : _name = name;
factory Community.create({
required String name,
@@ -91,7 +99,7 @@ class Community {
}) {
return Community(
id: id,
name: name ?? this.name,
name: name ?? _name,
relayUrl: relayUrl ?? this.relayUrl,
pubkey: pubkey == _sentinel ? this.pubkey : pubkey as String?,
nsec: nsec == _sentinel ? this.nsec : nsec as String?,
@@ -112,7 +120,7 @@ class Community {
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'name': _name,
'relayUrl': relayUrl,
if (pubkey != null) 'pubkey': pubkey,
if (nsec != null) 'nsec': nsec,

View File

@@ -375,8 +375,14 @@ class NostrBindDeepLink extends BuzzDeepLink {
class AddCommunityDeepLink extends BuzzDeepLink {
@override
final String relayUrl;
final String name;
const AddCommunityDeepLink({required this.relayUrl, required this.name});
final String _name;
/// User-facing label, shortening hosted host-name fallbacks like [Community].
String get name => HostedEnvironment.communityDisplayName(_name, relayUrl);
const AddCommunityDeepLink({required this.relayUrl, required String name})
: _name = name;
}
bool _webHandoffShape(Uri uri, String host, Set<String> allowed) =>

View File

@@ -1669,8 +1669,9 @@ void main() {
),
Community(
id: 'bravo',
name: 'Bravo',
relayUrl: 'wss://bravo.example.com',
// Hosted communities without relay metadata store their host.
name: 'bravo.communities.buzz.xyz',
relayUrl: 'wss://bravo.communities.buzz.xyz',
addedAt: DateTime(2025),
),
];
@@ -1717,7 +1718,11 @@ void main() {
expect(find.byTooltip('Close community switcher'), findsOneWidget);
expect(find.text('alpha.example.com'), findsNothing);
expect(find.text('bravo.example.com'), findsNothing);
expect(find.text('bravo.communities.buzz.xyz'), findsNothing);
expect(
find.descendant(of: options, matching: find.text('bravo')),
findsOneWidget,
);
expect(find.text('Rename'), findsNothing);
expect(
@@ -1790,13 +1795,13 @@ void main() {
expect(activeSelection, findsNothing);
expect(inactiveSelection, findsNothing);
await tester.tap(find.byTooltip('Remove Bravo'));
await tester.tap(find.byTooltip('Remove bravo'));
await tester.pumpAndSettle();
expect(find.text('Remove community?'), findsOneWidget);
expect(
find.text(
'Are you sure you want to remove “Bravo”? '
'Are you sure you want to remove “bravo”? '
'You can pair with it again later.',
),
findsOneWidget,
@@ -1807,7 +1812,7 @@ void main() {
expect(communityNotifier.removedIds, ['bravo']);
expect(find.text('Switch Community'), findsOneWidget);
expect(find.text('Bravo'), findsNothing);
expect(find.text('bravo'), findsNothing);
expect(find.text('Done'), findsOneWidget);
await tester.tap(find.text('Done'));

View File

@@ -49,7 +49,7 @@ void main() {
find.byType(CommunityGridOption),
);
expect(tile.relayUrl, relay);
expect(fixture.flow.communities.single.name, name);
expect(fixture.flow.communities.single.name, expected);
if (name != expected) expect(find.text(name), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
}

View File

@@ -1084,7 +1084,7 @@ void main() {
findsOneWidget,
);
final firstSaved = (await f.storage.loadAll()).firstWhere(
(c) => c.name == 'one.communities.buzz.xyz',
(c) => c.relayUrl == 'wss://one.communities.buzz.xyz',
);
Navigator.of(
tester.element(find.byType(ProfileSetupPage, skipOffstage: false)),
@@ -1108,16 +1108,18 @@ void main() {
isTrue,
);
expect(
saved.singleWhere((c) => c.name == 'one.communities.buzz.xyz').id,
saved
.singleWhere((c) => c.relayUrl == 'wss://one.communities.buzz.xyz')
.id,
firstSaved.id,
);
expect(await f.storage.loadActiveId(), firstSaved.id);
expect(
f.probes.where((c) => c.name == 'one.communities.buzz.xyz'),
f.probes.where((c) => c.relayUrl == 'wss://one.communities.buzz.xyz'),
hasLength(1),
);
expect(
f.probes.where((c) => c.name == 'two.communities.buzz.xyz'),
f.probes.where((c) => c.relayUrl == 'wss://two.communities.buzz.xyz'),
hasLength(2),
);
expect(find.byType(OnboardingAppEntry), findsNothing);
@@ -1173,11 +1175,14 @@ void main() {
final communities = await f.storage.loadAll();
expect(communities, hasLength(2));
expect(
communities.map((c) => c.name),
containsAll(['one.communities.buzz.xyz', 'two.communities.buzz.xyz']),
communities.map((c) => c.relayUrl),
containsAll([
'wss://one.communities.buzz.xyz',
'wss://two.communities.buzz.xyz',
]),
);
final saved = communities.singleWhere(
(c) => c.name == 'two.communities.buzz.xyz',
(c) => c.relayUrl == 'wss://two.communities.buzz.xyz',
);
expect(saved.nsec, f.keys.nsec);
expect(saved.pubkey, f.keys.public);

View File

@@ -200,7 +200,7 @@ void main() {
f.beforeAdmission = null;
await tester.tap(find.text('Try again').hitTestable());
await tester.pumpAndSettle();
expect(f.admitted, ['paired']);
expect(f.admitted, ['paired.communities.buzz.xyz']);
expect(f.remembered, contains('wss://paired.communities.buzz.xyz'));
expect(find.byType(OnboardingAppEntry), findsNothing);
expect(find.text('Add community').hitTestable(), findsOneWidget);

View File

@@ -48,7 +48,7 @@ void main() {
await container.read(pairingProvider.notifier).pair(_payload(f.keys));
await settled.future.timeout(const Duration(seconds: 2));
expect(legacyProbes, 0);
expect(f.admitted, backup ? ['paired'] : isEmpty);
expect(f.admitted, backup ? ['paired.communities.buzz.xyz'] : isEmpty);
expect((await f.store.read())?.pubkey, f.keys.public);
},
);

View File

@@ -180,9 +180,10 @@ class HostedCommunitiesFixture {
if (current()) openedCommunity = link.relayUrl;
},
enterCommunity: (link, subject, pubkey, current) async {
final target = link as AddCommunityDeepLink;
await beforeAdmission?.call(target.name);
if (current()) admitted.add(target.name);
// Record the relay host: names are display labels.
final host = Uri.parse((link as AddCommunityDeepLink).relayUrl).host;
await beforeAdmission?.call(host);
if (current()) admitted.add(host);
},
);
}