diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index cb19e65b07a..5559a606119 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -472,6 +472,7 @@ class _RowAvatar extends StatelessWidget { final initial = profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); return AvatarImage( + pubkey: pubkey, imageUrl: profile?.avatarUrl, radius: activityAvatarSize / 2, backgroundColor: context.colors.primaryContainer, diff --git a/mobile/lib/features/channels/add_members_sheet.dart b/mobile/lib/features/channels/add_members_sheet.dart index 4041f1efbb2..5d48310bfd9 100644 --- a/mobile/lib/features/channels/add_members_sheet.dart +++ b/mobile/lib/features/channels/add_members_sheet.dart @@ -186,6 +186,7 @@ class AddChannelMembersSheet extends HookConsumerWidget { child: ListTile( contentPadding: EdgeInsets.zero, leading: AvatarImage( + pubkey: user.pubkey, imageUrl: user.avatarUrl, radius: 20, backgroundColor: diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart index 58127f90119..aac180c8c86 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart @@ -235,6 +235,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { ), ) : AvatarImage( + pubkey: pubkey, key: ValueKey('huddle-avatar-image-$pubkey'), imageUrl: profile?.avatarUrl, radius: avatarRadius, diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart index 48f55bfd879..a0a29e740fd 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart @@ -205,6 +205,7 @@ class _HuddleParticipantSpotlight extends ConsumerWidget { ), ), child: AvatarImage( + pubkey: pubkey, imageUrl: profile?.avatarUrl, radius: _huddleParticipantSpotlightRadius, backgroundColor: context.colors.primaryContainer, @@ -346,6 +347,7 @@ class _HuddleParticipantRoster extends ConsumerWidget { child: Row( children: [ AvatarImage( + pubkey: pubkey, imageUrl: profile?.avatarUrl, radius: 22, backgroundColor: diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 18bbfb67037..4284fdc5deb 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -339,6 +339,7 @@ class _UserAvatar extends StatelessWidget { final avatarUrl = profile?.avatarUrl; return AvatarImage( + pubkey: pubkey, imageUrl: avatarUrl, radius: size / 2, backgroundColor: context.colors.primaryContainer, diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index a129b127739..b657b1fe678 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -677,6 +677,7 @@ class _ChannelMemberPreviewRow extends StatelessWidget { return AppListRowRaw( leading: AvatarImage( + pubkey: member.pubkey, imageUrl: avatarUrl, radius: 20, backgroundColor: context.colors.primaryContainer, diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index e060e9e9ac4..ac096f06581 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -206,6 +206,7 @@ class _DmAvatar extends ConsumerWidget { clipBehavior: Clip.none, children: [ AvatarImage( + pubkey: otherPubkey, imageUrl: avatarUrl, radius: _kDmAvatarSize / 2, backgroundColor: context.colors.primaryContainer, diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index 14dab6497fe..3e13227d84d 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -735,6 +735,7 @@ class _NewDirectMessageSheet extends HookConsumerWidget { horizontal: Grid.half, ), leading: AvatarImage( + pubkey: user.pubkey, imageUrl: user.avatarUrl, radius: 20, backgroundColor: context.colors.primaryContainer, @@ -838,6 +839,7 @@ class _SelectedDmRecipientChip extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ AvatarImage( + pubkey: user.pubkey, imageUrl: user.avatarUrl, radius: 16, backgroundColor: context.colors.primaryContainer, diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 71ba39d14ec..18e34ad7f2a 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -23,6 +23,7 @@ import '../../shared/huddle/huddle_session.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/agent_provenance.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/concentric_sheet_surface.dart'; diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 012b42e0d4f..632af19afc2 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -252,9 +252,13 @@ class ComposeBar extends HookConsumerWidget { '\u0000', ); useEffect(() { + controller.setMentionPubkeys({ + for (final entry in mentionMap.value.entries) + entry.key.toLowerCase(): entry.value.pubkey, + }); controller.setAgentMentionNames(agentMentionLabels); return null; - }, [controller, agentMentionLabelsKey]); + }, [controller, agentMentionLabelsKey, mentionMap.value]); useEffect( () { final memberList = channelMembersForAutocomplete( diff --git a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart index 2862aad1f37..8552daad457 100644 --- a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart +++ b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart @@ -15,6 +15,7 @@ class _MarkdownRule { } class _MarkdownEditingController extends TextEditingController { + Map _mentionPubkeys = const {}; final Set _agentMentionNames = {}; TextSpan? _cachedTextSpan; String? _cachedText; @@ -24,6 +25,13 @@ class _MarkdownEditingController extends TextEditingController { Color? _cachedSurface; Map _channelNames = const {}; + void setMentionPubkeys(Map keys) { + if (mapEquals(keys, _mentionPubkeys)) return; + _mentionPubkeys = keys; + _cachedTextSpan = null; + notifyListeners(); + } + void setChannelNames(Map names) { if (mapEquals(_channelNames, names)) return; _channelNames = Map.unmodifiable(names); @@ -296,7 +304,11 @@ class _MarkdownEditingController extends TextEditingController { WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: _ComposerAgentMentionChip(label: label, textStyle: style), + child: _ComposerAgentMentionChip( + label: label, + textStyle: style, + pubkey: _mentionPubkeys[label.toLowerCase()], + ), ), ); // The visual chip replaces the `@` placeholder. Keep the label as @@ -547,17 +559,19 @@ class _ComposerBuzzLinkChip extends StatelessWidget { } } -class _ComposerAgentMentionChip extends StatelessWidget { +class _ComposerAgentMentionChip extends ConsumerWidget { + final String? pubkey; final String label; final TextStyle textStyle; const _ComposerAgentMentionChip({ + this.pubkey, required this.label, required this.textStyle, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final style = textStyle.copyWith( color: context.colors.primary, fontWeight: FontWeight.w500, @@ -565,8 +579,11 @@ class _ComposerAgentMentionChip extends StatelessWidget { ); final fontSize = style.fontSize ?? 16; + final notManaged = + pubkey != null && ref.watch(agentNotManagedHereProvider(pubkey!)); return Semantics( - label: 'Agent mention: $label', + label: + 'Agent mention: $label${notManaged ? ', Not managed on this device' : ''}', excludeSemantics: true, child: Container( key: const ValueKey('composer-agent-mention-chip'), @@ -597,6 +614,7 @@ class _ComposerAgentMentionChip extends StatelessWidget { ), const SizedBox(width: Grid.quarter), Text(label, style: style), + AgentProvenance(pubkey: pubkey), ], ), ), diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index 31e57c85f02..49472625198 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -43,6 +43,7 @@ class _MentionSuggestions extends StatelessWidget { dense: true, visualDensity: VisualDensity.compact, leading: AvatarImage( + pubkey: candidate.pubkey, imageUrl: avatarUrl, radius: 18, backgroundColor: context.colors.primaryContainer, diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index ba720a408e9..3880007b5b6 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -233,6 +233,7 @@ class _MemberTile extends ConsumerWidget { return ListTile( contentPadding: EdgeInsets.zero, leading: _MemberAvatar( + pubkey: member.pubkey, avatarUrl: profile?.avatarUrl, initial: initial, isAgent: member.isBot || profile?.isAgent == true, @@ -446,11 +447,13 @@ class _RoleSelector extends StatelessWidget { } class _MemberAvatar extends StatelessWidget { + final String pubkey; final String? avatarUrl; final String initial; final bool isAgent; const _MemberAvatar({ + required this.pubkey, required this.avatarUrl, required this.initial, required this.isAgent, @@ -459,6 +462,7 @@ class _MemberAvatar extends StatelessWidget { @override Widget build(BuildContext context) { return AvatarImage( + pubkey: pubkey, imageUrl: avatarUrl, radius: 20, fallback: Text(initial), diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index f2cccbaa237..90058840876 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -16,6 +16,7 @@ import 'package:video_player/video_player.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/mentions/mention_bindings.dart'; import '../../shared/mentions/mention_tags.dart'; +import '../../shared/widgets/agent_provenance.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/deeplink/pending_deep_link_provider.dart'; import '../../shared/relay/relay.dart'; @@ -878,6 +879,7 @@ class _MentionMd extends InlineMd { final pill = _MentionPill( label: visibleLabel, semanticsLabel: fullLabel, + pubkey: pubkey, isAgent: isAgent, textStyle: config.style, ); @@ -893,12 +895,14 @@ class _MentionMd extends InlineMd { } class _MentionPill extends StatelessWidget { + final String? pubkey; final String label; final String? semanticsLabel; final bool isAgent; final TextStyle? textStyle; const _MentionPill({ + this.pubkey, required this.label, this.semanticsLabel, required this.isAgent, @@ -950,6 +954,7 @@ class _MentionPill extends StatelessWidget { Flexible( child: Text(label, style: style, semanticsLabel: semanticsLabel), ), + AgentProvenance(pubkey: pubkey), ], ), ); diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index 1b30fea3a2b..4155edc27f7 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -457,6 +457,7 @@ class _ReactorTile extends StatelessWidget { return ListTile( leading: _ReactorAvatar( + pubkey: pubkey, avatarUrl: profile?.avatarUrl, initial: profile?.initial ?? @@ -486,11 +487,13 @@ class _ReactorTile extends StatelessWidget { } class _ReactorAvatar extends StatelessWidget { + final String pubkey; final String? avatarUrl; final String initial; final bool isAgent; const _ReactorAvatar({ + required this.pubkey, required this.avatarUrl, required this.initial, required this.isAgent, @@ -499,6 +502,7 @@ class _ReactorAvatar extends StatelessWidget { @override Widget build(BuildContext context) { return AvatarImage( + pubkey: pubkey, imageUrl: avatarUrl, radius: 20, fallback: Text(initial), diff --git a/mobile/lib/features/channels/small_avatar.dart b/mobile/lib/features/channels/small_avatar.dart index be7a90955e6..9d7db296f7c 100644 --- a/mobile/lib/features/channels/small_avatar.dart +++ b/mobile/lib/features/channels/small_avatar.dart @@ -34,6 +34,7 @@ class SmallAvatar extends StatelessWidget { border: Border.all(color: context.colors.surface, width: 1.5), ), child: AvatarImage( + pubkey: pubkey, imageUrl: avatarUrl, radius: (size - 2) / 2, backgroundColor: context.colors.primaryContainer, diff --git a/mobile/lib/features/channels/thread_detail_page/avatar.dart b/mobile/lib/features/channels/thread_detail_page/avatar.dart index c5c14acce79..98db385d665 100644 --- a/mobile/lib/features/channels/thread_detail_page/avatar.dart +++ b/mobile/lib/features/channels/thread_detail_page/avatar.dart @@ -18,6 +18,7 @@ class _Avatar extends StatelessWidget { final avatarUrl = profile?.avatarUrl; return AvatarImage( + pubkey: pubkey, imageUrl: avatarUrl, radius: messageAvatarSize / 2, backgroundColor: context.colors.primaryContainer, diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index 9ec9a3afb1d..234bb0f1cf5 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -331,6 +331,7 @@ class _PostAvatar extends StatelessWidget { final avatarUrl = profile?.avatarUrl; return AvatarImage( + pubkey: pubkey, imageUrl: avatarUrl, radius: 14, backgroundColor: context.colors.primaryContainer, diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 298630b42c8..0edb04a9a48 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -642,6 +642,7 @@ class _Avatar extends StatelessWidget { final avatarUrl = profile?.avatarUrl; return AvatarImage( + pubkey: pubkey, imageUrl: avatarUrl, radius: radius, backgroundColor: context.colors.primaryContainer, diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 1bc8c78fc9c..523ce26766e 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -12,6 +12,7 @@ import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/utils/string_utils.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/agent_provenance.dart'; import '../../shared/widgets/buzz_action_tile.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/progressive_animated_avatar.dart'; @@ -172,6 +173,7 @@ class UserProfileSheet extends HookConsumerWidget { child: FractionallySizedBox( widthFactor: 0.5, child: _ProfileAvatar( + pubkey: pubkey, avatarUrl: avatarUrl, initial: initial, isAgent: profile?.isAgent == true, @@ -400,11 +402,13 @@ class _ProfilePresenceChip extends StatelessWidget { } class _ProfileAvatar extends HookWidget { + final String pubkey; final String? avatarUrl; final String initial; final bool isAgent; const _ProfileAvatar({ + required this.pubkey, required this.avatarUrl, required this.initial, required this.isAgent, @@ -440,12 +444,24 @@ class _ProfileAvatar extends HookWidget { imageUrl: animatedAvatar?.posterUrl ?? avatarUrl, fallback: _AvatarFallback(initial: initial), ); - if (!isAgent) return ClipOval(child: avatar); - return ClipRRect( - borderRadius: BorderRadius.circular( - constraints.biggest.shortestSide * 0.3, - ), - child: avatar, + final shaped = !isAgent + ? ClipOval(child: avatar) + : ClipRRect( + borderRadius: BorderRadius.circular( + constraints.biggest.shortestSide * 0.3, + ), + child: avatar, + ); + return Stack( + fit: StackFit.expand, + children: [ + shaped, + Positioned( + right: 0, + top: 0, + child: AgentProvenance(pubkey: pubkey, size: 24), + ), + ], ); }, ), diff --git a/mobile/lib/features/pulse/agent_activity_card.dart b/mobile/lib/features/pulse/agent_activity_card.dart index d5af2b3046f..ede704316e7 100644 --- a/mobile/lib/features/pulse/agent_activity_card.dart +++ b/mobile/lib/features/pulse/agent_activity_card.dart @@ -44,6 +44,7 @@ class AgentActivityCard extends HookConsumerWidget { Stack( children: [ AvatarImage( + pubkey: group.pubkey, imageUrl: profile?.avatarUrl, radius: 18, backgroundColor: context.colors.primaryContainer, diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart index 769240d3da0..7390709d81c 100644 --- a/mobile/lib/features/pulse/note_card.dart +++ b/mobile/lib/features/pulse/note_card.dart @@ -67,6 +67,7 @@ class NoteCard extends HookConsumerWidget { GestureDetector( onTap: () => showUserProfileSheet(context, note.pubkey), child: AvatarImage( + pubkey: note.pubkey, imageUrl: profile?.avatarUrl, radius: 18, backgroundColor: context.colors.primaryContainer, diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index b66a72a7098..2cb38dcf38e 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -725,6 +725,7 @@ class _PeopleSection extends ConsumerWidget { key: ValueKey('search-person-row-${user.pubkey}'), contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), leading: AvatarImage( + pubkey: user.pubkey, key: ValueKey('search-person-leading-${user.pubkey}'), imageUrl: user.avatarUrl, radius: 20, diff --git a/mobile/lib/shared/widgets/agent_provenance.dart b/mobile/lib/shared/widgets/agent_provenance.dart new file mode 100644 index 00000000000..6f57ff223d8 --- /dev/null +++ b/mobile/lib/shared/widgets/agent_provenance.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../mentions/agent_identity_provider.dart'; +import '../profile/user_cache_provider.dart'; +import '../relay/relay.dart'; +import '../theme/theme.dart'; + +/// Mobile has no local managed runtime inventory. Verified viewer-owned agents +/// are therefore not managed on this device; this says nothing about hosting. +final agentNotManagedHereProvider = Provider.family(( + ref, + pubkey, +) { + final viewer = ref.watch(myPubkeyProvider)?.toLowerCase(); + final key = pubkey.toLowerCase(); + final owners = ref.watch(agentOwnersProvider); + // Cached display evidence cannot keep a retired authority feed affirmative. + if (owners.isLoading || owners.hasError) return false; + final profiles = ref.watch(userCacheProvider); + final cache = ref.read(userCacheProvider.notifier); + final owner = cache.profilePubkeys.contains(key) + ? profiles[key]?.ownerPubkey + : owners.asData?.value[key]; + return viewer != null && owner == viewer && key != viewer; +}); + +/// One accessible cloud marker, shared by mobile identity surfaces. +class AgentProvenance extends ConsumerWidget { + final String? pubkey; + final double size; + const AgentProvenance({super.key, required this.pubkey, this.size = 12}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (pubkey == null || !ref.watch(agentNotManagedHereProvider(pubkey!))) { + return const SizedBox.shrink(); + } + const label = 'Not managed on this device'; + return Tooltip( + message: label, + excludeFromSemantics: true, + child: Icon( + LucideIcons.cloud, + size: size, + color: context.colors.primary, + semanticLabel: label, + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 33f4d65470d..eed8ef232b0 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -7,6 +7,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../animated_avatar.dart'; +import 'agent_provenance.dart'; import '../community/community_provider.dart'; import '../emoji/emoji_avatar.dart'; import '../emoji/native_emoji_glyph.dart'; @@ -24,6 +25,7 @@ class AvatarImage extends StatelessWidget { final Color? backgroundColor; final Widget fallback; final bool isAgent; + final String? pubkey; const AvatarImage({ super.key, @@ -32,10 +34,23 @@ class AvatarImage extends StatelessWidget { required this.fallback, this.backgroundColor, this.isAgent = false, + this.pubkey, }); @override Widget build(BuildContext context) { + final avatar = _buildAvatar(context); + if (pubkey == null) return avatar; + return Stack( + clipBehavior: Clip.none, + children: [ + avatar, + Positioned(right: 0, top: 0, child: AgentProvenance(pubkey: pubkey)), + ], + ); + } + + Widget _buildAvatar(BuildContext context) { final animatedAvatar = parseAnimatedAvatarUrl(imageUrl); final color = animatedAvatar == null ? backgroundColor : Colors.transparent; final content = SizedBox.square( diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 6293ae373e9..cbb790e1fa8 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -2662,6 +2662,19 @@ class _ReconnectingRelaySession extends RelaySessionNotifier { void Function(String message)? onClosed, }) async => () {}; + // The directory now uses the status-aware SDK seam. Do not fall through + // to a real session (and its EOSE fallback) in this connection-state fake. + @override + Future subscribeWithStatus( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + void Function(RelaySubscriptionStatus status)? onStatusChanged, + }) async { + onStatusChanged?.call(RelaySubscriptionStatus.ready); + return () {}; + } + void connect() { state = const SessionState(status: SessionStatus.connected); } diff --git a/mobile/test/shared/widgets/agent_provenance_delivery_test.dart b/mobile/test/shared/widgets/agent_provenance_delivery_test.dart new file mode 100644 index 00000000000..74a92296caf --- /dev/null +++ b/mobile/test/shared/widgets/agent_provenance_delivery_test.dart @@ -0,0 +1,218 @@ +import 'dart:async'; + +import 'package:buzz/shared/auth/auth_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../helpers/widget_helpers.dart'; +import '../crypto/nip_oa_test.dart' show authTag, profile; + +void main() { + testWidgets( + 'mounted avatar follows relay authority and retired generations', + (tester) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel( + 'dev.fluttercommunity.plus/connectivity_status', + ), + (_) async => null, + ); + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final peer = nostr.Keys.generate(); + final owned = profile(agent, [ + authTag(owner, agent.public), + ], createdAt: 1); + final revoked = profile(agent, [], createdAt: 2); + var history = owned; + final sockets = <_Socket>[]; + final config = _Config(owner.nsec); + final session = RelaySessionNotifier( + socketFactory: + ({ + required wsUrl, + required nsec, + required onMessage, + required onConnected, + required onDisconnected, + }) { + final socket = _Socket( + wsUrl: wsUrl, + nsec: nsec, + onMessage: onMessage, + onConnected: onConnected, + onDisconnected: onDisconnected, + history: () => history, + holdEose: sockets.isEmpty, + directory: profile(agent, [], kind: 10100), + ); + sockets.add(socket); + return socket; + }, + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + authProvider.overrideWith(_Auth.new), + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => session), + ], + child: AvatarImage( + imageUrl: null, + radius: 24, + fallback: const Text('A'), + pubkey: agent.public, + isAgent: true, + ), + ), + ); + await tester.pumpAndSettle(); + void marker(bool visible) => expect( + find.byIcon(LucideIcons.cloud), + visible ? findsOneWidget : findsNothing, + ); + final first = sockets.single; + expect(first.profiles, isNotEmpty, reason: 'production must subscribe'); + await tester.pump(const Duration(milliseconds: 600)); + marker(false); // Acquisition timeout is not authoritative EOSE. + for (final id in first.profiles) { + first.receive(['EOSE', id]); + } + await tester.pumpAndSettle(); + marker(true); // Signed current-context positive before every denial. + first.deliver(revoked); + await tester.pumpAndSettle(); + marker(false); + first.deliver(owned); + await tester.pumpAndSettle(); + marker(false); + first.drop(); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + expect( + sockets.length, + 2, + reason: 'production reconnect creates a socket', + ); + marker(false); // Reconnect history still contains the older owned event. + final current = sockets.last; + current.deliver( + profile(agent, [authTag(owner, agent.public)], createdAt: 3), + ); + await tester.pumpAndSettle(); + marker(true); + for (final id in current.profiles.toList()) { + current.receive(['CLOSED', id, 'restricted: no longer valid']); + } + await tester.pumpAndSettle(); + marker( + false, + ); // Positive display cache cannot bypass terminal feed failure. + history = profile(agent, [], createdAt: 4); + current.drop(); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + marker(false); + for (final next in [ + (url: 'https://one.example', owner: peer), + (url: 'https://two.example', owner: peer), + ]) { + final retired = sockets.last; + final ids = retired.profiles.toList(); + config.update(baseUrl: next.url, nsec: next.owner.nsec); + await tester.pumpAndSettle(); + marker(false); + expect(identical(sockets.last, retired), isFalse); + final stale = profile(agent, [ + authTag(next.owner, agent.public), + ], createdAt: 10); + for (final id in ids) { + retired.receive(['EVENT', id, stale.toJson()]); + } + await tester.pumpAndSettle(); + marker(false); + sockets.last.deliver( + profile(agent, [authTag(next.owner, agent.public)], createdAt: 11), + ); + await tester.pumpAndSettle(); + marker(true); // New context can establish its own authority. + } + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + }, + ); +} + +class _Auth extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.authenticated); +} + +class _Config extends RelayConfigNotifier { + _Config(this.key); + final String key; + @override + RelayConfig build() => RelayConfig(baseUrl: 'https://one.example', nsec: key); +} + +// Only the socket is controlled: production owns history, subscription readiness, +// event buffering, CLOSED dispatch, reconnect timers and generation fencing. +class _Socket extends RelaySocket { + _Socket({ + required super.wsUrl, + required super.nsec, + required super.onMessage, + required super.onConnected, + required super.onDisconnected, + required this.history, + required this.holdEose, + required this.directory, + }) : receive = onMessage, + connected = onConnected, + disconnected = onDisconnected; + final void Function(List) receive; + final void Function() connected; + final void Function(Object?) disconnected; + final NostrEvent Function() history; + final bool holdEose; + final NostrEvent directory; + final profiles = {}; + @override + Future connect() async => connected(); + @override + void dispose() {} + void drop() => disconnected(null); + void deliver(NostrEvent event) { + for (final id in profiles.toList()) { + receive(['EVENT', id, event.toJson()]); + } + } + + @override + void send(List payload) { + final id = payload[1] as String; + if (payload.first == 'CLOSE') { + profiles.remove(id); + return; + } + if (payload.first != 'REQ') return; + final kinds = (payload[2] as Map)['kinds'] as List; + if (id.startsWith('l-') && kinds.contains(0)) profiles.add(id); + scheduleMicrotask(() { + if (kinds.contains(10100) || kinds.contains(0)) { + final event = kinds.contains(10100) ? directory : history(); + receive(['EVENT', id, event.toJson()]); + } + if (!kinds.contains(0) || !holdEose) { + receive(['EOSE', id]); + } + }); + } +} diff --git a/mobile/test/shared/widgets/agent_provenance_test.dart b/mobile/test/shared/widgets/agent_provenance_test.dart new file mode 100644 index 00000000000..68ee6784592 --- /dev/null +++ b/mobile/test/shared/widgets/agent_provenance_test.dart @@ -0,0 +1,140 @@ +import 'package:buzz/features/channels/message_content.dart'; +import 'package:buzz/features/profile/user_profile_sheet.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/widgets/agent_provenance.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../helpers/widget_helpers.dart'; + +const _agent = + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; +const _owner = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const _peer = + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + +void main() { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity_status'), + (_) async => null, + ); + }); + testWidgets('shared avatar and mention use one accessible marker each', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final cache = _Cache(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + myPubkeyProvider.overrideWithValue(_owner), + userCacheProvider.overrideWith(() => cache), + agentOwnersProvider.overrideWith((ref) async => {_agent: _owner}), + ], + child: const Column( + children: [ + AvatarImage( + imageUrl: null, + radius: 24, + fallback: Text('A'), + pubkey: _agent, + isAgent: true, + ), + MessageContent( + content: 'Hello @Agent', + mentionNames: {_agent: 'Agent'}, + agentMentionPubkeys: {_agent}, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(LucideIcons.cloud), findsNWidgets(2)); + expect( + find.bySemanticsLabel(RegExp('Not managed on this device')), + findsNWidgets(2), + ); + expect(find.text('Agent'), findsOneWidget); + semantics.dispose(); + }); + + testWidgets('peer, unknown owner, self and unknown viewer are unmarked', ( + tester, + ) async { + for (final viewer in [_peer, null, _agent]) { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + myPubkeyProvider.overrideWithValue(viewer), + userCacheProvider.overrideWith(_Cache.new), + agentOwnersProvider.overrideWith((ref) async => {_agent: _owner}), + ], + child: const AgentProvenance(pubkey: _agent), + ), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(LucideIcons.cloud), findsNothing); + await tester.pumpWidget(const SizedBox()); + } + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + myPubkeyProvider.overrideWithValue(_owner), + userCacheProvider.overrideWith(_Cache.new), + agentOwnersProvider.overrideWith( + (ref) async => throw StateError('offline'), + ), + ], + child: const AgentProvenance(pubkey: _peer), + ), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(LucideIcons.cloud), findsNothing); + }); + + testWidgets('profile hero preserves its full hit area and marker', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + myPubkeyProvider.overrideWithValue(_owner), + userCacheProvider.overrideWith(_Cache.new), + agentOwnersProvider.overrideWith((ref) async => {_agent: _owner}), + ], + child: const UserProfileSheet(pubkey: _agent), + ), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(LucideIcons.cloud), findsOneWidget); + final avatar = tester.getSize( + find.byKey(const ValueKey('selected-profile-avatar')), + ); + expect(avatar.width, greaterThan(100)); + expect(avatar.height, avatar.width); + }); +} + +class _Cache extends UserCacheNotifier { + @override + Map build() => const { + _agent: UserProfile( + pubkey: _agent, + displayName: 'Agent', + ownerPubkey: _owner, + ), + _peer: UserProfile(pubkey: _peer, displayName: 'Peer', ownerPubkey: _peer), + }; + @override + Future preload(List pubkeys) async => true; +}