feat(mobile): add Pulse social feed tab (#772)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-05-28 11:09:44 -07:00
committed by GitHub
parent fec6a683ea
commit 1218572fa0
12 changed files with 1532 additions and 8 deletions
+6 -5
View File
@@ -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
+7 -1
View File
@@ -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)}';
+330
View File
@@ -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 = 'Whats 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);
}
+139
View File
@@ -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];
}
+289
View File
@@ -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,
),
),
],
),
),
),
],
],
),
);
}
}
+49 -2
View File
@@ -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;