mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile): add Pulse social feed tab (#772)
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -2,8 +2,9 @@ PODS:
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- flutter_secure_storage_darwin (10.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- mobile_scanner (7.0.0):
|
||||
@@ -23,7 +24,7 @@ PODS:
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
@@ -36,8 +37,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_secure_storage:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
flutter_secure_storage_darwin:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
mobile_scanner:
|
||||
@@ -54,7 +55,7 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../activity/activity_page.dart';
|
||||
import '../channels/channels_page.dart';
|
||||
import '../pulse/pulse_page.dart';
|
||||
import '../search/search_page.dart';
|
||||
|
||||
class HomePage extends HookConsumerWidget {
|
||||
@@ -14,7 +15,7 @@ class HomePage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabIndex = useState(0);
|
||||
|
||||
const pages = [ChannelsPage(), ActivityPage(), SearchPage()];
|
||||
const pages = [ChannelsPage(), PulsePage(), ActivityPage(), SearchPage()];
|
||||
|
||||
return Scaffold(
|
||||
body: IndexedStack(index: tabIndex.value, children: pages),
|
||||
@@ -27,6 +28,11 @@ class HomePage extends HookConsumerWidget {
|
||||
selectedIcon: Icon(LucideIcons.house),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(LucideIcons.radio),
|
||||
selectedIcon: Icon(LucideIcons.radio),
|
||||
label: 'Pulse',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(LucideIcons.bell),
|
||||
selectedIcon: Icon(LucideIcons.bell),
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import 'note_card.dart';
|
||||
import 'pulse_models.dart';
|
||||
|
||||
class AgentActivityCard extends HookConsumerWidget {
|
||||
final AgentNoteGroup group;
|
||||
final Map<String, PulseReactionState> reactions;
|
||||
final VoidCallback? onReactionChanged;
|
||||
|
||||
const AgentActivityCard({
|
||||
super.key,
|
||||
required this.group,
|
||||
required this.reactions,
|
||||
this.onReactionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final expanded = useState(group.notes.length == 1);
|
||||
final profile =
|
||||
ref.watch(userCacheProvider.select((cache) => cache[group.pubkey])) ??
|
||||
ref.read(userCacheProvider.notifier).get(group.pubkey);
|
||||
final name = profile?.label ?? _shortPubkey(group.pubkey);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
border: Border.all(
|
||||
color: context.colors.primary.withValues(alpha: 0.22),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: group.notes.length > 1
|
||||
? () => expanded.value = !expanded.value
|
||||
: null,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(Radii.lg),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.twelve),
|
||||
child: Row(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
backgroundImage: profile?.avatarUrl != null
|
||||
? NetworkImage(profile!.avatarUrl!)
|
||||
: null,
|
||||
child: profile?.avatarUrl == null
|
||||
? const Icon(LucideIcons.bot, size: 18)
|
||||
: null,
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.success,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: Grid.xs),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.half,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primary.withValues(
|
||||
alpha: 0.12,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
),
|
||||
child: Text(
|
||||
'BOT',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'${group.notes.length} update${group.notes.length == 1 ? '' : 's'} · ${formatPulseRelativeTime(group.latestAt)}',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (group.notes.length > 1)
|
||||
Icon(
|
||||
expanded.value
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.chevronDown,
|
||||
size: 18,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (expanded.value)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final note in group.notes) ...[
|
||||
NoteCard(
|
||||
note: note,
|
||||
reaction:
|
||||
reactions[note.id] ??
|
||||
const PulseReactionState(
|
||||
count: 0,
|
||||
reactedByCurrentUser: false,
|
||||
),
|
||||
isAgent: true,
|
||||
onReactionChanged: onReactionChanged,
|
||||
),
|
||||
if (note != group.notes.last)
|
||||
const SizedBox(height: Grid.xxs),
|
||||
],
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.twelve,
|
||||
0,
|
||||
Grid.twelve,
|
||||
Grid.twelve,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
group.notes.first.content,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _shortPubkey(String pubkey) =>
|
||||
pubkey.length <= 8 ? pubkey : '${pubkey.substring(0, 8)}…';
|
||||
@@ -0,0 +1,330 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../channels/channel_detail_page.dart';
|
||||
import '../channels/channel_management_provider.dart';
|
||||
import '../channels/message_content.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile_sheet.dart';
|
||||
import 'note_composer.dart';
|
||||
import 'pulse_actions.dart';
|
||||
import 'pulse_models.dart';
|
||||
|
||||
class NoteCard extends HookConsumerWidget {
|
||||
final UserNote note;
|
||||
final PulseReactionState reaction;
|
||||
final bool isAgent;
|
||||
final bool isFollowing;
|
||||
final bool canFollow;
|
||||
final VoidCallback? onReactionChanged;
|
||||
final ValueChanged<String>? onFollowChanged;
|
||||
|
||||
const NoteCard({
|
||||
super.key,
|
||||
required this.note,
|
||||
required this.reaction,
|
||||
this.isAgent = false,
|
||||
this.isFollowing = false,
|
||||
this.canFollow = false,
|
||||
this.onReactionChanged,
|
||||
this.onFollowChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final showReply = useState(false);
|
||||
final pendingUpvote = useState<bool?>(null);
|
||||
final pubkey = note.pubkey.toLowerCase();
|
||||
final profile =
|
||||
ref.watch(userCacheProvider.select((cache) => cache[pubkey])) ??
|
||||
ref.read(userCacheProvider.notifier).get(pubkey);
|
||||
final displayName = profile?.label ?? _shortPubkey(pubkey);
|
||||
final effectiveUpvoted =
|
||||
pendingUpvote.value ?? reaction.reactedByCurrentUser;
|
||||
final effectiveCount = _effectiveCount(reaction, pendingUpvote.value);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(Grid.twelve),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
border: Border.all(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => showUserProfileSheet(context, note.pubkey),
|
||||
child: CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
backgroundImage: profile?.avatarUrl != null
|
||||
? NetworkImage(profile!.avatarUrl!)
|
||||
: null,
|
||||
child: profile?.avatarUrl == null
|
||||
? Text(
|
||||
(profile?.initial ?? displayName[0]).toUpperCase(),
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xs),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => showUserProfileSheet(context, note.pubkey),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
displayName,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (isAgent) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Icon(
|
||||
LucideIcons.bot,
|
||||
size: 13,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatPulseRelativeTime(note.createdAt),
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (canFollow) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
_FollowButton(
|
||||
isFollowing: isFollowing,
|
||||
onPressed: () async {
|
||||
if (isFollowing) {
|
||||
await unfollowUser(ref, note.pubkey);
|
||||
} else {
|
||||
await followUser(ref, note.pubkey);
|
||||
}
|
||||
onFollowChanged?.call(note.pubkey);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (note.replyParentId != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Replying to ${_shortPubkey(note.replyParentAuthor ?? note.replyParentId!)}',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Grid.half),
|
||||
MessageContent(content: note.content, tags: note.tags),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Row(
|
||||
children: [
|
||||
_ActionButton(
|
||||
icon: effectiveUpvoted
|
||||
? LucideIcons.heart
|
||||
: LucideIcons.heart,
|
||||
label: effectiveCount > 0 ? '$effectiveCount' : 'Like',
|
||||
color: effectiveUpvoted ? Colors.redAccent : null,
|
||||
filled: effectiveUpvoted,
|
||||
onTap: () async {
|
||||
final next = !effectiveUpvoted;
|
||||
pendingUpvote.value = next;
|
||||
try {
|
||||
await toggleNoteUpvote(
|
||||
ref,
|
||||
noteId: note.id,
|
||||
isUpvoted: reaction.reactedByCurrentUser,
|
||||
reactionEventId: reaction.currentUserReactionId,
|
||||
);
|
||||
onReactionChanged?.call();
|
||||
} finally {
|
||||
pendingUpvote.value = null;
|
||||
}
|
||||
},
|
||||
),
|
||||
_ActionButton(
|
||||
icon: LucideIcons.messageCircle,
|
||||
label: 'Reply',
|
||||
onTap: () => showReply.value = !showReply.value,
|
||||
),
|
||||
_ActionButton(
|
||||
icon: LucideIcons.share,
|
||||
label: 'Share',
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: _shareUri(note)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Copied note URI')),
|
||||
);
|
||||
},
|
||||
),
|
||||
_ActionButton(
|
||||
icon: LucideIcons.mail,
|
||||
label: 'DM',
|
||||
onTap: () async {
|
||||
final channel = await ref
|
||||
.read(channelActionsProvider)
|
||||
.openDm(pubkeys: [note.pubkey]);
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ChannelDetailPage(channel: channel),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showReply.value) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
NoteComposer(
|
||||
replyTo: note,
|
||||
hintText: 'Reply to $displayName',
|
||||
onSent: () => showReply.value = false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _effectiveCount(PulseReactionState reaction, bool? pending) {
|
||||
if (pending == null || pending == reaction.reactedByCurrentUser) {
|
||||
return reaction.count;
|
||||
}
|
||||
return (reaction.count + (pending ? 1 : -1)).clamp(0, 1 << 31);
|
||||
}
|
||||
}
|
||||
|
||||
class _FollowButton extends StatelessWidget {
|
||||
final bool isFollowing;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _FollowButton({required this.isFollowing, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.half, vertical: 2),
|
||||
child: Text(
|
||||
isFollowing ? 'Following' : 'Follow',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final Color? color;
|
||||
final bool filled;
|
||||
|
||||
const _ActionButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.color,
|
||||
this.filled = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveColor = color ?? context.colors.onSurfaceVariant;
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: effectiveColor, fill: filled ? 1 : 0),
|
||||
const SizedBox(width: 3),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: effectiveColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _shareUri(UserNote note) =>
|
||||
'nostr:${nostr.Nip19.encodeShareableIdentifiers(prefix: nostr.Nip19Prefix.nevent, data: note.id, author: note.pubkey, kind: 1)}';
|
||||
|
||||
String _shortPubkey(String pubkey) =>
|
||||
pubkey.length <= 8 ? pubkey : '${pubkey.substring(0, 8)}…';
|
||||
|
||||
String formatPulseRelativeTime(int createdAt) {
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(createdAt * 1000);
|
||||
final diff = DateTime.now().difference(date);
|
||||
if (diff.inMinutes < 1) return 'just now';
|
||||
if (diff.inHours < 1) return '${diff.inMinutes}m';
|
||||
if (diff.inDays < 1) return '${diff.inHours}h';
|
||||
if (diff.inDays < 7) return '${diff.inDays}d';
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
return '${months[date.month - 1]} ${date.day}';
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../profile/profile_provider.dart';
|
||||
import 'pulse_actions.dart';
|
||||
import 'pulse_models.dart';
|
||||
|
||||
class NoteComposer extends HookConsumerWidget {
|
||||
final UserNote? replyTo;
|
||||
final VoidCallback? onSent;
|
||||
final String hintText;
|
||||
|
||||
const NoteComposer({
|
||||
super.key,
|
||||
this.replyTo,
|
||||
this.onSent,
|
||||
this.hintText = 'What’s on your mind?',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useTextEditingController();
|
||||
final isSending = useState(false);
|
||||
final hasText = useListenableSelector(
|
||||
controller,
|
||||
() => controller.text.trim().isNotEmpty,
|
||||
);
|
||||
final profile = ref.watch(profileProvider).asData?.value;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(Grid.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.82),
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
border: Border.all(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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,
|
||||
),
|
||||
const SizedBox(width: Grid.xs),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
SizedBox(
|
||||
width: 38,
|
||||
height: 38,
|
||||
child: FilledButton(
|
||||
onPressed: hasText && !isSending.value
|
||||
? () async {
|
||||
isSending.value = true;
|
||||
try {
|
||||
await publishNote(
|
||||
ref,
|
||||
content: controller.text,
|
||||
replyTo: replyTo,
|
||||
);
|
||||
controller.clear();
|
||||
onSent?.call();
|
||||
} finally {
|
||||
isSending.value = false;
|
||||
}
|
||||
}
|
||||
: null,
|
||||
style: FilledButton.styleFrom(padding: EdgeInsets.zero),
|
||||
child: isSending.value
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(LucideIcons.send, size: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../channels/channel_management_provider.dart';
|
||||
import 'pulse_models.dart';
|
||||
import 'pulse_provider.dart';
|
||||
|
||||
Future<void> publishNote(
|
||||
WidgetRef ref, {
|
||||
required String content,
|
||||
UserNote? replyTo,
|
||||
List<String> mentionPubkeys = const [],
|
||||
List<List<String>> mediaTags = const [],
|
||||
}) async {
|
||||
final text = content.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final config = ref.read(relayConfigProvider);
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final relay = SignedEventRelay(session: session, nsec: config.nsec);
|
||||
final tags = <List<String>>[];
|
||||
final seen = <String>{};
|
||||
|
||||
if (replyTo != null) {
|
||||
tags.add(['e', replyTo.id, '', 'reply']);
|
||||
seen.add(replyTo.pubkey.toLowerCase());
|
||||
tags.add(['p', replyTo.pubkey.toLowerCase()]);
|
||||
}
|
||||
|
||||
for (final pubkey in mentionPubkeys) {
|
||||
final normalized = pubkey.toLowerCase();
|
||||
if (seen.add(normalized)) tags.add(['p', normalized]);
|
||||
}
|
||||
tags.addAll(mediaTags);
|
||||
|
||||
await relay.submit(kind: EventKind.note, content: text, tags: tags);
|
||||
ref.invalidate(globalNotesProvider);
|
||||
ref.invalidate(likedNotesProvider);
|
||||
}
|
||||
|
||||
Future<void> toggleNoteUpvote(
|
||||
WidgetRef ref, {
|
||||
required String noteId,
|
||||
required bool isUpvoted,
|
||||
String? reactionEventId,
|
||||
}) async {
|
||||
final actions = ref.read(channelActionsProvider);
|
||||
if (isUpvoted) {
|
||||
if (reactionEventId != null) {
|
||||
await actions.removeReaction(reactionEventId, '+');
|
||||
}
|
||||
} else {
|
||||
await actions.addReaction(noteId, '+');
|
||||
}
|
||||
ref.invalidate(likedNotesProvider);
|
||||
}
|
||||
|
||||
Future<void> setContactList(WidgetRef ref, List<ContactEntry> contacts) async {
|
||||
final currentPubkey = ref.read(myPubkeyProvider);
|
||||
if (currentPubkey == null) return;
|
||||
final config = ref.read(relayConfigProvider);
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final relay = SignedEventRelay(session: session, nsec: config.nsec);
|
||||
|
||||
await relay.submit(
|
||||
kind: EventKind.contactList,
|
||||
content: '',
|
||||
tags: contacts.map((entry) => entry.toTag()).toList(),
|
||||
);
|
||||
ref.invalidate(contactListProvider(currentPubkey));
|
||||
}
|
||||
|
||||
Future<void> followUser(WidgetRef ref, String pubkey) async {
|
||||
final currentPubkey = ref.read(myPubkeyProvider);
|
||||
if (currentPubkey == null) return;
|
||||
final contacts = await _fetchFreshContacts(ref, currentPubkey);
|
||||
final normalized = pubkey.toLowerCase();
|
||||
if (contacts.any((entry) => entry.pubkey == normalized)) return;
|
||||
await setContactList(ref, [...contacts, ContactEntry(pubkey: normalized)]);
|
||||
}
|
||||
|
||||
Future<void> unfollowUser(WidgetRef ref, String pubkey) async {
|
||||
final currentPubkey = ref.read(myPubkeyProvider);
|
||||
if (currentPubkey == null) return;
|
||||
final normalized = pubkey.toLowerCase();
|
||||
final contacts = await _fetchFreshContacts(ref, currentPubkey);
|
||||
await setContactList(
|
||||
ref,
|
||||
contacts.where((entry) => entry.pubkey != normalized).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ContactEntry>> _fetchFreshContacts(
|
||||
WidgetRef ref,
|
||||
String currentPubkey,
|
||||
) async {
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(
|
||||
NostrFilters.contactList(currentPubkey),
|
||||
);
|
||||
return contactsFromEvents(events);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../shared/relay/nostr_models.dart';
|
||||
|
||||
@immutable
|
||||
class UserNote {
|
||||
final String id;
|
||||
final String pubkey;
|
||||
final int createdAt;
|
||||
final String content;
|
||||
final List<List<String>> tags;
|
||||
|
||||
const UserNote({
|
||||
required this.id,
|
||||
required this.pubkey,
|
||||
required this.createdAt,
|
||||
required this.content,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
factory UserNote.fromEvent(NostrEvent event) => UserNote(
|
||||
id: event.id,
|
||||
pubkey: event.pubkey.toLowerCase(),
|
||||
createdAt: event.createdAt,
|
||||
content: event.content,
|
||||
tags: event.tags,
|
||||
);
|
||||
|
||||
String? get replyParentId {
|
||||
for (final tag in tags) {
|
||||
if (tag.length >= 4 && tag[0] == 'e' && tag[3] == 'reply') return tag[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? get replyParentAuthor {
|
||||
for (final tag in tags) {
|
||||
if (tag.length >= 2 && tag[0] == 'p') return tag[1].toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> get mentionPubkeys => [
|
||||
for (final tag in tags)
|
||||
if (tag.length >= 2 && tag[0] == 'p') tag[1].toLowerCase(),
|
||||
];
|
||||
}
|
||||
|
||||
@immutable
|
||||
class NoteReactionSummary {
|
||||
final String noteId;
|
||||
final String emoji;
|
||||
final int count;
|
||||
final List<String> pubkeys;
|
||||
final Map<String, String> reactionIdsByPubkey;
|
||||
|
||||
const NoteReactionSummary({
|
||||
required this.noteId,
|
||||
required this.emoji,
|
||||
required this.count,
|
||||
required this.pubkeys,
|
||||
this.reactionIdsByPubkey = const {},
|
||||
});
|
||||
}
|
||||
|
||||
@immutable
|
||||
class PulseReactionState {
|
||||
final int count;
|
||||
final bool reactedByCurrentUser;
|
||||
final String? currentUserReactionId;
|
||||
|
||||
const PulseReactionState({
|
||||
required this.count,
|
||||
required this.reactedByCurrentUser,
|
||||
this.currentUserReactionId,
|
||||
});
|
||||
}
|
||||
|
||||
@immutable
|
||||
class AgentNoteGroup {
|
||||
final String pubkey;
|
||||
final List<UserNote> notes;
|
||||
final int latestAt;
|
||||
final int earliestAt;
|
||||
|
||||
const AgentNoteGroup({
|
||||
required this.pubkey,
|
||||
required this.notes,
|
||||
required this.latestAt,
|
||||
required this.earliestAt,
|
||||
});
|
||||
}
|
||||
|
||||
List<AgentNoteGroup> groupAgentNotes(
|
||||
List<UserNote> notes, {
|
||||
int windowSeconds = 300,
|
||||
}) {
|
||||
if (notes.isEmpty) return const [];
|
||||
|
||||
final groups = <AgentNoteGroup>[];
|
||||
AgentNoteGroup? current;
|
||||
|
||||
for (final note in notes) {
|
||||
final lastNote = current?.notes.last;
|
||||
if (current != null &&
|
||||
lastNote != null &&
|
||||
current.pubkey == note.pubkey &&
|
||||
lastNote.createdAt - note.createdAt <= windowSeconds) {
|
||||
current = AgentNoteGroup(
|
||||
pubkey: current.pubkey,
|
||||
notes: [...current.notes, note],
|
||||
latestAt: current.latestAt,
|
||||
earliestAt: note.createdAt,
|
||||
);
|
||||
} else {
|
||||
if (current != null) groups.add(current);
|
||||
current = AgentNoteGroup(
|
||||
pubkey: note.pubkey,
|
||||
notes: [note],
|
||||
latestAt: note.createdAt,
|
||||
earliestAt: note.createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (current != null) groups.add(current);
|
||||
return groups;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ContactEntry {
|
||||
final String pubkey;
|
||||
final String? relayUrl;
|
||||
final String? petname;
|
||||
|
||||
const ContactEntry({required this.pubkey, this.relayUrl, this.petname});
|
||||
|
||||
List<String> toTag() => ['p', pubkey.toLowerCase(), ?relayUrl, ?petname];
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import 'agent_activity_card.dart';
|
||||
import 'note_card.dart';
|
||||
import 'note_composer.dart';
|
||||
import 'pulse_models.dart';
|
||||
import 'pulse_provider.dart';
|
||||
import 'pulse_tab_bar.dart';
|
||||
|
||||
enum PulseTab { everyone, following, liked, agents, mine }
|
||||
|
||||
class PulsePage extends HookConsumerWidget {
|
||||
const PulsePage({super.key});
|
||||
|
||||
static const _tabs = [
|
||||
PulseTabSpec(id: 'everyone', label: 'Everyone', icon: LucideIcons.radio),
|
||||
PulseTabSpec(id: 'following', label: 'Following', icon: LucideIcons.users),
|
||||
PulseTabSpec(id: 'liked', label: 'Liked', icon: LucideIcons.heart),
|
||||
PulseTabSpec(id: 'agents', label: 'Agents', icon: LucideIcons.bot),
|
||||
PulseTabSpec(id: 'mine', label: 'Mine', icon: LucideIcons.user),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final active = useState(PulseTab.everyone);
|
||||
final currentPubkey = ref.watch(myPubkeyProvider);
|
||||
final contactsAsync = currentPubkey == null
|
||||
? const AsyncValue<List<ContactEntry>>.data([])
|
||||
: ref.watch(contactListProvider(currentPubkey));
|
||||
final contacts = contactsAsync.asData?.value ?? const <ContactEntry>[];
|
||||
final contactPubkeys = contacts.map((c) => c.pubkey).toList();
|
||||
final contactSet = contactPubkeys.toSet();
|
||||
final agentPubkeys =
|
||||
ref.watch(agentPubkeysProvider).asData?.value.toSet() ?? {};
|
||||
|
||||
final notesAsync = switch (active.value) {
|
||||
PulseTab.everyone => ref.watch(globalNotesProvider),
|
||||
PulseTab.following => ref.watch(
|
||||
notesTimelineProvider(pulseKeyFor(contactPubkeys)),
|
||||
),
|
||||
PulseTab.liked => ref.watch(likedNotesProvider),
|
||||
PulseTab.agents => ref.watch(agentNotesProvider),
|
||||
PulseTab.mine =>
|
||||
currentPubkey == null
|
||||
? const AsyncValue<List<UserNote>>.data([])
|
||||
: ref.watch(notesTimelineProvider(pulseKeyFor([currentPubkey]))),
|
||||
};
|
||||
|
||||
final visibleNotes = notesAsync.asData?.value ?? const <UserNote>[];
|
||||
if (visibleNotes.isNotEmpty) preloadPulseProfiles(ref, visibleNotes);
|
||||
final notesKey = pulseKeyFor(visibleNotes.map((note) => note.id));
|
||||
final reactions = ref.watch(noteReactionsProvider(notesKey));
|
||||
final reactionMap =
|
||||
reactions.asData?.value ?? const <String, PulseReactionState>{};
|
||||
|
||||
return FrostedScaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: const FrostedAppBar(title: Text('Pulse')),
|
||||
body: Column(
|
||||
children: [
|
||||
SizedBox(height: frostedAppBarHeight(context)),
|
||||
PulseTabBar(
|
||||
tabs: _tabs,
|
||||
selected: active.value.name,
|
||||
onSelected: (id) => active.value = PulseTab.values.firstWhere(
|
||||
(tab) => tab.name == id,
|
||||
orElse: () => PulseTab.everyone,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.xs,
|
||||
Grid.xxs,
|
||||
Grid.xs,
|
||||
Grid.xxs,
|
||||
),
|
||||
child: const NoteComposer(),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async => _refresh(ref, active.value, currentPubkey),
|
||||
child: _PulseBody(
|
||||
tab: active.value,
|
||||
notesAsync: notesAsync,
|
||||
reactions: reactionMap,
|
||||
agentPubkeys: agentPubkeys,
|
||||
contactPubkeys: contactSet,
|
||||
currentPubkey: currentPubkey,
|
||||
onReactionChanged: () =>
|
||||
ref.invalidate(noteReactionsProvider(notesKey)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refresh(
|
||||
WidgetRef ref,
|
||||
PulseTab tab,
|
||||
String? currentPubkey,
|
||||
) async {
|
||||
ref.invalidate(globalNotesProvider);
|
||||
ref.invalidate(likedNotesProvider);
|
||||
ref.invalidate(agentPubkeysProvider);
|
||||
ref.invalidate(agentNotesProvider);
|
||||
if (currentPubkey != null) {
|
||||
ref.invalidate(contactListProvider(currentPubkey));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PulseBody extends ConsumerWidget {
|
||||
final PulseTab tab;
|
||||
final AsyncValue<List<UserNote>> notesAsync;
|
||||
final Map<String, PulseReactionState> reactions;
|
||||
final Set<String> agentPubkeys;
|
||||
final Set<String> contactPubkeys;
|
||||
final String? currentPubkey;
|
||||
final VoidCallback onReactionChanged;
|
||||
|
||||
const _PulseBody({
|
||||
required this.tab,
|
||||
required this.notesAsync,
|
||||
required this.reactions,
|
||||
required this.agentPubkeys,
|
||||
required this.contactPubkeys,
|
||||
required this.currentPubkey,
|
||||
required this.onReactionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return notesAsync.when(
|
||||
loading: () => const _TimelineSkeleton(),
|
||||
error: (_, _) => _MessageListShell(
|
||||
child: _EmptyState(
|
||||
icon: LucideIcons.circleAlert,
|
||||
message: 'Could not load Pulse. Pull to try again.',
|
||||
),
|
||||
),
|
||||
data: (notes) {
|
||||
if (notes.isEmpty) {
|
||||
return _MessageListShell(
|
||||
child: _EmptyState(message: _emptyMessage(tab)),
|
||||
);
|
||||
}
|
||||
if (tab == PulseTab.agents) {
|
||||
final groups = groupAgentNotes(notes);
|
||||
return ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.xs,
|
||||
Grid.xxs,
|
||||
Grid.xs,
|
||||
Grid.xs,
|
||||
),
|
||||
itemCount: groups.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: Grid.xxs),
|
||||
itemBuilder: (context, index) => AgentActivityCard(
|
||||
group: groups[index],
|
||||
reactions: reactions,
|
||||
onReactionChanged: onReactionChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.xs,
|
||||
Grid.xxs,
|
||||
Grid.xs,
|
||||
Grid.xs,
|
||||
),
|
||||
itemCount: notes.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: Grid.xxs),
|
||||
itemBuilder: (context, index) {
|
||||
final note = notes[index];
|
||||
return NoteCard(
|
||||
note: note,
|
||||
reaction:
|
||||
reactions[note.id] ??
|
||||
const PulseReactionState(
|
||||
count: 0,
|
||||
reactedByCurrentUser: false,
|
||||
),
|
||||
isAgent: agentPubkeys.contains(note.pubkey),
|
||||
isFollowing: contactPubkeys.contains(note.pubkey),
|
||||
canFollow:
|
||||
currentPubkey != null &&
|
||||
currentPubkey!.toLowerCase() != note.pubkey.toLowerCase(),
|
||||
onReactionChanged: onReactionChanged,
|
||||
onFollowChanged: (_) {
|
||||
if (currentPubkey != null) {
|
||||
ref.invalidate(contactListProvider(currentPubkey!));
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _emptyMessage(PulseTab tab) => switch (tab) {
|
||||
PulseTab.everyone => 'No notes yet. Be the first pulse.',
|
||||
PulseTab.following => 'Follow people to build your Pulse timeline.',
|
||||
PulseTab.liked => 'Heart notes to save them here.',
|
||||
PulseTab.agents =>
|
||||
'No agent notes yet. Agents post here when they publish.',
|
||||
PulseTab.mine => 'Your notes will show up here.',
|
||||
};
|
||||
}
|
||||
|
||||
class _MessageListShell extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _MessageListShell({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(Grid.xs),
|
||||
children: [SizedBox(height: 260, child: Center(child: child))],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String message;
|
||||
|
||||
const _EmptyState({this.icon = LucideIcons.radio, required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Grid.lg),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: context.colors.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: context.colors.onSurfaceVariant),
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimelineSkeleton extends StatelessWidget {
|
||||
const _TimelineSkeleton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(Grid.xs),
|
||||
itemCount: 5,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: Grid.xxs),
|
||||
itemBuilder: (_, _) => Container(
|
||||
height: 112,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import 'pulse_models.dart';
|
||||
|
||||
final globalNotesProvider = FutureProvider<List<UserNote>>((ref) async {
|
||||
final session = ref.watch(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(NostrFilters.globalNotes());
|
||||
return _notesFromEvents(events);
|
||||
});
|
||||
|
||||
final notesTimelineProvider = FutureProvider.family<List<UserNote>, String>((
|
||||
ref,
|
||||
pubkeysKey,
|
||||
) async {
|
||||
final pubkeys = parsePulseKey(pubkeysKey);
|
||||
if (pubkeys.isEmpty) return const [];
|
||||
final session = ref.watch(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(
|
||||
NostrFilters.notesTimeline(pubkeys),
|
||||
);
|
||||
return _notesFromEvents(events);
|
||||
});
|
||||
|
||||
final likedNotesProvider = FutureProvider<List<UserNote>>((ref) async {
|
||||
final pubkey = ref.watch(myPubkeyProvider);
|
||||
if (pubkey == null) return const [];
|
||||
final session = ref.watch(relaySessionProvider.notifier);
|
||||
final reactions = await session.fetchHistory(
|
||||
NostrFilters.userReactions(pubkey),
|
||||
);
|
||||
final liveReactions = await _filterDeletedReactions(
|
||||
session,
|
||||
reactions,
|
||||
deletionAuthors: [pubkey],
|
||||
);
|
||||
final ids = <String>[];
|
||||
final seen = <String>{};
|
||||
for (final reaction in liveReactions) {
|
||||
if (reaction.content != '+') continue;
|
||||
final noteId = _lastETag(reaction.tags);
|
||||
if (noteId != null && seen.add(noteId)) ids.add(noteId);
|
||||
}
|
||||
if (ids.isEmpty) return const [];
|
||||
final notes = await session.fetchHistory(NostrFilters.notesByIds(ids));
|
||||
return _notesFromEvents(notes);
|
||||
});
|
||||
|
||||
final noteReactionsProvider =
|
||||
FutureProvider.family<Map<String, PulseReactionState>, String>((
|
||||
ref,
|
||||
noteIdsKey,
|
||||
) async {
|
||||
final noteIds = parsePulseKey(noteIdsKey);
|
||||
if (noteIds.isEmpty) return const {};
|
||||
final currentPubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
|
||||
final session = ref.watch(relaySessionProvider.notifier);
|
||||
final reactions = await session.fetchHistory(
|
||||
NostrFilters.noteReactions(noteIds),
|
||||
);
|
||||
final events = await _filterDeletedReactions(session, reactions);
|
||||
final pubkeysByNote = <String, Set<String>>{};
|
||||
final currentReactionIds = <String, String>{};
|
||||
|
||||
for (final event in events) {
|
||||
if (event.content != '+') continue;
|
||||
final noteId = _lastETag(event.tags);
|
||||
if (noteId == null) continue;
|
||||
final pubkey = event.pubkey.toLowerCase();
|
||||
pubkeysByNote.putIfAbsent(noteId, () => <String>{}).add(pubkey);
|
||||
if (currentPubkey != null && pubkey == currentPubkey) {
|
||||
currentReactionIds[noteId] = event.id;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
for (final noteId in noteIds)
|
||||
noteId: PulseReactionState(
|
||||
count: pubkeysByNote[noteId]?.length ?? 0,
|
||||
reactedByCurrentUser: currentReactionIds.containsKey(noteId),
|
||||
currentUserReactionId: currentReactionIds[noteId],
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
final contactListProvider = FutureProvider.family<List<ContactEntry>, String>((
|
||||
ref,
|
||||
pubkey,
|
||||
) async {
|
||||
if (pubkey.isEmpty) return const [];
|
||||
final session = ref.watch(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(
|
||||
NostrFilters.contactList(pubkey.toLowerCase()),
|
||||
);
|
||||
return contactsFromEvents(events);
|
||||
});
|
||||
|
||||
final agentPubkeysProvider = FutureProvider<List<String>>((ref) async {
|
||||
final session = ref.watch(relaySessionProvider.notifier);
|
||||
|
||||
// Primary source: kind:10100 agent profile events (each agent signs its own).
|
||||
final profileEvents = await session.fetchHistory(
|
||||
NostrFilters.agentProfiles(),
|
||||
);
|
||||
final pubkeys = <String>{};
|
||||
for (final event in profileEvents) {
|
||||
pubkeys.add(event.pubkey.toLowerCase());
|
||||
final p = event.getTagValue('p');
|
||||
if (p != null) pubkeys.add(p.toLowerCase());
|
||||
}
|
||||
|
||||
// Fallback: relay membership list (kind:13534) — extract members with
|
||||
// role "bot". This catches managed agents that may not have published
|
||||
// a kind:10100 profile event yet.
|
||||
final memberEvents = await session.fetchHistory(NostrFilters.relayMembers());
|
||||
if (memberEvents.isNotEmpty) {
|
||||
final event = memberEvents.first;
|
||||
for (final tag in event.tags) {
|
||||
if (tag.length >= 3 && tag[0] == 'member' && tag[2] == 'bot') {
|
||||
pubkeys.add(tag[1].toLowerCase());
|
||||
}
|
||||
// NIP-29 fallback: ["p", pubkey, relay_url?, role?]
|
||||
if (tag.length >= 4 && tag[0] == 'p' && tag[3] == 'bot') {
|
||||
pubkeys.add(tag[1].toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pubkeys.toList();
|
||||
});
|
||||
|
||||
final agentNotesProvider = FutureProvider<List<UserNote>>((ref) async {
|
||||
final pubkeys = await ref.watch(agentPubkeysProvider.future);
|
||||
if (pubkeys.isEmpty) return const [];
|
||||
return ref.watch(notesTimelineProvider(pulseKeyFor(pubkeys)).future);
|
||||
});
|
||||
|
||||
List<UserNote> _notesFromEvents(List<NostrEvent> events) {
|
||||
final notes =
|
||||
events
|
||||
.where((event) => event.kind == EventKind.note)
|
||||
.map(UserNote.fromEvent)
|
||||
.toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return notes;
|
||||
}
|
||||
|
||||
List<ContactEntry> _contactsFromTags(List<List<String>> tags) => [
|
||||
for (final tag in tags)
|
||||
if (tag.length >= 2 && tag[0] == 'p')
|
||||
ContactEntry(
|
||||
pubkey: tag[1].toLowerCase(),
|
||||
relayUrl: tag.length >= 3 && tag[2].isNotEmpty ? tag[2] : null,
|
||||
petname: tag.length >= 4 && tag[3].isNotEmpty ? tag[3] : null,
|
||||
),
|
||||
];
|
||||
|
||||
String? _lastETag(List<List<String>> tags) {
|
||||
for (final tag in tags.reversed) {
|
||||
if (tag.length >= 2 && tag[0] == 'e') return tag[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String pulseKeyFor(Iterable<String> values) {
|
||||
final normalized =
|
||||
values
|
||||
.map((value) => value.toLowerCase().trim())
|
||||
.where((value) => value.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
return normalized.join(',');
|
||||
}
|
||||
|
||||
List<String> parsePulseKey(String key) {
|
||||
if (key.isEmpty) return const [];
|
||||
return key.split(',').where((value) => value.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
Future<List<NostrEvent>> _filterDeletedReactions(
|
||||
RelaySessionNotifier session,
|
||||
List<NostrEvent> reactions, {
|
||||
List<String>? deletionAuthors,
|
||||
}) async {
|
||||
if (reactions.isEmpty) return const [];
|
||||
final reactionIds = reactions.map((event) => event.id).toList();
|
||||
final deletions = await session.fetchHistory(
|
||||
NostrFilters.deletionsByTargetIds(reactionIds, authors: deletionAuthors),
|
||||
);
|
||||
final deletedIds = <String>{};
|
||||
for (final deletion in deletions) {
|
||||
for (final tag in deletion.tags) {
|
||||
if (tag.length >= 2 && tag[0] == 'e') deletedIds.add(tag[1]);
|
||||
}
|
||||
}
|
||||
if (deletedIds.isEmpty) return reactions;
|
||||
return [
|
||||
for (final reaction in reactions)
|
||||
if (!deletedIds.contains(reaction.id)) reaction,
|
||||
];
|
||||
}
|
||||
|
||||
List<ContactEntry> contactsFromEvents(List<NostrEvent> events) {
|
||||
if (events.isEmpty) return const [];
|
||||
return _contactsFromTags(events.first.tags);
|
||||
}
|
||||
|
||||
void preloadPulseProfiles(WidgetRef ref, List<UserNote> notes) {
|
||||
final pubkeys = <String>{};
|
||||
for (final note in notes) {
|
||||
pubkeys.add(note.pubkey);
|
||||
pubkeys.addAll(note.mentionPubkeys);
|
||||
}
|
||||
ref.read(userCacheProvider.notifier).preload(pubkeys.toList());
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
|
||||
class PulseTabSpec {
|
||||
final String id;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
|
||||
const PulseTabSpec({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
class PulseTabBar extends StatelessWidget {
|
||||
final List<PulseTabSpec> tabs;
|
||||
final String selected;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
const PulseTabBar({
|
||||
super.key,
|
||||
required this.tabs,
|
||||
required this.selected,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.xs,
|
||||
Grid.twelve,
|
||||
Grid.xs,
|
||||
Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final tab in tabs) ...[
|
||||
if (tab != tabs.first) const SizedBox(width: Grid.xxs),
|
||||
GestureDetector(
|
||||
onTap: () => onSelected(tab.id),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.twelve,
|
||||
vertical: Grid.half + 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: selected == tab.id
|
||||
? context.colors.primary
|
||||
: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
border: selected == tab.id
|
||||
? null
|
||||
: Border.all(color: context.colors.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
tab.icon,
|
||||
size: 14,
|
||||
color: selected == tab.id
|
||||
? context.colors.onPrimary
|
||||
: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
tab.label,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: selected == tab.id
|
||||
? context.colors.onPrimary
|
||||
: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -123,13 +123,60 @@ abstract final class NostrFilters {
|
||||
limit: limit,
|
||||
);
|
||||
|
||||
/// Deletions (kind:5) targeting event IDs.
|
||||
static NostrFilter deletionsByTargetIds(
|
||||
List<String> ids, {
|
||||
List<String>? authors,
|
||||
}) => NostrFilter(
|
||||
kinds: [EventKind.deletion],
|
||||
authors: authors,
|
||||
tags: {'#e': ids},
|
||||
limit: ids.length,
|
||||
);
|
||||
|
||||
/// User notes (kind:1) for the global Pulse timeline.
|
||||
static NostrFilter globalNotes({int limit = 50, int? until}) =>
|
||||
NostrFilter(kinds: [EventKind.note], limit: limit, until: until);
|
||||
|
||||
/// Notes by a set of authors for Pulse timelines.
|
||||
static NostrFilter notesTimeline(
|
||||
List<String> pubkeys, {
|
||||
int limit = 200,
|
||||
int? until,
|
||||
}) => NostrFilter(
|
||||
kinds: [EventKind.note],
|
||||
authors: pubkeys,
|
||||
limit: limit,
|
||||
until: until,
|
||||
);
|
||||
|
||||
/// Reactions authored by a user.
|
||||
static NostrFilter userReactions(String pubkey, {int limit = 200}) =>
|
||||
NostrFilter(kinds: [EventKind.reaction], authors: [pubkey], limit: limit);
|
||||
|
||||
/// Reactions targeting notes.
|
||||
static NostrFilter noteReactions(List<String> noteIds) => NostrFilter(
|
||||
kinds: [EventKind.reaction],
|
||||
tags: {'#e': noteIds},
|
||||
limit: 500,
|
||||
);
|
||||
|
||||
/// Fetch notes by ids.
|
||||
static NostrFilter notesByIds(List<String> ids) =>
|
||||
NostrFilter(kinds: [EventKind.note], ids: ids, limit: ids.length);
|
||||
|
||||
/// User notes (kind:1) for a single author.
|
||||
static NostrFilter userNotes(String pubkey, {int limit = 20, int? until}) =>
|
||||
NostrFilter(kinds: [1], authors: [pubkey], limit: limit, until: until);
|
||||
NostrFilter(
|
||||
kinds: [EventKind.note],
|
||||
authors: [pubkey],
|
||||
limit: limit,
|
||||
until: until,
|
||||
);
|
||||
|
||||
/// Contact list (kind:3) for a user.
|
||||
static NostrFilter contactList(String pubkey) =>
|
||||
NostrFilter(kinds: [3], authors: [pubkey], limit: 1);
|
||||
NostrFilter(kinds: [EventKind.contactList], authors: [pubkey], limit: 1);
|
||||
|
||||
/// Relay membership list (kind:13534).
|
||||
static NostrFilter relayMembers() =>
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart';
|
||||
///
|
||||
/// Keep in sync with `desktop/src/shared/constants/kinds.ts`.
|
||||
abstract final class EventKind {
|
||||
static const note = 1;
|
||||
static const contactList = 3;
|
||||
static const deletion = 5;
|
||||
static const reaction = 7;
|
||||
static const streamMessage = 9;
|
||||
|
||||
Reference in New Issue
Block a user