From 088c4e7da71c8635c72db3f5fa92539c6fcb5280 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 2 May 2026 10:54:21 -0600 Subject: [PATCH] feat(mobile): add user profile sheet and polish bottom sheets (#453) Co-authored-by: Claude Opus 4.6 --- .../channels/channel_detail_page.dart | 20 +- .../features/channels/date_formatters.dart | 9 +- .../lib/features/channels/members_sheet.dart | 150 ++++--- .../features/channels/message_actions.dart | 83 ++-- .../features/channels/thread_detail_page.dart | 20 +- .../lib/features/forum/forum_post_card.dart | 21 +- .../lib/features/forum/forum_thread_page.dart | 41 +- .../features/profile/set_status_sheet.dart | 215 +++++------ .../features/profile/user_cache_provider.dart | 1 + mobile/lib/features/profile/user_profile.dart | 3 + .../features/profile/user_profile_sheet.dart | 365 ++++++++++++++++++ mobile/lib/shared/utils/string_utils.dart | 5 + 12 files changed, 667 insertions(+), 266 deletions(-) create mode 100644 mobile/lib/features/profile/user_profile_sheet.dart create mode 100644 mobile/lib/shared/utils/string_utils.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 31e72666b..21aee697d 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -32,6 +32,7 @@ import 'read_state/read_state_provider.dart'; import 'read_state/read_state_time.dart'; import 'reaction_row.dart'; import 'send_message_provider.dart'; +import '../profile/user_profile_sheet.dart'; import 'small_avatar.dart'; import 'thread_detail_page.dart'; import 'timeline_message.dart'; @@ -833,7 +834,10 @@ class _MessageBubble extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showAuthor) - _UserAvatar(profile: profile, pubkey: message.pubkey) + GestureDetector( + onTap: () => showUserProfileSheet(context, message.pubkey), + child: _UserAvatar(profile: profile, pubkey: message.pubkey), + ) else const SizedBox(width: 28), const SizedBox(width: Grid.xxs), @@ -846,11 +850,15 @@ class _MessageBubble extends ConsumerWidget { padding: const EdgeInsets.only(bottom: Grid.quarter), child: Row( children: [ - Text( - displayName, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.colors.onSurface, + GestureDetector( + onTap: () => + showUserProfileSheet(context, message.pubkey), + child: Text( + displayName, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.colors.onSurface, + ), ), ), const SizedBox(width: Grid.xxs), diff --git a/mobile/lib/features/channels/date_formatters.dart b/mobile/lib/features/channels/date_formatters.dart index 516baf9aa..9d9ddc878 100644 --- a/mobile/lib/features/channels/date_formatters.dart +++ b/mobile/lib/features/channels/date_formatters.dart @@ -1,6 +1,9 @@ import 'package:flutter/foundation.dart'; import 'package:intl/intl.dart'; +// Re-export shortPubkey so existing callers continue to compile. +export '../../shared/utils/string_utils.dart' show shortPubkey; + final _fullDateFormat = DateFormat('EEEE, MMMM d, y'); /// Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026". @@ -76,9 +79,3 @@ String formatMessageTime(int unixSeconds) { } return '$hh:$mm'; } - -/// Truncates a hex pubkey to the first 8 characters with an ellipsis. -String shortPubkey(String pubkey) { - if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026'; - return pubkey; -} diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index 096418740..43552cc89 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -83,94 +83,84 @@ class MembersSheet extends HookConsumerWidget { Grid.xs, 0, Grid.xs, - MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + MediaQuery.viewInsetsOf(context).bottom, ), - child: SafeArea( - top: false, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Members', style: context.textTheme.titleMedium), - const SizedBox(height: Grid.xxs), - Text( - 'People in ${channel.displayLabel(currentPubkey: currentPubkey)}.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - if (!channel.isDm) ...[const Divider(height: Grid.sm)], - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 400), - child: membersAsync.when( - data: (_) => ListView( - shrinkWrap: true, - children: [ - if (people.isNotEmpty) ...[ - _SectionLabel(label: 'People — ${people.length}'), - for (final member in people) - _MemberTile( - member: member, - currentPubkey: currentPubkey, - profile: userCache[member.pubkey.toLowerCase()], - canManage: canManage, - isSelf: - member.pubkey.toLowerCase() == - currentPubkey?.toLowerCase(), - channelId: channel.id, - userStatus: - statusCache[member.pubkey.toLowerCase()], - ), - ], - if (bots.isNotEmpty) ...[ - const SizedBox(height: Grid.xxs), - _SectionLabel(label: 'Bots — ${bots.length}'), - for (final bot in bots) - _MemberTile( - member: bot, - currentPubkey: currentPubkey, - profile: userCache[bot.pubkey.toLowerCase()], - canManage: canManage, - isSelf: false, - channelId: channel.id, - isWorking: typingBotPubkeys.contains( - bot.pubkey.toLowerCase(), - ), - onViewActivity: () => openActivity(bot), - onActivityTap: - typingBotPubkeys.contains( - bot.pubkey.toLowerCase(), - ) - ? () => openActivity(bot) - : null, - ), - ], - if (people.isEmpty && bots.isEmpty) - Center( - child: Text( - 'No members found.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Members', style: context.textTheme.titleMedium), + const SizedBox(height: Grid.xxs), + if (!channel.isDm) ...[const Divider(height: 1)], + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: membersAsync.when( + data: (_) => ListView( + shrinkWrap: true, + padding: const EdgeInsets.only(top: Grid.xxs), + children: [ + if (people.isNotEmpty) ...[ + _SectionLabel(label: 'People — ${people.length}'), + for (final member in people) + _MemberTile( + member: member, + currentPubkey: currentPubkey, + profile: userCache[member.pubkey.toLowerCase()], + canManage: canManage, + isSelf: + member.pubkey.toLowerCase() == + currentPubkey?.toLowerCase(), + channelId: channel.id, + userStatus: statusCache[member.pubkey.toLowerCase()], ), ], - ), - loading: () => - const Center(child: CircularProgressIndicator()), - error: (error, _) => Center( - child: Text( - error.toString(), - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, + if (bots.isNotEmpty) ...[ + const SizedBox(height: Grid.xxs), + _SectionLabel(label: 'Bots — ${bots.length}'), + for (final bot in bots) + _MemberTile( + member: bot, + currentPubkey: currentPubkey, + profile: userCache[bot.pubkey.toLowerCase()], + canManage: canManage, + isSelf: false, + channelId: channel.id, + isWorking: typingBotPubkeys.contains( + bot.pubkey.toLowerCase(), + ), + onViewActivity: () => openActivity(bot), + onActivityTap: + typingBotPubkeys.contains( + bot.pubkey.toLowerCase(), + ) + ? () => openActivity(bot) + : null, + ), + ], + if (people.isEmpty && bots.isEmpty) + Center( + child: Text( + 'No members found.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), ), + ], + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text( + error.toString(), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, ), ), ), ), - ], - ), + ), + ], ), ), ); diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 331c6c5a6..ce195028e 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -186,51 +186,48 @@ void _showEditSheet({ Grid.xs, 0, Grid.xs, - MediaQuery.viewInsetsOf(sheetContext).bottom + Grid.xs, + MediaQuery.viewInsetsOf(sheetContext).bottom, ), - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: controller, - autofocus: true, - minLines: 1, - maxLines: 5, - decoration: const InputDecoration(hintText: 'Edit message'), - ), - const SizedBox(height: Grid.xxs), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(sheetContext).pop(), - child: const Text('Cancel'), - ), - const SizedBox(width: Grid.half), - FilledButton( - onPressed: () { - final text = controller.text.trim(); - if (text.isEmpty || text == message.content) { - Navigator.of(sheetContext).pop(); - return; - } - ref - .read(channelActionsProvider) - .editMessage( - channelId: channelId, - eventId: message.id, - content: text, - ); + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: controller, + autofocus: true, + minLines: 1, + maxLines: 5, + decoration: const InputDecoration(hintText: 'Edit message'), + ), + const SizedBox(height: Grid.xxs), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(sheetContext).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: Grid.half), + FilledButton( + onPressed: () { + final text = controller.text.trim(); + if (text.isEmpty || text == message.content) { Navigator.of(sheetContext).pop(); - }, - child: const Text('Save'), - ), - ], - ), - ], - ), + return; + } + ref + .read(channelActionsProvider) + .editMessage( + channelId: channelId, + eventId: message.id, + content: text, + ); + Navigator.of(sheetContext).pop(); + }, + child: const Text('Save'), + ), + ], + ), + ], ), ), ); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 0968b7825..8c6bdb467 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -12,6 +12,7 @@ import 'channel_typing_provider.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; import 'date_formatters.dart'; +import '../profile/user_profile_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; import 'reaction_row.dart'; @@ -391,7 +392,10 @@ class _ThreadMessage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showAuthor) - _Avatar(profile: profile, pubkey: message.pubkey) + GestureDetector( + onTap: () => showUserProfileSheet(context, message.pubkey), + child: _Avatar(profile: profile, pubkey: message.pubkey), + ) else const SizedBox(width: 28), const SizedBox(width: Grid.xxs), @@ -404,11 +408,15 @@ class _ThreadMessage extends ConsumerWidget { padding: const EdgeInsets.only(bottom: Grid.quarter), child: Row( children: [ - Text( - displayName, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.colors.onSurface, + GestureDetector( + onTap: () => + showUserProfileSheet(context, message.pubkey), + child: Text( + displayName, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.colors.onSurface, + ), ), ), const SizedBox(width: Grid.xxs), diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index 7f8205731..2da176001 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -6,6 +6,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; import '../channels/message_content.dart'; import '../profile/user_cache_provider.dart'; +import '../profile/user_profile_sheet.dart'; import '../profile/user_profile.dart'; import 'forum_models.dart'; @@ -63,15 +64,23 @@ class ForumPostCard extends ConsumerWidget { // Author row Row( children: [ - _PostAvatar(profile: profile, pubkey: post.pubkey), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => showUserProfileSheet(context, post.pubkey), + child: _PostAvatar(profile: profile, pubkey: post.pubkey), + ), const SizedBox(width: Grid.xxs), Expanded( - child: Text( - displayName, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => showUserProfileSheet(context, post.pubkey), + child: Text( + displayName, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, ), - overflow: TextOverflow.ellipsis, ), ), Text( diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 368095148..b18aceae8 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -13,6 +13,7 @@ import '../channels/compose_bar.dart'; import '../channels/message_content.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import '../profile/user_profile_sheet.dart'; import 'forum_models.dart'; import 'forum_provider.dart'; @@ -322,16 +323,26 @@ class _OriginalPost extends ConsumerWidget { children: [ Row( children: [ - _Avatar(profile: profile, pubkey: post.pubkey, radius: 16), + GestureDetector( + onTap: () => showUserProfileSheet(context, post.pubkey), + child: _Avatar( + profile: profile, + pubkey: post.pubkey, + radius: 16, + ), + ), const SizedBox(width: Grid.xxs), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - displayName, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, + GestureDetector( + onTap: () => showUserProfileSheet(context, post.pubkey), + child: Text( + displayName, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ), ), ), Text( @@ -391,15 +402,25 @@ class _ReplyRow extends ConsumerWidget { children: [ Row( children: [ - _Avatar(profile: profile, pubkey: reply.pubkey, radius: 12), + GestureDetector( + onTap: () => showUserProfileSheet(context, reply.pubkey), + child: _Avatar( + profile: profile, + pubkey: reply.pubkey, + radius: 12, + ), + ), const SizedBox(width: Grid.xxs), Expanded( child: Row( children: [ - Text( - displayName, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, + GestureDetector( + onTap: () => showUserProfileSheet(context, reply.pubkey), + child: Text( + displayName, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ), ), ), const SizedBox(width: Grid.xxs), diff --git a/mobile/lib/features/profile/set_status_sheet.dart b/mobile/lib/features/profile/set_status_sheet.dart index 41f8fa363..5bd554c9a 100644 --- a/mobile/lib/features/profile/set_status_sheet.dart +++ b/mobile/lib/features/profile/set_status_sheet.dart @@ -98,125 +98,122 @@ class _SetStatusSheet extends HookConsumerWidget { Grid.xs, 0, Grid.xs, - MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + MediaQuery.viewInsetsOf(context).bottom, ), - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Set a status', style: context.textTheme.titleMedium), - const SizedBox(height: Grid.half), - Text( - 'Let others know what you\u2019re up to.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Set a status', style: context.textTheme.titleMedium), + const SizedBox(height: Grid.half), + Text( + 'Let others know what you\u2019re up to.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.twelve), + + // Text input with emoji preview + Row( + children: [ + Container( + width: 40, + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border.all(color: context.colors.outlineVariant), + borderRadius: BorderRadius.circular(Radii.md), + ), + child: Text( + emoji.value.isNotEmpty ? emoji.value : '\u{1F4AC}', + style: const TextStyle(fontSize: 18), + ), ), - ), - const SizedBox(height: Grid.twelve), - - // Text input with emoji preview - Row( - children: [ - Container( - width: 40, - height: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - border: Border.all(color: context.colors.outlineVariant), - borderRadius: BorderRadius.circular(Radii.md), - ), - child: Text( - emoji.value.isNotEmpty ? emoji.value : '\u{1F4AC}', - style: const TextStyle(fontSize: 18), - ), - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: TextField( - controller: textController, - autofocus: true, - decoration: const InputDecoration( - hintText: 'What\u2019s your status?', - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric( - horizontal: Grid.twelve, - vertical: Grid.xxs, - ), + const SizedBox(width: Grid.xxs), + Expanded( + child: TextField( + controller: textController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'What\u2019s your status?', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.xxs, ), - textInputAction: TextInputAction.done, - onSubmitted: (_) { - if (hasContent) handleSave(); - }, ), + textInputAction: TextInputAction.done, + onSubmitted: (_) { + if (hasContent) handleSave(); + }, ), - ], - ), - const SizedBox(height: Grid.twelve), + ), + ], + ), + const SizedBox(height: Grid.twelve), - // Emoji grid - Wrap( - spacing: Grid.half, - runSpacing: Grid.half, - children: [ - for (final option in _emojiOptions) - _EmojiButton( - emoji: option.emoji, - label: option.label, - selected: emoji.value == option.emoji, - onTap: () { - emoji.value = emoji.value == option.emoji - ? '' - : option.emoji; - }, - ), - ], - ), - const SizedBox(height: Grid.twelve), + // Emoji grid + Wrap( + spacing: Grid.half, + runSpacing: Grid.half, + children: [ + for (final option in _emojiOptions) + _EmojiButton( + emoji: option.emoji, + label: option.label, + selected: emoji.value == option.emoji, + onTap: () { + emoji.value = emoji.value == option.emoji + ? '' + : option.emoji; + }, + ), + ], + ), + const SizedBox(height: Grid.twelve), - // Presets - Wrap( - spacing: Grid.half, - runSpacing: Grid.half, - children: [ - for (final preset in _presets) - ActionChip( - label: Text('${preset.emoji} ${preset.text}'), - labelStyle: context.textTheme.labelSmall, - onPressed: () { - textController.text = preset.text; - emoji.value = preset.emoji; - }, - ), - ], - ), - const SizedBox(height: Grid.xs), + // Presets + Wrap( + spacing: Grid.half, + runSpacing: Grid.half, + children: [ + for (final preset in _presets) + ActionChip( + label: Text('${preset.emoji} ${preset.text}'), + labelStyle: context.textTheme.labelSmall, + onPressed: () { + textController.text = preset.text; + emoji.value = preset.emoji; + }, + ), + ], + ), + const SizedBox(height: Grid.xs), - // Action buttons - Row( - children: [ - if (hasExistingStatus) - TextButton( - onPressed: isSaving.value ? null : handleClear, - child: const Text('Clear status'), - ), - const Spacer(), + // Action buttons + Row( + children: [ + if (hasExistingStatus) TextButton( - onPressed: isSaving.value - ? null - : () => Navigator.of(context).pop(), - child: const Text('Cancel'), + onPressed: isSaving.value ? null : handleClear, + child: const Text('Clear status'), ), - const SizedBox(width: Grid.xxs), - FilledButton( - onPressed: hasContent && !isSaving.value ? handleSave : null, - child: const Text('Save'), - ), - ], - ), - ], - ), + const Spacer(), + TextButton( + onPressed: isSaving.value + ? null + : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: Grid.xxs), + FilledButton( + onPressed: hasContent && !isSaving.value ? handleSave : null, + child: const Text('Save'), + ), + ], + ), + ], ), ); } diff --git a/mobile/lib/features/profile/user_cache_provider.dart b/mobile/lib/features/profile/user_cache_provider.dart index 60d06598a..b185f8abc 100644 --- a/mobile/lib/features/profile/user_cache_provider.dart +++ b/mobile/lib/features/profile/user_cache_provider.dart @@ -70,6 +70,7 @@ class UserCacheNotifier extends Notifier> { pubkey: entry.key.toLowerCase(), displayName: data['display_name'] as String?, avatarUrl: data['avatar_url'] as String?, + nip05Handle: data['nip05_handle'] as String?, ); } diff --git a/mobile/lib/features/profile/user_profile.dart b/mobile/lib/features/profile/user_profile.dart index 421a396db..e7139e901 100644 --- a/mobile/lib/features/profile/user_profile.dart +++ b/mobile/lib/features/profile/user_profile.dart @@ -6,12 +6,14 @@ class UserProfile { final String? displayName; final String? avatarUrl; final String? about; + final String? nip05Handle; const UserProfile({ required this.pubkey, this.displayName, this.avatarUrl, this.about, + this.nip05Handle, }); factory UserProfile.fromJson(Map json) => UserProfile( @@ -19,6 +21,7 @@ class UserProfile { displayName: json['display_name'] as String?, avatarUrl: json['avatar_url'] as String?, about: json['about'] as String?, + nip05Handle: json['nip05_handle'] as String?, ); /// Short label: display name, or first 8 chars of pubkey. diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart new file mode 100644 index 000000000..73a6d17a2 --- /dev/null +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -0,0 +1,365 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/utils/string_utils.dart'; +import '../channels/channel_detail_page.dart'; +import '../channels/channel_management_provider.dart'; +import 'presence_cache_provider.dart'; +import 'user_cache_provider.dart'; +import 'user_status_cache_provider.dart'; + +/// Show a user profile bottom sheet for the given [pubkey]. +void showUserProfileSheet(BuildContext context, String pubkey) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => UserProfileSheet(pubkey: pubkey), + ); +} + +class UserProfileSheet extends HookConsumerWidget { + final String pubkey; + + const UserProfileSheet({super.key, required this.pubkey}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pk = pubkey.toLowerCase(); + + // Watch cached profile, presence, and user status. + final profile = + ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? + ref.read(userCacheProvider.notifier).get(pk); + final presenceMap = ref.watch(presenceCacheProvider); + final presence = presenceMap[pk] ?? 'offline'; + final statusCache = ref.watch(userStatusCacheProvider); + final userStatus = statusCache[pk]; + + // Fetch about from the individual profile endpoint. + final aboutFuture = useMemoized( + () => ref + .read(relayClientProvider) + .get('/api/users/$pk/profile') + .then( + (json) => (json as Map)['about'] as String? ?? '', + ) + .catchError((_) => ''), + [pk], + ); + final aboutSnapshot = useFuture(aboutFuture); + final about = aboutSnapshot.data ?? profile?.about ?? ''; + + // Ensure presence and status are tracked. + useEffect(() { + ref.read(presenceCacheProvider.notifier).track([pk]); + ref.read(userStatusCacheProvider.notifier).track([pk]); + ref.read(userCacheProvider.notifier).preload([pk]); + return null; + }, [pk]); + + final displayName = profile?.displayName; + final avatarUrl = profile?.avatarUrl; + final nip05 = profile?.nip05Handle; + final initial = + profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); + + final presenceColor = switch (presence) { + 'online' => context.appColors.success, + 'away' => context.appColors.warning, + _ => context.colors.outline, + }; + final presenceLabel = switch (presence) { + 'online' => 'Online', + 'away' => 'Away', + _ => 'Offline', + }; + + return SizedBox( + width: double.infinity, + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.sm, + 0, + Grid.sm, + MediaQuery.viewInsetsOf(context).bottom, + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar — near full-width + _ProfileAvatar(avatarUrl: avatarUrl, initial: initial), + const SizedBox(height: Grid.xs), + + // Display name — centered, large + Center( + child: Text( + displayName ?? shortPubkey(pubkey), + style: context.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + + // NIP-05 handle — centered, secondary + if (nip05 != null && nip05.isNotEmpty) ...[ + const SizedBox(height: Grid.half), + Center( + child: Text( + nip05, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ], + + const SizedBox(height: Grid.xs), + + // Presence row — filled dot, not an outline icon + Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.half + 2), + child: Row( + children: [ + SizedBox( + width: 24, + child: Center( + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: presenceColor, + shape: BoxShape.circle, + ), + ), + ), + ), + const SizedBox(width: Grid.xxs), + Text( + presenceLabel, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurface, + ), + ), + ], + ), + ), + + if (userStatus != null && !userStatus.isEmpty) + _InfoRow( + icon: LucideIcons.messageCircle, + text: + '${userStatus.emoji.isNotEmpty ? '${userStatus.emoji} ' : ''}${userStatus.text}', + ), + + _InfoRow( + icon: LucideIcons.key, + text: shortPubkey(pubkey), + textStyle: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontFamily: 'monospace', + ), + onTap: () { + Clipboard.setData(ClipboardData(text: pubkey)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Public key copied'), + duration: Duration(seconds: 2), + ), + ); + }, + ), + + // About / bio section + if (about.isNotEmpty) ...[ + const SizedBox(height: Grid.xxs), + Divider( + color: context.colors.outlineVariant.withValues(alpha: 0.3), + ), + const SizedBox(height: Grid.xxs), + Text( + 'About', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Grid.half), + Text( + about, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + + const SizedBox(height: Grid.xs), + + // Action button — Message + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () async { + Navigator.of(context).pop(); + try { + final channel = await ref + .read(channelActionsProvider) + .openDm(pubkeys: [pk]); + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); + } catch (_) { + // Silently fail — user tapped but DM open failed. + } + }, + icon: const Icon(LucideIcons.messageSquare, size: 18), + label: const Text('Message'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: Grid.twelve, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.lg), + ), + ), + ), + ), + + const SizedBox(height: Grid.xxs), + ], + ), + ), + ), + ), + ); + } +} + +/// A row displaying an icon + text, used for profile info items. +class _InfoRow extends StatelessWidget { + final IconData icon; + final String text; + final TextStyle? textStyle; + final VoidCallback? onTap; + + const _InfoRow({ + required this.icon, + required this.text, + this.textStyle, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final child = Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.half + 2), + child: Row( + children: [ + SizedBox( + width: 24, + child: Icon(icon, size: 16, color: context.colors.onSurfaceVariant), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + text, + style: + textStyle ?? + context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurface, + ), + ), + ), + if (onTap != null) + Icon( + LucideIcons.copy, + size: 14, + color: context.colors.onSurfaceVariant, + ), + ], + ), + ); + + if (onTap != null) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: child, + ); + } + return child; + } +} + +class _ProfileAvatar extends HookWidget { + final String? avatarUrl; + final String initial; + + const _ProfileAvatar({required this.avatarUrl, required this.initial}); + + @override + Widget build(BuildContext context) { + final failed = useState(false); + + useEffect(() { + failed.value = false; + return null; + }, [avatarUrl]); + + final url = avatarUrl; + final showImage = url != null && !failed.value; + + return AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: showImage + ? Image.network( + url, + fit: BoxFit.cover, + errorBuilder: (_, _, _) { + failed.value = true; + return _AvatarFallback(initial: initial); + }, + ) + : _AvatarFallback(initial: initial), + ), + ); + } +} + +class _AvatarFallback extends StatelessWidget { + final String initial; + + const _AvatarFallback({required this.initial}); + + @override + Widget build(BuildContext context) { + return Container( + color: context.colors.primaryContainer, + alignment: Alignment.center, + child: Text( + initial, + style: context.textTheme.displayLarge?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/mobile/lib/shared/utils/string_utils.dart b/mobile/lib/shared/utils/string_utils.dart new file mode 100644 index 000000000..e821b30d5 --- /dev/null +++ b/mobile/lib/shared/utils/string_utils.dart @@ -0,0 +1,5 @@ +/// Truncates a hex pubkey to the first 8 characters with an ellipsis. +String shortPubkey(String pubkey) { + if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026'; + return pubkey; +}