mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
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>
243 lines
7.9 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|