mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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";
|
||||
|
||||
|
||||
42
mobile/lib/features/activity/activity_page.dart
Normal file
42
mobile/lib/features/activity/activity_page.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user