mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
## Summary Mobile kept offering archived people and agents as choices: Add members (including adding an agent to a Solo chat), New DM, the `@` mention chooser, Search › People and the Agents page. An archived agent that shares a live agent's name also forced a qualifier onto the live one, because names were told apart across both. The members sheet listed archived members among live ones with nothing to mark them. Now, matching desktop: - Archived people and agents are left out of those choices. History (messages, activity, profiles) still shows them. - The members sheet moves archived members out of People and Agents into their own "Archived · N" section after both: archived people, then archived agents. Each row reads "Archived · Member" (or Agent) in place of the role, status or "Working…" line. A screen reader reads it with the name. The members preview on the channel info page (the first five members above "See all") follows the same order and label. - Nothing is hidden or marked while the community's archive list is unknown or cannot be read, and you are never hidden from yourself. ### Related issue None found. The desktop members dialog gets the same Archived section in https://github.com/block/buzz-app/pull/3203. ### Testing Widget and provider tests cover each surface, the unknown-archive case, the viewer rule and a community switch. Each test fails without its change. Android emulator, local relay, fictional names. Each archived person or agent has a live namesake. History rows still show archived identities, as intended. **`@` mention chooser, typing "@Sc" (Scout `qsfn` is archived)** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/6693729c-66c1-4433-8a7b-4f0e948057ee" width="320"> | <img src="https://github.com/user-attachments/assets/eb4f80be-780e-4dbe-8b2b-e6a5b6c3ca97" width="320"> | **`@` mention chooser, typing "@Ca" (Casey Lee `9mhy` is archived)** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/b6d409cd-7af1-4da7-b5b9-deece5e21de5" width="320"> | <img src="https://github.com/user-attachments/assets/60e21387-791b-4233-b8e6-1a29f94e6810" width="320"> | **Add members, searching "Morgan" (Morgan Reyes `ssc2` is archived)** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/1222a923-5e43-4263-843d-57564bdc7af3" width="320"> | <img src="https://github.com/user-attachments/assets/dbe58278-788d-4a89-83bf-f0b4bc119c5e" width="320"> | **Add members, searching "Atlas" (Atlas `85gl` is archived)** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/55c67a21-4cf5-47c5-b6ac-1ba80d68a3ec" width="320"> | <img src="https://github.com/user-attachments/assets/54b19ab3-fa2f-4933-8b67-1056d6e3b3a4" width="320"> | **New DM, no query yet** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/6a26fd49-4a06-47bd-ba37-37e5898ab344" width="320"> | <img src="https://github.com/user-attachments/assets/3f33eb2b-f27a-4528-91c0-ac76d3fdf44b" width="320"> | **New DM, searching "Morgan"** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/22d2d617-3074-4910-8c39-e0778e47a879" width="320"> | <img src="https://github.com/user-attachments/assets/fa00b160-e350-49af-94d0-beb15c357bef" width="320"> | **Search › People, "Morgan"** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/83a28f2b-9b22-4d0c-8728-8a2dfb10e634" width="320"> | <img src="https://github.com/user-attachments/assets/573aed3f-75ab-478f-8c11-bedab5c6ce4f" width="320"> | **Agents page** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/d7980f95-825c-4d1a-ad67-95f906a52e9a" width="320"> | <img src="https://github.com/user-attachments/assets/6252c38a-b2ec-48dc-ab4e-824ea29bbd85" width="320"> | **Channel info, members preview** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/8744cdfd-c073-4ade-bd53-9d5e137cee60" width="320"> | <img src="https://github.com/user-attachments/assets/5240b1be-13d5-4f22-85a9-8375f1d8ac18" width="320"> | **Members sheet, top** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/c9241478-99b1-452e-ad41-8c38fe080182" width="320"> | <img src="https://github.com/user-attachments/assets/3c6f9247-60c6-47df-94b0-9175f25805bb" width="320"> | **Members sheet, scrolled to the end** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/7da7fcc8-28ea-4c7a-904e-91ca5a068d23" width="320"> | <img src="https://github.com/user-attachments/assets/024f676e-8150-457d-b7d0-67fa1a680f8d" width="320"> | ## Details - The archive reader (the community's NIP-IA archive list, signed by the relay's NIP-11 `self` key) moves from `shared/me` to `shared/identity_archives`, since it is no longer Solo-only. `hiddenArchivedIdentitiesProvider` is the single rule the surfaces read. - Pulling to refresh on the Agents page also rereads the archive list. Other views reuse the last list, as on desktop; it is also reread on reconnect and when the agent inventory changes. - A refresh keeps the last known list. After a community or account switch, the previous community's list does not apply: nobody is hidden until the new list arrives. - The shared chooser drops archived identities before names are compared, which covers Add members, New DM and mentions. - Search › People asks the relay for 24 people and shows the first 8 that are not archived, so archived matches do not push live people out. - The channel info preview shows the label on one line, so with a long name the role after "Archived" can be cut off. A screen reader still reads it in full. - Not changed: typing a full `@Name` by hand (without the chooser) still resolves against archived channel members too, so an archived namesake can still make a typed name ambiguous. Picking from the chooser is unaffected. Possible follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
4.2 KiB
Dart
114 lines
4.2 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../shared/agents/owned_agent_directory.dart';
|
|
import '../../shared/identity_archives/identity_archives.dart';
|
|
import '../../shared/me/me_sync.dart';
|
|
import '../../shared/relay/relay.dart';
|
|
import '../../shared/mentions/agent_identity_provider.dart';
|
|
import '../../shared/profile/user_cache_provider.dart';
|
|
import 'channel.dart';
|
|
import 'channel_management_provider.dart';
|
|
import 'channels_provider.dart';
|
|
|
|
/// Runtime is owner-signed discovery evidence, not permission to run an agent.
|
|
/// The hosting desktop rechecks its live runtime, protection and room access.
|
|
/// Derived from the owned-agent directory an agent DM already loads, so voice
|
|
/// adds no inventory read.
|
|
final ownedCodexVoiceAgentsProvider = FutureProvider<Set<String>>((ref) async {
|
|
ref.watch(_voiceInventoryChangesProvider);
|
|
final archived = ref.watch(identityArchivesProvider.future);
|
|
final owned = await ref.watch(ownedAgentDirectoryProvider.future);
|
|
final hidden = await archived;
|
|
return {
|
|
for (final agent in owned)
|
|
if (agent.runtime == 'buzz.codex/codex' && !hidden.contains(agent.pubkey))
|
|
agent.pubkey,
|
|
};
|
|
});
|
|
|
|
/// Only the viewer's Codex peer in a writable one-to-one DM is offered voice.
|
|
String? dmVoiceAgent(Channel channel, String? viewer, Set<String> ownedCodex) {
|
|
final members = channel.participantPubkeys.toSet();
|
|
if (!channel.isDm ||
|
|
channel.isArchived ||
|
|
!channel.isMember ||
|
|
members.length != 2 ||
|
|
!members.contains(viewer)) {
|
|
return null;
|
|
}
|
|
final peer = members.firstWhere((key) => key != viewer);
|
|
return ownedCodex.contains(peer) ? peer : null;
|
|
}
|
|
|
|
// Subscribe separately from the snapshot so refreshing it does not replay the
|
|
// subscription and cause an invalidation loop. Reconnects resubscribe. A
|
|
// desktop inventory change rereads the directory, which mentions share.
|
|
final _voiceInventoryChangesProvider = Provider.autoDispose<void>((ref) {
|
|
final sync = ref.watch(meSyncProvider);
|
|
ref.watch(relaySessionProvider.select((state) => state.status));
|
|
var disposed = false;
|
|
void Function()? unsubscribe;
|
|
unawaited(() async {
|
|
try {
|
|
unsubscribe = await sync.session.subscribe(
|
|
NostrFilter(kinds: const [30177], authors: [sync.pubkey], limit: 0),
|
|
(_) {
|
|
if (!disposed) ref.invalidate(ownedAgentDirectoryProvider);
|
|
ref.invalidate(identityArchivesProvider);
|
|
},
|
|
);
|
|
if (disposed) unsubscribe?.call();
|
|
} catch (error, stack) {
|
|
if (!disposed) {
|
|
FlutterError.reportError(
|
|
FlutterErrorDetails(exception: error, stack: stack),
|
|
);
|
|
}
|
|
}
|
|
}());
|
|
ref.onDispose(() {
|
|
disposed = true;
|
|
unsubscribe?.call();
|
|
});
|
|
});
|
|
|
|
/// Classification only: ordinary human/group DMs never depend on inventory
|
|
/// network availability. Admission still requires fresh signed evidence below.
|
|
final dmVoiceCandidateProvider = Provider.autoDispose.family<String?, String>((
|
|
ref,
|
|
channelId,
|
|
) {
|
|
final channel = ref.watch(channelByIdProvider(channelId));
|
|
if (channel == null) return null;
|
|
final known = ref.watch(agentMentionPubkeysProvider(channelId));
|
|
final profiles = ref.watch(userCacheProvider);
|
|
final owned =
|
|
ref.watch(ownedCodexVoiceAgentsProvider).value ?? const <String>{};
|
|
return dmVoiceAgent(channel, ref.watch(currentPubkeyProvider), {
|
|
...known,
|
|
...owned,
|
|
for (final profile in profiles.values)
|
|
if (profile.ownerPubkey != null) profile.pubkey,
|
|
});
|
|
});
|
|
|
|
/// All new-call paths, including recovery, resolve fresh signed evidence.
|
|
Future<String?> resolveDmVoiceAgent(Ref ref, String parentChannelId) async {
|
|
final channel = ref.read(channelByIdProvider(parentChannelId));
|
|
if (channel == null ||
|
|
ref.read(dmVoiceCandidateProvider(parentChannelId)) == null) {
|
|
return null;
|
|
}
|
|
ref.invalidate(ownedAgentDirectoryProvider);
|
|
ref.invalidate(identityArchivesProvider);
|
|
final agents = await ref.read(ownedCodexVoiceAgentsProvider.future);
|
|
final agent = dmVoiceAgent(channel, ref.read(currentPubkeyProvider), agents);
|
|
if (agent == null) {
|
|
throw StateError('This agent is no longer available for voice.');
|
|
}
|
|
return agent;
|
|
}
|