mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Summary Use native iOS sheets for channel editing and Add Members, with profile avatars, matching search, and destructive removal dialogs. Canvas content becomes a tappable preview with Add/Remove actions. Fix the Settings availability menu and clipped copy confirmation. Align channel action messages with chat styling and keep agent identities distinguishable across selection, attribution, and profile surfaces. ### Related issue No duplicate found in the existing mobile Settings/channel issues and PRs. ### Testing - Flutter analysis, formatting, file-size checks, focused collision regressions, and the full mobile test suite passed. - Production UIKit picker simulator check passed: distinct visible/accessibility labels, avatar updates, selected rows, and pubkey callbacks. Wired into Mobile Swift CI. - Original UI was built and tested on the physical iPhone with author confirmation; profile/community data preserved. - Review fixes await renewed author device confirmation before restoring `buzz-review-completed` and ready-for-review status. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
276 lines
10 KiB
Dart
276 lines
10 KiB
Dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../../shared/crypto/nip_oa.dart';
|
|
import '../../../shared/identity_names/identity_names.dart';
|
|
import '../../../shared/identity_names/identity_names_provider.dart';
|
|
import '../../../shared/mentions/agent_identity_provider.dart';
|
|
import '../../../shared/relay/relay.dart';
|
|
import '../../../shared/profile/user_cache_provider.dart';
|
|
import '../../../shared/profile/user_profile.dart';
|
|
import '../channel.dart';
|
|
import '../channel_management_provider.dart';
|
|
import '../channels_provider.dart';
|
|
import 'mention_candidates.dart';
|
|
import 'mention_ranking.dart';
|
|
import '../../../shared/mentions/mention_rules.dart';
|
|
import '../../../shared/mentions/mention_tags.dart';
|
|
import '../../profile/presence_cache_provider.dart';
|
|
|
|
/// Typing pause before a mention query hits the relay search endpoint.
|
|
const mentionSearchDebounce = Duration(milliseconds: 200);
|
|
|
|
/// Global user search for mention autocomplete — kind:0 prefix search via
|
|
/// the relay HTTP bridge. Mirrors desktop's `useInfiniteUserSearchQuery`
|
|
/// feeding `useMentions` (source 4: people and agents outside the channel).
|
|
///
|
|
/// Debounced: the provider waits [mentionSearchDebounce] before querying;
|
|
/// keystrokes dispose the stale family member so its request never fires.
|
|
final mentionUserSearchProvider = FutureProvider.autoDispose
|
|
.family<List<UserProfile>, String>((ref, query) async {
|
|
// The session notifier is stable across communities, and two
|
|
// communities can share a signing key. Watching the relay config
|
|
// restarts the search at a community switch, so a late result or
|
|
// error from the old community never reaches the new one.
|
|
ref.watch(relayConfigProvider);
|
|
final trimmed = query.trim();
|
|
if (trimmed.isEmpty) return const [];
|
|
|
|
var disposed = false;
|
|
ref.onDispose(() => disposed = true);
|
|
await Future<void>.delayed(mentionSearchDebounce);
|
|
if (disposed) return const [];
|
|
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
final events = await session.queryRelay([
|
|
NostrFilters.searchUsers(trimmed),
|
|
]);
|
|
|
|
// Keep only the latest kind:0 event per pubkey (the bridge does not
|
|
// honor the `kinds` filter under search, and may return several
|
|
// profile revisions — mirrors desktop's `list_user_search_results`).
|
|
final latestByPubkey = <String, NostrEvent>{};
|
|
for (final event in events) {
|
|
if (event.kind != 0) continue;
|
|
final pk = event.pubkey.toLowerCase();
|
|
final current = latestByPubkey[pk];
|
|
if (current == null || event.createdAt > current.createdAt) {
|
|
latestByPubkey[pk] = event;
|
|
}
|
|
}
|
|
|
|
return [
|
|
for (final event in latestByPubkey.values) _profileFromEvent(event),
|
|
];
|
|
});
|
|
|
|
UserProfile _profileFromEvent(NostrEvent event) {
|
|
final data = ProfileData.fromEvent(event);
|
|
return UserProfile(
|
|
pubkey: event.pubkey.toLowerCase(),
|
|
displayName: data.displayName,
|
|
avatarUrl: data.avatarUrl,
|
|
about: data.about,
|
|
nip05Handle: data.nip05,
|
|
ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey),
|
|
);
|
|
}
|
|
|
|
/// One chooser opening: an inline `@` token or one open picker.
|
|
typedef MentionChooserArgs = ({String channelId, String query, int opening});
|
|
|
|
/// The last finished directory search for each chooser opening. A new query
|
|
/// keeps showing its still-matching people while its own search runs.
|
|
/// Nothing here outlives the relay session or the community, and a new
|
|
/// opening starts empty.
|
|
class _SettledSearches {
|
|
final _pages = <(String, int), List<UserProfile>>{};
|
|
|
|
List<UserProfile>? last(String channelId, int opening) =>
|
|
_pages[(channelId, opening)];
|
|
|
|
/// Keeps [people] as this opening's last search. A search that found no
|
|
/// one is not kept, so the next opening searches again.
|
|
void settle(String channelId, int opening, List<UserProfile> people) {
|
|
if (people.isEmpty) return;
|
|
final key = (channelId, opening);
|
|
_pages.remove(key);
|
|
_pages[key] = people;
|
|
if (_pages.length > 20) _pages.remove(_pages.keys.first);
|
|
}
|
|
}
|
|
|
|
final _settledSearchesProvider = Provider<_SettledSearches>((ref) {
|
|
ref.watch(relaySessionProvider.select((s) => s.status));
|
|
ref.watch(relayConfigProvider);
|
|
return _SettledSearches();
|
|
});
|
|
|
|
/// Explicit mention choices per channel, newest highest. Memory only,
|
|
/// bounded to 100 channels and 100 keys each.
|
|
class MentionHistoryNotifier extends Notifier<Map<String, Map<String, int>>> {
|
|
var _clock = 0;
|
|
|
|
@override
|
|
Map<String, Map<String, int>> build() {
|
|
ref.watch(currentPubkeyProvider);
|
|
return const {};
|
|
}
|
|
|
|
void remember(String channelId, String pubkey) {
|
|
final channels = {...state};
|
|
final keys = {...?channels.remove(channelId)};
|
|
keys.remove(pubkey);
|
|
keys[pubkey] = ++_clock;
|
|
while (keys.length > 100) {
|
|
keys.remove(keys.keys.first);
|
|
}
|
|
channels[channelId] = keys;
|
|
while (channels.length > 100) {
|
|
channels.remove(channels.keys.first);
|
|
}
|
|
state = channels;
|
|
}
|
|
}
|
|
|
|
final mentionHistoryProvider =
|
|
NotifierProvider<MentionHistoryNotifier, Map<String, Map<String, int>>>(
|
|
MentionHistoryNotifier.new,
|
|
);
|
|
|
|
/// Ranked mention candidates for a channel + query, by the portable mention
|
|
/// rules: channel members, the agents offered here, and (in streams, forums
|
|
/// and DMs) people from a community directory search. A multi-word
|
|
/// query that continues no known name, including the names its search
|
|
/// found, is prose: it has no choices.
|
|
final mentionCandidatesProvider = Provider.family
|
|
.autoDispose<List<MentionCandidate>, MentionChooserArgs>((ref, args) {
|
|
final channelsAsync = ref.watch(channelsProvider);
|
|
final membersAsync = ref.watch(channelMembersProvider(args.channelId));
|
|
final sessionStatus = ref.watch(relaySessionProvider).status;
|
|
final cachedMembers = channelsAsync.asData == null
|
|
? const <ChannelMember>[]
|
|
: ref
|
|
.read(channelsProvider.notifier)
|
|
.cachedMembersForChannel(args.channelId);
|
|
final members = channelMembersForAutocomplete(
|
|
membersAsync: membersAsync,
|
|
sessionStatus: sessionStatus,
|
|
cachedMembers: cachedMembers,
|
|
);
|
|
final relayAgents =
|
|
ref.watch(agentDirectoryProvider).asData?.value ??
|
|
const <AgentDirectoryEntry>[];
|
|
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
|
|
final channels = channelsAsync.asData?.value ?? const <Channel>[];
|
|
final userCache = ref.watch(userCacheProvider);
|
|
final currentPubkey = ref.watch(currentPubkeyProvider);
|
|
final channel = channels
|
|
.where((candidate) => candidate.id == args.channelId)
|
|
.firstOrNull;
|
|
// Archived channels take no mentions.
|
|
if (channel?.isArchived == true) return const [];
|
|
final directory = channel?.mentionsOutsidePeople ?? false;
|
|
final settled = ref.watch(_settledSearchesProvider);
|
|
|
|
final sharedChannelIds = {
|
|
for (final channel in channels)
|
|
if (channel.isMember && !channel.isArchived) channel.id,
|
|
};
|
|
|
|
List<MentionCandidate> build(List<UserProfile> searchResults) => [
|
|
for (final candidate in buildMentionCandidates(
|
|
members: members,
|
|
relayAgents: relayAgents,
|
|
sharedChannelIds: sharedChannelIds,
|
|
userCache: userCache,
|
|
ownerByAgentPubkey: owners,
|
|
searchResults: searchResults,
|
|
currentPubkey: currentPubkey,
|
|
))
|
|
if (isMentionKey(candidate.pubkey)) candidate,
|
|
];
|
|
|
|
// Admission does not gate the search: `@Mary J` can name a person
|
|
// outside the channel, so the search runs and admission is checked
|
|
// again with the people it finds (portable mention rules, section 2).
|
|
var candidates = build(
|
|
directory
|
|
? settled.last(args.channelId, args.opening) ??
|
|
const <UserProfile>[]
|
|
: const <UserProfile>[],
|
|
);
|
|
if (directory) {
|
|
final search = ref.watch(mentionUserSearchProvider(args.query));
|
|
final page = search.asData?.value;
|
|
if (page != null) {
|
|
settled.settle(args.channelId, args.opening, page);
|
|
candidates = build(page);
|
|
}
|
|
}
|
|
if (!matchesMentionQuery(args.query, [
|
|
for (final candidate in candidates)
|
|
if (candidate.displayName?.trim().isNotEmpty == true)
|
|
candidate.displayName!.trim(),
|
|
])) {
|
|
return const [];
|
|
}
|
|
|
|
final names = mentionPickerNames(
|
|
ref.watch(identityNameSourcesProvider),
|
|
candidates,
|
|
);
|
|
loadIdentityNameOwners(ref, names);
|
|
final presence = ref.watch(presenceCacheProvider);
|
|
return rankMentionCandidates(
|
|
[
|
|
for (final candidate in candidates)
|
|
candidate.withContextLabel(names.labelFor(candidate.pubkey)),
|
|
],
|
|
args.query,
|
|
viewer: currentPubkey,
|
|
history: ref.watch(mentionHistoryProvider)[args.channelId] ?? const {},
|
|
presence: (key) => presence[key] ?? 'unknown',
|
|
);
|
|
});
|
|
|
|
/// Picker labels compare every selectable choice, before query ranking
|
|
/// filters them, so a row's label does not change as the query narrows.
|
|
IdentityNames mentionPickerNames(
|
|
IdentityNameSources sources,
|
|
List<MentionCandidate> candidates,
|
|
) => sources.scope(
|
|
[for (final candidate in candidates) candidate.pubkey],
|
|
agentPubkeys: {
|
|
for (final candidate in candidates)
|
|
if (candidate.isAgent) candidate.pubkey,
|
|
},
|
|
fallbackNames: {
|
|
for (final candidate in candidates)
|
|
if (candidate.displayName?.trim().isNotEmpty == true)
|
|
candidate.pubkey: candidate.displayName!,
|
|
},
|
|
// A newly searched agent can carry its verified owner before its profile
|
|
// reaches the cache; that owner still decides mine-before-others.
|
|
ownerPubkeys: {
|
|
for (final candidate in candidates)
|
|
candidate.pubkey: ?candidate.ownerPubkey,
|
|
},
|
|
);
|
|
|
|
/// Whether the directory search for this chooser failed. The chooser keeps
|
|
/// the error and a retry instead of closing (portable mention rules,
|
|
/// section 2). Archived channels never search.
|
|
final mentionSearchFailedProvider = Provider.family
|
|
.autoDispose<bool, MentionChooserArgs>((ref, args) {
|
|
final channel = (ref.watch(channelsProvider).asData?.value ?? const [])
|
|
.where((candidate) => candidate.id == args.channelId)
|
|
.firstOrNull;
|
|
if (channel == null ||
|
|
channel.isArchived ||
|
|
!channel.mentionsOutsidePeople) {
|
|
return false;
|
|
}
|
|
final search = ref.watch(mentionUserSearchProvider(args.query));
|
|
return search.hasError && !search.isLoading;
|
|
});
|