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):
|
- connectivity_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_secure_storage (6.0.0):
|
- flutter_secure_storage_darwin (10.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- image_picker_ios (0.0.1):
|
- image_picker_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- mobile_scanner (7.0.0):
|
- mobile_scanner (7.0.0):
|
||||||
@@ -23,7 +24,7 @@ PODS:
|
|||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- 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`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
@@ -36,8 +37,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_secure_storage:
|
flutter_secure_storage_darwin:
|
||||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||||
image_picker_ios:
|
image_picker_ios:
|
||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
mobile_scanner:
|
mobile_scanner:
|
||||||
@@ -54,7 +55,7 @@ EXTERNAL SOURCES:
|
|||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||||
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
|
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|||||||
|
|
||||||
import '../activity/activity_page.dart';
|
import '../activity/activity_page.dart';
|
||||||
import '../channels/channels_page.dart';
|
import '../channels/channels_page.dart';
|
||||||
|
import '../pulse/pulse_page.dart';
|
||||||
import '../search/search_page.dart';
|
import '../search/search_page.dart';
|
||||||
|
|
||||||
class HomePage extends HookConsumerWidget {
|
class HomePage extends HookConsumerWidget {
|
||||||
@@ -14,7 +15,7 @@ class HomePage extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final tabIndex = useState(0);
|
final tabIndex = useState(0);
|
||||||
|
|
||||||
const pages = [ChannelsPage(), ActivityPage(), SearchPage()];
|
const pages = [ChannelsPage(), PulsePage(), ActivityPage(), SearchPage()];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: IndexedStack(index: tabIndex.value, children: pages),
|
body: IndexedStack(index: tabIndex.value, children: pages),
|
||||||
@@ -27,6 +28,11 @@ class HomePage extends HookConsumerWidget {
|
|||||||
selectedIcon: Icon(LucideIcons.house),
|
selectedIcon: Icon(LucideIcons.house),
|
||||||
label: 'Home',
|
label: 'Home',
|
||||||
),
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(LucideIcons.radio),
|
||||||
|
selectedIcon: Icon(LucideIcons.radio),
|
||||||
|
label: 'Pulse',
|
||||||
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(LucideIcons.bell),
|
icon: Icon(LucideIcons.bell),
|
||||||
selectedIcon: 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,
|
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.
|
/// User notes (kind:1) for a single author.
|
||||||
static NostrFilter userNotes(String pubkey, {int limit = 20, int? until}) =>
|
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.
|
/// Contact list (kind:3) for a user.
|
||||||
static NostrFilter contactList(String pubkey) =>
|
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).
|
/// Relay membership list (kind:13534).
|
||||||
static NostrFilter relayMembers() =>
|
static NostrFilter relayMembers() =>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart';
|
|||||||
///
|
///
|
||||||
/// Keep in sync with `desktop/src/shared/constants/kinds.ts`.
|
/// Keep in sync with `desktop/src/shared/constants/kinds.ts`.
|
||||||
abstract final class EventKind {
|
abstract final class EventKind {
|
||||||
|
static const note = 1;
|
||||||
|
static const contactList = 3;
|
||||||
static const deletion = 5;
|
static const deletion = 5;
|
||||||
static const reaction = 7;
|
static const reaction = 7;
|
||||||
static const streamMessage = 9;
|
static const streamMessage = 9;
|
||||||
|
|||||||
Reference in New Issue
Block a user