Files
buzz/mobile/lib/features/search/search_page/message_tile.dart
Tom Brow 61b8455a3c fix(mobile): open unjoined channels from links and search results (#8923)
Tapping a `#channel` link to an open channel you haven't joined showed
"Channel could not be opened", because a tap could only open a channel
already in the local list. A search hit had the same gap: it did nothing
when its channel wasn't in the loaded list, for example when the
open-channel directory failed to load or hadn't loaded yet.

Both taps now go through one resolver. It looks in the loaded list
first, then reads the channel's own metadata, as `buzz://` deep links
already do.
- An open channel you haven't joined opens in preview ("Join this
channel from Manage to participate").
- A private or withheld channel stays closed and shows "Channel not
found here".
- A failed lookup shows "Could not open the channel". So does a missing
channel when the channel list failed to load, since it may be one you
belong to.

The resolver also guards two cases:
- A second tap during a slow lookup doesn't open the channel twice.
- A lookup that finishes after you switch community or identity, or
after you moved to another page, opens nothing and doesn't block a new
tap.

Fixes AIDA-2324 and AIDA-2286.
[#8764](https://github.com/block/buzz/pull/8764) already made search
load the open directory, which fixed the common search case, so the
search part here covers the remaining fallback.

### Before: a link to an unjoined open channel fails


https://github.com/user-attachments/assets/c39cf0db-8098-4010-993e-cf664230c809

### After: the same link opens the channel in preview


https://github.com/user-attachments/assets/1422e70f-39c5-4598-8232-efad190b3413

### Validation

- Added tests:
- A table-driven channel-link journey with four rows: an unjoined open
channel opens, a withheld channel and a private channel show "Channel
not found here", and a failed lookup shows "Could not open the channel".
Each row double-taps during a gated lookup and asserts a single lookup
and a single push. Failure rows then tap again to show the lock is
released.
- A lookup that settles after the user opened another page pushes
nothing.
- Search journeys: a message hit opens its unlisted channel (including
after the channel list failed to load), or explains why it can't ("Could
not open the channel" when the list failed).
- Taps whose lookup will be discarded don't block the next tap: after a
community switch away and back, or from another page after the first
page closed. Each row fails if its part of the tap lock or the
connection fence is removed.
- I reverted the production files to `main` once and confirmed every new
test fails there.
- The full mobile suite and `mobile-check` pass.
- I ran it on an iPhone 18 Pro simulator (iOS 27), signed in to
Builderlab with an account that isn't a member of the open channel
`#speed-tests`.
- A link to that channel shows the error toast on `main` and opens a
preview with this change (videos above).
- A search hit in that channel opens it with this change. On `main` it
also opens, through [#8764](https://github.com/block/buzz/pull/8764)'s
directory load.
- Not covered by tests: waiting for a channel-list load still in flight,
a short guard I checked by reading the code.
- The human app-test step doesn't apply (Tom's standing exception for
mobile client changes).

buzz-review-completed

🤖 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: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-02 12:55:59 -08:00

243 lines
7.9 KiB
Dart

part of '../search_page.dart';
// Two-line search snippets must not parse an entire document. Long messages
// use literal text so cutting a code fence or link cannot create a new embed.
const _richSearchPreviewLimit = 512;
String _literalSearchPreview(String content) {
var end = _richSearchPreviewLimit;
final last = content.codeUnitAt(end - 1);
if (last >= 0xD800 && last <= 0xDBFF) end--;
return '${content.substring(0, end)}…';
}
class _MessageTile extends ConsumerWidget {
final SearchHit hit;
final UserProfile? authorProfile;
final Map<String, UserProfile> userCache;
final Channel? channel;
final String? currentPubkey;
final VoidCallback onResultSelected;
const _MessageTile({
required this.hit,
required this.authorProfile,
required this.userCache,
required this.channel,
required this.currentPubkey,
required this.onResultSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mentionedPubkeys = hit.content.length > _richSearchPreviewLimit
? <String>{}
: mentionedPubkeysFromTags(hit.tags);
// A hit is labelled within its channel, as it would be there.
final hitChannelId = channel?.id ?? hit.channelId;
final labelPubkeys = {hit.pubkey.toLowerCase(), ...mentionedPubkeys};
final Map<String, String> labels;
if (hitChannelId == null) {
final names = watchIdentityNames(ref, labelPubkeys);
labels = {
for (final pubkey in labelPubkeys) pubkey: names.labelFor(pubkey),
};
} else {
labels = watchChannelIdentityLabels(ref, hitChannelId, labelPubkeys);
}
final authorName =
labels[hit.pubkey.toLowerCase()] ??
authorProfile?.label ??
shortPubkey(hit.pubkey);
final timeAgo = relativeTime(hit.createdAt);
final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), '');
final hasChannelName = channelName != null && channelName.isNotEmpty;
final isDm = channel?.isDm ?? false;
return ListTile(
key: ValueKey('search-message-row-${hit.eventId}'),
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
titleAlignment: ListTileTitleAlignment.top,
horizontalTitleGap: messageAvatarContentGap,
leading: SmallAvatar(
key: ValueKey('search-message-avatar-${hit.eventId}'),
pubkey: hit.pubkey,
userCache: userCache,
size: compactMessageAvatarSize,
),
title: MessageAuthorMeta(
displayName: authorName,
username: messageUsernameLabel(authorProfile),
timestamp: timeAgo,
nameColor: context.colors.onSurface,
metadataColor: context.colors.onSurfaceVariant,
displayNameKey: ValueKey('search-message-author-${hit.eventId}'),
usernameKey: ValueKey('search-message-username-${hit.eventId}'),
timestampKey: ValueKey('search-message-timestamp-${hit.eventId}'),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 2),
Row(
key: ValueKey('search-message-context-${hit.eventId}'),
children: [
Flexible(
child: Text(
isDm
? 'Direct message'
: hasChannelName
? 'Message in'
: 'Message',
style: activityContextTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
if (!isDm && hasChannelName) ...[
const SizedBox(width: Grid.half),
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.half + Grid.quarter,
vertical: Grid.quarter / 2,
),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.xs),
),
child: Text(
'#$channelName',
key: ValueKey('search-message-channel-${hit.eventId}'),
style: activityContextTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
],
),
const SizedBox(height: Grid.half),
_buildPreview(context, ref, labels),
],
),
onTap: () {
onResultSelected();
unawaited(_navigateToHit(context, ref, hit, channel));
},
);
}
Widget _buildPreview(
BuildContext context,
WidgetRef ref,
Map<String, String> labels,
) {
if (hit.content.length > _richSearchPreviewLimit) {
return Text(
_literalSearchPreview(hit.content),
key: ValueKey('search-message-body-${hit.eventId}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: activityPreviewTextStyle.copyWith(
color: context.colors.onSurface,
),
);
}
final profileMentionNames = {
for (final pubkey in mentionedPubkeysFromTags(hit.tags))
if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true)
pubkey: userCache[pubkey]!.displayName!.trim(),
};
final mentionPubkeys = mentionedPubkeysFromTags(hit.tags);
final knownAgentPubkeys = channel == null
? ref.watch(knownAgentPubkeysProvider)
: ref.watch(agentMentionPubkeysProvider(channel!.id));
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: knownAgentPubkeys,
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: mentionPubkeys,
profileMentionNames: profileMentionNames,
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
return MessagePreview(
key: ValueKey('search-message-body-${hit.eventId}'),
content: hit.content,
mentionNames: mentionNames,
mentionLabels: labels,
agentMentionPubkeys: agentMentionPubkeys,
tags: hit.tags,
maxLines: 2,
baseStyle: activityPreviewTextStyle.copyWith(
color: context.colors.onSurface,
),
);
}
Future<void> _navigateToHit(
BuildContext context,
WidgetRef ref,
SearchHit hit,
Channel? listedChannel,
) async {
final channelId = hit.channelId;
final channel =
listedChannel ??
(channelId == null
? null
: await resolveChannelToOpen(context, ref, channelId));
if (channel == null || !context.mounted) return;
if (hit.kind == 45001) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ForumThreadPage(
channelId: channel.id,
postEventId: hit.eventId,
currentPubkey: currentPubkey,
isMember: channel.isMember,
isArchived: channel.isArchived,
),
),
);
} else {
unawaited(_openSearchChannel(context, channel));
}
}
}
class _SectionLabel extends StatelessWidget {
final String label;
const _SectionLabel({required this.label});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xs,
Grid.gutter,
Grid.half,
),
child: Text(
label,
key: ValueKey('search-section-${label.toLowerCase()}'),
style: activityContextTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
);
}
}