mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile): add user profile sheet and polish bottom sheets (#453)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
pubkey: entry.key.toLowerCase(),
|
||||
displayName: data['display_name'] as String?,
|
||||
avatarUrl: data['avatar_url'] as String?,
|
||||
nip05Handle: data['nip05_handle'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, dynamic> 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.
|
||||
|
||||
@@ -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<void>(
|
||||
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<String, dynamic>)['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<void>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user