feat(mobile): design refresh — navigation, search, reactions (#334)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-15 18:20:25 -06:00
committed by GitHub
parent f03bbe3dd7
commit d283f0894d
12 changed files with 959 additions and 388 deletions

View File

@@ -22,7 +22,7 @@ struct KnownAcpProvider {
avatar_url: &'static str,
}
const GOOSE_AVATAR_URL: &str = "https://block.github.io/goose/img/logo_dark.png";
const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default";
const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default";

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
class ActivityPage extends StatelessWidget {
const ActivityPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Activity')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.bell,
size: Grid.xl,
color: context.colors.outline,
),
const SizedBox(height: Grid.xs),
Text(
'No activity yet',
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.half),
Text(
'Mentions, replies, and reactions will show up here.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.outline,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:async';
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';
@@ -143,10 +144,14 @@ class ChannelDetailPage extends HookConsumerWidget {
),
),
data: (events) {
final messages = formatTimeline(events);
final messages = formatTimeline(
events,
currentPubkey: currentPubkey,
);
return _MessageList(
messages: messages,
channelId: channel.id,
currentPubkey: currentPubkey,
);
},
),
@@ -170,8 +175,13 @@ class ChannelDetailPage extends HookConsumerWidget {
class _MessageList extends ConsumerWidget {
final List<TimelineMessage> messages;
final String channelId;
final String? currentPubkey;
const _MessageList({required this.messages, required this.channelId});
const _MessageList({
required this.messages,
required this.channelId,
required this.currentPubkey,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -242,6 +252,7 @@ class _MessageList extends ConsumerWidget {
showAuthor: showAuthor,
channelNames: channelNamesMap,
currentChannelId: channelId,
currentPubkey: currentPubkey,
);
},
);
@@ -321,12 +332,14 @@ class _MessageBubble extends ConsumerWidget {
final bool showAuthor;
final Map<String, String> channelNames;
final String currentChannelId;
final String? currentPubkey;
const _MessageBubble({
required this.message,
required this.showAuthor,
required this.channelNames,
required this.currentChannelId,
required this.currentPubkey,
});
@override
@@ -348,84 +361,112 @@ class _MessageBubble extends ConsumerWidget {
}
}
return Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xxs : Grid.quarter),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
_UserAvatar(profile: profile, pubkey: message.pubkey)
else
const SizedBox(width: 28),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(bottom: Grid.quarter),
child: Row(
children: [
Text(
displayName,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colors.onSurface,
),
),
const SizedBox(width: Grid.xxs),
Text(
_formatTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () => _showMessageActions(
context: context,
ref: ref,
message: message,
channelId: currentChannelId,
isOwnMessage:
currentPubkey?.toLowerCase() == message.pubkey.toLowerCase(),
),
child: Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.quarter),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
_UserAvatar(profile: profile, pubkey: message.pubkey)
else
const SizedBox(width: 28),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(bottom: Grid.quarter),
child: Row(
children: [
Text(
'(edited)',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
fontStyle: FontStyle.italic,
displayName,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colors.onSurface,
),
),
const SizedBox(width: Grid.xxs),
Text(
_formatTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
fontStyle: FontStyle.italic,
),
),
],
],
],
),
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
channelNames: channelNames,
onChannelTap: (channelId) {
if (channelId == currentChannelId) {
return;
}
final channelsAsync = ref.read(channelsProvider);
final channels = channelsAsync.hasValue
? channelsAsync.value
: null;
Channel? targetChannel;
for (final channel in channels ?? const <Channel>[]) {
if (channel.id == channelId) {
targetChannel = channel;
break;
}
}
if (targetChannel == null) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
ChannelDetailPage(channel: targetChannel!),
),
);
},
),
],
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
channelNames: channelNames,
onChannelTap: (channelId) {
if (channelId == currentChannelId) return;
final channelsAsync = ref.read(channelsProvider);
final channels = channelsAsync.hasValue
? channelsAsync.value
: null;
Channel? targetChannel;
for (final channel in channels ?? const <Channel>[]) {
if (channel.id == channelId) {
targetChannel = channel;
break;
}
}
if (targetChannel == null) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
ChannelDetailPage(channel: targetChannel!),
),
);
},
),
if (message.reactions.isNotEmpty)
_ReactionRow(
reactions: message.reactions,
onToggle: (emoji) {
final actions = ref.read(channelActionsProvider);
final reaction = message.reactions.firstWhere(
(r) => r.emoji == emoji,
);
if (reaction.reactedByCurrentUser &&
reaction.currentUserReactionId != null) {
actions.removeReaction(
reaction.currentUserReactionId!,
emoji,
);
} else {
actions.addReaction(message.id, emoji);
}
},
),
],
),
),
),
],
],
),
),
);
}
@@ -460,6 +501,267 @@ class _UserAvatar extends StatelessWidget {
}
}
// ---------------------------------------------------------------------------
// Reaction pills row
// ---------------------------------------------------------------------------
class _ReactionRow extends StatelessWidget {
final List<TimelineReaction> reactions;
final void Function(String emoji) onToggle;
const _ReactionRow({required this.reactions, required this.onToggle});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: Wrap(
spacing: Grid.half,
runSpacing: Grid.half,
children: [
for (final reaction in reactions)
GestureDetector(
onTap: () => onToggle(reaction.emoji),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: Grid.quarter,
),
decoration: BoxDecoration(
color: reaction.reactedByCurrentUser
? context.colors.primary.withValues(alpha: 0.12)
: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.lg),
border: Border.all(
color: reaction.reactedByCurrentUser
? context.colors.primary.withValues(alpha: 0.4)
: context.colors.outlineVariant,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(reaction.emoji, style: const TextStyle(fontSize: 14)),
if (reaction.count > 1) ...[
const SizedBox(width: Grid.quarter),
Text(
'${reaction.count}',
style: context.textTheme.labelSmall?.copyWith(
color: reaction.reactedByCurrentUser
? context.colors.primary
: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Message actions (long-press sheet)
// ---------------------------------------------------------------------------
const _quickEmojis = ['👍', '❤️', '😂', '🎉', '👀', '🙏'];
void _showMessageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool isOwnMessage,
}) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Quick emoji row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (final emoji in _quickEmojis)
GestureDetector(
onTap: () {
Navigator.of(sheetContext).pop();
ref
.read(channelActionsProvider)
.addReaction(message.id, emoji);
},
child: Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Theme.of(
sheetContext,
).colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Text(emoji, style: const TextStyle(fontSize: 20)),
),
),
],
),
const SizedBox(height: Grid.xs),
ListTile(
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
// Copy to clipboard
final data = ClipboardData(text: message.content);
Clipboard.setData(data);
},
),
if (isOwnMessage) ...[
ListTile(
leading: const Icon(LucideIcons.pencil),
title: const Text('Edit message'),
onTap: () {
Navigator.of(sheetContext).pop();
_showEditSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
);
},
),
ListTile(
leading: Icon(
LucideIcons.trash2,
color: Theme.of(sheetContext).colorScheme.error,
),
title: Text(
'Delete message',
style: TextStyle(
color: Theme.of(sheetContext).colorScheme.error,
),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
messageId: message.id,
);
},
),
],
],
),
),
),
);
}
void _showEditSheet({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
}) {
final controller = TextEditingController(text: message.content);
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => Padding(
padding: EdgeInsets.fromLTRB(
Grid.xs,
0,
Grid.xs,
MediaQuery.viewInsetsOf(sheetContext).bottom + Grid.xs,
),
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,
);
Navigator.of(sheetContext).pop();
},
child: const Text('Save'),
),
],
),
],
),
),
),
);
}
void _confirmDelete({
required BuildContext context,
required WidgetRef ref,
required String messageId,
}) {
showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete message'),
content: const Text('This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.of(dialogContext).pop();
ref.read(channelActionsProvider).deleteMessage(messageId);
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
child: const Text('Delete'),
),
],
),
);
}
// ---------------------------------------------------------------------------
// Channel management
// ---------------------------------------------------------------------------
@@ -563,6 +865,17 @@ class _MembersSheet extends HookConsumerWidget {
final membersAsync = ref.watch(channelMembersProvider(channel.id));
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
final people = allMembers.where((member) => !member.isBot).toList();
final userCache = ref.watch(userCacheProvider);
// Preload profiles for all members so avatars appear.
useEffect(() {
if (people.isNotEmpty) {
ref
.read(userCacheProvider.notifier)
.preload(people.map((m) => m.pubkey).toList());
}
return null;
}, [people.length]);
return Padding(
padding: EdgeInsets.fromLTRB(
@@ -614,18 +927,25 @@ class _MembersSheet extends HookConsumerWidget {
shrinkWrap: true,
children: [
for (final member in people)
ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
child: Text(
member
.labelFor(currentPubkey)
.substring(0, 1)
.toUpperCase(),
),
),
title: Text(member.labelFor(currentPubkey)),
subtitle: Text(member.role),
Builder(
builder: (context) {
final profile =
userCache[member.pubkey.toLowerCase()];
final avatarUrl = profile?.avatarUrl;
final label = member.labelFor(currentPubkey);
final initial = label
.substring(0, 1)
.toUpperCase();
return ListTile(
contentPadding: EdgeInsets.zero,
leading: _MemberAvatar(
avatarUrl: avatarUrl,
initial: initial,
),
title: Text(label),
subtitle: Text(member.role),
);
},
),
],
),
@@ -649,6 +969,33 @@ class _MembersSheet extends HookConsumerWidget {
}
}
class _MemberAvatar extends HookWidget {
final String? avatarUrl;
final String initial;
const _MemberAvatar({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;
if (url == null || failed.value) {
return CircleAvatar(child: Text(initial));
}
return CircleAvatar(
backgroundImage: NetworkImage(url),
onBackgroundImageError: (_, _) => failed.value = true,
child: null,
);
}
}
class _ManageChannelSheet extends HookConsumerWidget {
final Channel channel;

View File

@@ -298,6 +298,51 @@ class ChannelActions {
'${hex.substring(20, 32)}';
}
Future<void> addReaction(String eventId, String emoji) async {
await _signedEventRelay.submit(
kind: EventKind.reaction,
content: emoji,
tags: [
['e', eventId],
],
);
}
Future<void> removeReaction(String reactionEventId, String emoji) async {
await _signedEventRelay.submit(
kind: EventKind.deletion,
content: '',
tags: [
['e', reactionEventId],
],
);
}
Future<void> editMessage({
required String channelId,
required String eventId,
required String content,
}) async {
await _signedEventRelay.submit(
kind: EventKind.streamMessageEdit,
content: content,
tags: [
['h', channelId],
['e', eventId],
],
);
}
Future<void> deleteMessage(String eventId) async {
await _signedEventRelay.submit(
kind: EventKind.deletion,
content: '',
tags: [
['e', eventId],
],
);
}
static final Random _random = Random.secure();
}

View File

@@ -10,16 +10,15 @@ import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../profile/profile_avatar.dart';
import '../profile/profile_provider.dart';
import '../settings/settings_page.dart';
import 'channel.dart';
import 'channel_detail_page.dart';
import 'channel_management_provider.dart';
import 'channels_provider.dart';
enum _BrowseMode { channels, forums }
enum _QuickAction { createChannel, createForum, newDm }
class ChannelsPage extends ConsumerWidget {
class ChannelsPage extends HookConsumerWidget {
const ChannelsPage({super.key});
@override
@@ -31,6 +30,14 @@ class ChannelsPage extends ConsumerWidget {
.whenData((value) => value?.pubkey)
.value;
// Cache the last successfully loaded channels so the UI never flashes
// back to a loading state when the provider rebuilds.
final cachedChannels = useRef<List<Channel>?>(null);
if (channelsAsync.asData?.value case final data?) {
cachedChannels.value = data;
}
final channels = cachedChannels.value;
Future<void> openChannel(Channel channel) async {
if (!context.mounted) return;
await Navigator.of(context).push(
@@ -100,45 +107,78 @@ class ChannelsPage extends ConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text('Sprout'),
actions: [
IconButton(
onPressed: channelsAsync.hasValue ? browseChannels : null,
tooltip: 'Browse channels',
icon: const Icon(LucideIcons.search),
),
IconButton(
onPressed: openQuickActions,
tooltip: 'Create or start conversation',
icon: const Icon(LucideIcons.plus),
),
const ProfileAvatar(),
],
),
body: Column(
children: [
_ConnectionBanner(status: sessionState.status),
Expanded(
child: channelsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => _ErrorView(
error: error,
onRetry: () => ref.read(channelsProvider.notifier).refresh(),
),
data: (channels) => _ChannelsList(
channels: channels,
currentPubkey: currentPubkey,
onSelectChannel: openChannel,
titleSpacing: Grid.xs,
title: Row(
children: [
Expanded(
child: GestureDetector(
onTap: channelsAsync.hasValue ? browseChannels : null,
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.lg),
border: Border.all(color: context.colors.outlineVariant),
),
child: Row(
children: [
Icon(
LucideIcons.search,
size: 16,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.xxs),
Text(
'Search',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
),
),
),
],
const SizedBox(width: Grid.twelve),
ProfileAvatar(
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SettingsPage()),
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: openQuickActions,
tooltip: 'Create or start conversation',
shape: const CircleBorder(),
child: const Icon(LucideIcons.plus),
),
body: channels != null
? Column(
children: [
_ConnectionBanner(status: sessionState.status),
Expanded(
child: _ChannelsList(
channels: channels,
currentPubkey: currentPubkey,
onSelectChannel: openChannel,
),
),
],
)
: channelsAsync.hasError
? _ErrorView(
error: channelsAsync.error!,
onRetry: () => ref.read(channelsProvider.notifier).refresh(),
)
: const _ConnectionBanner(status: SessionStatus.connecting),
);
}
}
class _ChannelsList extends ConsumerWidget {
class _ChannelsList extends HookConsumerWidget {
final List<Channel> channels;
final String? currentPubkey;
final Future<void> Function(Channel channel) onSelectChannel;
@@ -164,16 +204,23 @@ class _ChannelsList extends ConsumerWidget {
.where((channel) => channel.isDm)
.toList();
final channelsExpanded = useState(true);
final forumsExpanded = useState(true);
final dmsExpanded = useState(true);
return RefreshIndicator(
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
child: ListView(
padding: const EdgeInsets.only(top: Grid.xxs, bottom: Grid.xs),
padding: const EdgeInsets.only(top: Grid.xxs, bottom: 80),
children: [
if (visibleChannels.isEmpty)
const _EmptyState()
else ...[
_ChannelSection(
title: 'Channels',
icon: LucideIcons.hash,
expanded: channelsExpanded.value,
onToggle: () => channelsExpanded.value = !channelsExpanded.value,
channels: streamChannels,
currentPubkey: currentPubkey,
emptyLabel: 'No stream channels yet',
@@ -181,6 +228,9 @@ class _ChannelsList extends ConsumerWidget {
),
_ChannelSection(
title: 'Forums',
icon: LucideIcons.messageSquareText,
expanded: forumsExpanded.value,
onToggle: () => forumsExpanded.value = !forumsExpanded.value,
channels: forumChannels,
currentPubkey: currentPubkey,
emptyLabel: 'No forums yet',
@@ -188,6 +238,9 @@ class _ChannelsList extends ConsumerWidget {
),
_ChannelSection(
title: 'DMs',
icon: LucideIcons.messagesSquare,
expanded: dmsExpanded.value,
onToggle: () => dmsExpanded.value = !dmsExpanded.value,
channels: dmChannels,
currentPubkey: currentPubkey,
emptyLabel: 'No direct messages yet',
@@ -202,6 +255,9 @@ class _ChannelsList extends ConsumerWidget {
class _ChannelSection extends StatelessWidget {
final String title;
final IconData icon;
final bool expanded;
final VoidCallback onToggle;
final List<Channel> channels;
final String? currentPubkey;
final String emptyLabel;
@@ -209,6 +265,9 @@ class _ChannelSection extends StatelessWidget {
const _ChannelSection({
required this.title,
required this.icon,
required this.expanded,
required this.onToggle,
required this.channels,
required this.currentPubkey,
required this.emptyLabel,
@@ -220,27 +279,36 @@ class _ChannelSection extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader(label: title, count: channels.length),
if (channels.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.half,
),
child: Text(
emptyLabel,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.outline,
_SectionHeader(
label: title,
icon: icon,
expanded: expanded,
onToggle: onToggle,
),
if (expanded) ...[
if (channels.isEmpty)
Padding(
padding: const EdgeInsets.only(
left: Grid.xs + Grid.xxs,
right: Grid.xs,
top: Grid.half,
bottom: Grid.half,
),
),
)
else
for (final channel in channels)
_ChannelTile(
channel: channel,
currentPubkey: currentPubkey,
onTap: () => onSelectChannel(channel),
),
child: Text(
emptyLabel,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.outline,
),
),
)
else
for (final channel in channels)
_ChannelTile(
channel: channel,
currentPubkey: currentPubkey,
onTap: () => onSelectChannel(channel),
),
],
],
);
}
@@ -278,34 +346,49 @@ class _EmptyState extends StatelessWidget {
class _SectionHeader extends StatelessWidget {
final String label;
final int count;
final IconData icon;
final bool expanded;
final VoidCallback onToggle;
const _SectionHeader({required this.label, required this.count});
const _SectionHeader({
required this.label,
required this.icon,
required this.expanded,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
child: Row(
children: [
Text(
label,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
return GestureDetector(
onTap: onToggle,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.xs,
Grid.twelve,
Grid.xs,
Grid.half,
),
child: Row(
children: [
Icon(icon, size: 14, color: context.colors.outline),
const SizedBox(width: Grid.half),
Text(
label.toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
),
const SizedBox(width: Grid.xxs),
Text(
'$count',
style: context.textTheme.labelSmall?.copyWith(
const Spacer(),
Icon(
expanded ? LucideIcons.chevronDown : LucideIcons.chevronRight,
size: 14,
color: context.colors.outline,
),
),
],
],
),
),
);
}
@@ -330,9 +413,11 @@ class _ChannelTile extends StatelessWidget {
borderRadius: BorderRadius.circular(Radii.md),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs + Grid.quarter,
padding: const EdgeInsets.only(
left: Grid.xs + Grid.xxs,
right: Grid.xs,
top: Grid.xxs + Grid.quarter,
bottom: Grid.xxs + Grid.quarter,
),
child: Row(
children: [
@@ -395,15 +480,6 @@ class _ChannelTile extends StatelessWidget {
const SizedBox(width: Grid.xxs),
_EphemeralBadge(channel: channel),
],
if (channel.lastMessageAt != null) ...[
const SizedBox(width: Grid.xxs),
Text(
_relativeTime(channel.lastMessageAt!),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
),
],
],
),
),
@@ -411,16 +487,6 @@ class _ChannelTile extends StatelessWidget {
}
IconData _iconFor(Channel channel) => channelIcon(channel);
static String _relativeTime(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inMinutes < 1) return 'now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m';
if (diff.inHours < 24) return '${diff.inHours}h';
if (diff.inDays < 7) return '${diff.inDays}d';
if (diff.inDays < 365) return '${(diff.inDays / 7).floor()}w';
return '${(diff.inDays / 365).floor()}y';
}
}
class _QuickActionsSheet extends StatelessWidget {
@@ -435,8 +501,6 @@ class _QuickActionsSheet extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('New', style: context.textTheme.titleMedium),
const SizedBox(height: Grid.xxs),
ListTile(
leading: const Icon(LucideIcons.hash),
title: const Text('Create channel'),
@@ -476,8 +540,6 @@ class _CreateChannelSheet extends HookConsumerWidget {
final isSubmitting = useState(false);
final errorMessage = useState<String?>(null);
final kindLabel = channelType == 'forum' ? 'forum' : 'channel';
Future<void> submit() async {
final name = nameController.text.trim();
if (name.isEmpty || isSubmitting.value) {
@@ -518,17 +580,6 @@ class _CreateChannelSheet extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Create $kindLabel', style: context.textTheme.titleMedium),
const SizedBox(height: Grid.xxs),
Text(
channelType == 'forum'
? 'Forums organize threaded discussions around a topic.'
: 'Channels are real-time streams for team conversation.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.xs),
TextField(
controller: nameController,
enabled: !isSubmitting.value,
@@ -552,15 +603,13 @@ class _CreateChannelSheet extends HookConsumerWidget {
maxLines: 3,
),
const SizedBox(height: Grid.xxs),
SegmentedButton<String>(
segments: const [
ButtonSegment<String>(value: 'open', label: Text('Open')),
ButtonSegment<String>(value: 'private', label: Text('Private')),
],
selected: {visibility.value},
onSelectionChanged: isSubmitting.value
SwitchListTile(
title: const Text('Private'),
contentPadding: EdgeInsets.zero,
value: visibility.value == 'private',
onChanged: isSubmitting.value
? null
: (selection) => visibility.value = selection.first,
: (on) => visibility.value = on ? 'private' : 'open',
),
if (errorMessage.value case final error?) ...[
const SizedBox(height: Grid.xxs),
@@ -602,30 +651,17 @@ class _BrowseChannelsSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final mode = useState(_BrowseMode.channels);
final query = useState('');
final busyChannelId = useState<String?>(null);
final normalizedQuery = query.value.trim().toLowerCase();
final browsableChannels = channels.where((channel) {
if (channel.isDm) {
return false;
}
if (mode.value == _BrowseMode.channels && !channel.isStream) {
return false;
}
if (mode.value == _BrowseMode.forums && !channel.isForum) {
return false;
}
if (channel.isDm) return false;
final visible = channel.isArchived
? channel.isMember
: channel.visibility == 'open' || channel.isMember;
if (!visible) {
return false;
}
if (normalizedQuery.isEmpty) {
return true;
}
if (!visible) return false;
if (normalizedQuery.isEmpty) return true;
return channel.name.toLowerCase().contains(normalizedQuery) ||
channel.description.toLowerCase().contains(normalizedQuery);
}).toList();
@@ -638,9 +674,7 @@ class _BrowseChannelsSheet extends HookConsumerWidget {
.toList();
Future<void> openOrJoin(Channel channel) async {
if (busyChannelId.value != null) {
return;
}
if (busyChannelId.value != null) return;
if (channel.isMember) {
Navigator.of(context).pop(channel);
@@ -668,93 +702,99 @@ class _BrowseChannelsSheet extends HookConsumerWidget {
}
}
return Padding(
padding: EdgeInsets.fromLTRB(
Grid.xs,
0,
Grid.xs,
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
mode.value == _BrowseMode.forums
? 'Browse forums'
: 'Browse channels',
style: context.textTheme.titleMedium,
return DraggableScrollableSheet(
initialChildSize: 0.75,
minChildSize: 0.4,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.xs,
Grid.twelve,
Grid.xs,
Grid.xxs,
),
const SizedBox(height: Grid.xxs),
SegmentedButton<_BrowseMode>(
segments: const [
ButtonSegment<_BrowseMode>(
value: _BrowseMode.channels,
label: Text('Channels'),
child: GestureDetector(
onTap: () {},
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.lg),
border: Border.all(color: context.colors.outlineVariant),
),
ButtonSegment<_BrowseMode>(
value: _BrowseMode.forums,
label: Text('Forums'),
),
],
selected: {mode.value},
onSelectionChanged: (selection) => mode.value = selection.first,
),
const SizedBox(height: Grid.xxs),
TextField(
decoration: InputDecoration(
prefixIcon: const Icon(LucideIcons.search),
hintText: mode.value == _BrowseMode.forums
? 'Search forums by name or description'
: 'Search channels by name or description',
),
onChanged: (value) => query.value = value,
),
const SizedBox(height: Grid.xs),
SizedBox(
height: 360,
child: browsableChannels.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.sm),
child: Center(
child: Text(
'No matching spaces',
style: context.textTheme.bodyMedium?.copyWith(
child: Row(
children: [
Icon(
LucideIcons.search,
size: 16,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: 'Search channels, forums…',
hintStyle: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
style: context.textTheme.bodyMedium,
onChanged: (value) => query.value = value,
),
)
: ListView(
shrinkWrap: true,
children: [
if (notJoined.isNotEmpty) ...[
_MiniHeader(
label: '${notJoined.length} available to join',
),
for (final channel in notJoined)
_BrowseTile(
channel: channel,
isBusy: busyChannelId.value == channel.id,
onTap: () => openOrJoin(channel),
),
],
if (joined.isNotEmpty) ...[
_MiniHeader(label: '${joined.length} joined'),
for (final channel in joined)
_BrowseTile(
channel: channel,
isBusy: false,
onTap: () => openOrJoin(channel),
),
],
],
),
],
),
),
),
],
),
),
Expanded(
child: browsableChannels.isEmpty
? Center(
child: Text(
'No matching results',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
)
: ListView(
controller: scrollController,
padding: const EdgeInsets.only(top: Grid.xxs),
children: [
if (notJoined.isNotEmpty) ...[
_MiniHeader(
label: '${notJoined.length} available to join',
),
for (final channel in notJoined)
_BrowseTile(
channel: channel,
isBusy: busyChannelId.value == channel.id,
onTap: () => openOrJoin(channel),
),
],
if (joined.isNotEmpty) ...[
_MiniHeader(label: '${joined.length} joined'),
for (final channel in joined)
_BrowseTile(
channel: channel,
isBusy: false,
onTap: () => openOrJoin(channel),
),
],
],
),
),
],
),
);
}
@@ -879,15 +919,6 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('New direct message', style: context.textTheme.titleMedium),
const SizedBox(height: Grid.xxs),
Text(
'Pick 1 to 8 people. If the conversation already exists, mobile will reopen it.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.xs),
TextField(
controller: queryController,
decoration: const InputDecoration(
@@ -1016,7 +1047,10 @@ class _MiniHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: Grid.half, bottom: Grid.half),
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.half,
),
child: Text(
label,
style: context.textTheme.labelSmall?.copyWith(

View File

@@ -100,6 +100,29 @@ class SystemEvent {
}
}
// ---------------------------------------------------------------------------
// Reaction — aggregated emoji reaction on a message
// ---------------------------------------------------------------------------
@immutable
class TimelineReaction {
final String emoji;
final int count;
final bool reactedByCurrentUser;
final List<String> userPubkeys;
/// The event ID of the current user's reaction, for deletion.
final String? currentUserReactionId;
const TimelineReaction({
required this.emoji,
required this.count,
required this.reactedByCurrentUser,
required this.userPubkeys,
this.currentUserReactionId,
});
}
// ---------------------------------------------------------------------------
// TimelineMessage — a processed, display-ready message
// ---------------------------------------------------------------------------
@@ -117,6 +140,9 @@ class TimelineMessage {
/// Pubkeys mentioned in this message (from p-tags).
final List<String> mentionPubkeys;
/// Aggregated reactions on this message.
final List<TimelineReaction> reactions;
const TimelineMessage({
required this.id,
required this.pubkey,
@@ -126,6 +152,7 @@ class TimelineMessage {
this.edited = false,
this.systemEvent,
this.mentionPubkeys = const [],
this.reactions = const [],
});
}
@@ -134,10 +161,15 @@ class TimelineMessage {
// ---------------------------------------------------------------------------
/// Process a chronologically-sorted list of [NostrEvent]s into a list of
/// [TimelineMessage]s, applying deletions, edits, and system event parsing.
/// [TimelineMessage]s, applying deletions, edits, reactions, and system event
/// parsing.
///
/// Mirrors the desktop's `formatTimelineMessages` logic.
List<TimelineMessage> formatTimeline(List<NostrEvent> events) {
/// [currentPubkey] is used to determine if the current user has reacted.
List<TimelineMessage> formatTimeline(
List<NostrEvent> events, {
String? currentPubkey,
}) {
// 1. Collect deletion targets.
final deletedIds = <String>{};
for (final event in events) {
@@ -167,7 +199,27 @@ List<TimelineMessage> formatTimeline(List<NostrEvent> events) {
}
}
// 3. Filter to visible content events and build TimelineMessages.
// 3. Aggregate reactions: targetId → { emoji → { pubkey → eventId } }.
final reactionMap = <String, Map<String, Map<String, String>>>{};
for (final event in events) {
if (event.kind != EventKind.reaction) continue;
if (deletedIds.contains(event.id)) continue;
final targetId = _lastETag(event.tags);
if (targetId == null || deletedIds.contains(targetId)) continue;
final emoji = event.content.trim();
if (emoji.isEmpty) continue;
reactionMap
.putIfAbsent(targetId, () => {})
.putIfAbsent(emoji, () => {})[event.pubkey.toLowerCase()] =
event.id;
}
final normalizedCurrentPubkey = currentPubkey?.toLowerCase();
// 4. Filter to visible content events and build TimelineMessages.
final result = <TimelineMessage>[];
for (final event in events) {
if (deletedIds.contains(event.id)) continue;
@@ -197,6 +249,24 @@ List<TimelineMessage> formatTimeline(List<NostrEvent> events) {
for (final tag in event.tags)
if (tag.length >= 2 && tag[0] == 'p') tag[1],
];
final emojiMap = reactionMap[event.id];
final reactions = <TimelineReaction>[
if (emojiMap != null)
for (final entry in emojiMap.entries)
TimelineReaction(
emoji: entry.key,
count: entry.value.length,
reactedByCurrentUser:
normalizedCurrentPubkey != null &&
entry.value.containsKey(normalizedCurrentPubkey),
userPubkeys: entry.value.keys.toList(),
currentUserReactionId: normalizedCurrentPubkey != null
? entry.value[normalizedCurrentPubkey]
: null,
),
];
result.add(
TimelineMessage(
id: event.id,
@@ -205,6 +275,7 @@ List<TimelineMessage> formatTimeline(List<NostrEvent> events) {
content: edit?.content ?? event.content,
edited: edit != null,
mentionPubkeys: mentions,
reactions: reactions,
),
);
}

View File

@@ -3,8 +3,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../activity/activity_page.dart';
import '../channels/channels_page.dart';
import '../settings/settings_page.dart';
class HomePage extends HookConsumerWidget {
const HomePage({super.key});
@@ -13,7 +13,7 @@ class HomePage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tabIndex = useState(0);
const pages = [ChannelsPage(), SettingsPage()];
const pages = [ChannelsPage(), ActivityPage()];
return Scaffold(
body: IndexedStack(index: tabIndex.value, children: pages),
@@ -22,14 +22,14 @@ class HomePage extends HookConsumerWidget {
onDestinationSelected: (i) => tabIndex.value = i,
destinations: const [
NavigationDestination(
icon: Icon(LucideIcons.hash),
selectedIcon: Icon(LucideIcons.hash),
label: 'Channels',
icon: Icon(LucideIcons.house),
selectedIcon: Icon(LucideIcons.house),
label: 'Home',
),
NavigationDestination(
icon: Icon(LucideIcons.settings),
selectedIcon: Icon(LucideIcons.settings),
label: 'Settings',
icon: Icon(LucideIcons.bell),
selectedIcon: Icon(LucideIcons.bell),
label: 'Activity',
),
],
),

View File

@@ -170,20 +170,15 @@ class PairingPage extends HookConsumerWidget {
}
}
class _ScannerPage extends StatefulWidget {
class _ScannerPage extends HookWidget {
final void Function(String code) onScanned;
const _ScannerPage({required this.onScanned});
@override
State<_ScannerPage> createState() => _ScannerPageState();
}
class _ScannerPageState extends State<_ScannerPage> {
bool _handled = false;
@override
Widget build(BuildContext context) {
final handled = useState(false);
return Scaffold(
appBar: AppBar(
title: const Text('Scan QR Code'),
@@ -194,13 +189,13 @@ class _ScannerPageState extends State<_ScannerPage> {
),
body: MobileScanner(
onDetect: (capture) {
if (_handled) return;
if (handled.value) return;
final barcodes = capture.barcodes;
if (barcodes.isNotEmpty) {
final value = barcodes.first.rawValue;
if (value != null && value.isNotEmpty) {
_handled = true;
widget.onScanned(value);
handled.value = true;
onScanned(value);
}
}
},

View File

@@ -7,66 +7,81 @@ import 'user_profile.dart';
/// User avatar with a presence dot indicator, for use in the app bar.
class ProfileAvatar extends ConsumerWidget {
const ProfileAvatar({super.key});
final VoidCallback? onTap;
const ProfileAvatar({super.key, this.onTap});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profileAsync = ref.watch(profileProvider);
final presence =
ref.watch(presenceProvider).whenData((v) => v).value ?? 'offline';
final presenceAsync = ref.watch(presenceProvider);
final presence = presenceAsync.when(
data: (v) => v,
loading: () => 'online',
error: (_, _) => 'offline',
);
return profileAsync.when(
loading: () => const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(strokeWidth: 2),
),
loading: () => _buildPlaceholder(context),
error: (_, _) => _buildAvatar(context, null, presence),
data: (profile) => _buildAvatar(context, profile, presence),
);
}
Widget _buildPlaceholder(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: Grid.xxs),
child: CircleAvatar(
radius: 16,
backgroundColor: context.colors.primaryContainer,
),
);
}
Widget _buildAvatar(
BuildContext context,
UserProfile? profile,
String presence,
) {
return Padding(
padding: const EdgeInsets.only(right: Grid.xxs),
child: Stack(
children: [
CircleAvatar(
radius: 16,
backgroundColor: context.colors.primaryContainer,
backgroundImage: profile?.avatarUrl != null
? NetworkImage(profile!.avatarUrl!)
: null,
child: profile?.avatarUrl == null
? Text(
profile?.initial ?? '?',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
),
)
: null,
),
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _presenceColor(context, presence),
shape: BoxShape.circle,
border: Border.all(
color: context.theme.scaffoldBackgroundColor,
width: 1.5,
return GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(right: Grid.xxs),
child: Stack(
children: [
CircleAvatar(
radius: 16,
backgroundColor: context.colors.primaryContainer,
backgroundImage: profile?.avatarUrl != null
? NetworkImage(profile!.avatarUrl!)
: null,
child: profile?.avatarUrl == null
? Text(
profile?.initial ?? '?',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
),
)
: null,
),
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _presenceColor(context, presence),
shape: BoxShape.circle,
border: Border.all(
color: context.theme.scaffoldBackgroundColor,
width: 1.5,
),
),
),
),
),
],
],
),
),
);
}

View File

@@ -80,10 +80,11 @@ class PresenceNotifier extends AsyncNotifier<String> {
final client = ref.read(relayClientProvider);
try {
await client.post('/api/presence', body: {'status': status});
return status;
} catch (_) {
return state.value ?? 'offline';
// Optimistically report the requested status even if the POST fails —
// the heartbeat will retry on the next tick.
}
return status;
}
Future<String> _fetch() async {

View File

@@ -82,6 +82,28 @@ class AppTheme {
),
),
// Bottom navigation: clean style, no indicator pill
navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surface,
elevation: 0,
indicatorColor: Colors.transparent,
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return IconThemeData(color: scheme.primary, size: 24);
}
return IconThemeData(color: scheme.onSurfaceVariant, size: 24);
}),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return textTheme.labelSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w600,
);
}
return textTheme.labelSmall?.copyWith(color: scheme.onSurfaceVariant);
}),
),
// Buttons: desktop uses rounded-md (8px), h-9 (36px), px-4 (16px)
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(

View File

@@ -76,11 +76,10 @@ void main() {
expect(find.text('general'), findsOneWidget);
expect(find.text('design-forum'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Channels'), findsOneWidget);
expect(find.text('Forums'), findsOneWidget);
expect(find.text('DMs'), findsOneWidget);
expect(find.text('1'), findsNWidgets(3));
expect(find.byTooltip('Browse channels'), findsOneWidget);
expect(find.text('CHANNELS'), findsOneWidget);
expect(find.text('FORUMS'), findsOneWidget);
expect(find.text('DMS'), findsOneWidget);
expect(find.text('Search'), findsOneWidget);
expect(find.byTooltip('Create or start conversation'), findsOneWidget);
});
@@ -127,7 +126,7 @@ void main() {
expect(find.text('open-stream'), findsNothing);
expect(find.text('archived-stream'), findsNothing);
await tester.tap(find.byTooltip('Browse channels'));
await tester.tap(find.text('Search'));
await tester.pumpAndSettle();
expect(find.text('open-stream'), findsOneWidget);