feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,179 @@
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/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.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 Column(
children: [
InkWell(
onTap: group.notes.length > 1
? () => expanded.value = !expanded.value
: null,
borderRadius: BorderRadius.circular(Radii.md),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.twelve),
child: Row(
children: [
Stack(
children: [
AvatarImage(
imageUrl: profile?.avatarUrl,
radius: 18,
backgroundColor: context.colors.primaryContainer,
fallback: const Icon(LucideIcons.bot, size: 18),
),
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.surface,
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(
context.l10n.pulseBot,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w800,
fontSize: 10,
),
),
),
],
),
Text(
'${context.l10n.pulseUpdates(group.notes.length)} · '
'${formatPulseRelativeTime(group.latestAt, l10n: context.l10n)}',
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.only(left: Grid.xl),
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)
Divider(
height: 1,
thickness: 1,
color: context.colors.outlineVariant.withValues(
alpha: 0.4,
),
),
],
],
),
)
else
Padding(
padding: const EdgeInsets.only(left: Grid.xl, bottom: Grid.xxs),
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,272 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../channels/message_content.dart';
import '../profile/profile_provider.dart';
import '../profile/user_cache_provider.dart';
import 'note_card.dart';
import 'pulse_actions.dart';
import 'pulse_models.dart';
/// Full-screen page for composing a new note or replying to one.
///
/// When [replyTo] is null this composes a new top-level note; when set the
/// page shows the note being replied to as context and the action button
/// reads "Reply". The text field auto-focuses on open so the keyboard is
/// ready immediately.
class ComposeNotePage extends HookConsumerWidget {
final UserNote? replyTo;
const ComposeNotePage({super.key, this.replyTo});
bool get _isReply => replyTo != null;
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController();
final focusNode = useFocusNode();
final isSending = useState(false);
final hasText = useListenableSelector(
controller,
() => controller.text.trim().isNotEmpty,
);
final profile = ref.watch(profileProvider).asData?.value;
// Auto-focus the field once the page has mounted.
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
return null;
}, const []);
Future<void> submit() async {
if (!hasText || isSending.value) return;
isSending.value = true;
try {
await publishNote(ref, content: controller.text, replyTo: replyTo);
if (context.mounted) Navigator.of(context).pop(true);
} catch (_) {
// Keep the page open so the draft isn't lost; re-enable the button.
isSending.value = false;
rethrow;
}
}
return FrostedScaffold(
resizeToAvoidBottomInset: true,
appBar: FrostedAppBar(
leading: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.cancel),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: Grid.xxs),
child: FilledButton(
onPressed: hasText && !isSending.value ? submit : null,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
minimumSize: const Size(0, 36),
shape: const StadiumBorder(),
),
child: isSending.value
? SizedBox(
width: 16,
height: 16,
child: BuzzLoadingIndicator(
size: 16,
semanticLabel: _isReply
? context.l10n.pulseSendingReply
: context.l10n.pulsePublishingPost,
),
)
: Text(
_isReply ? context.l10n.pulseReply : context.l10n.pulsePost,
),
),
),
],
),
body: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: frostedAppBarHeight(context)),
if (_isReply) _ReplyContext(note: replyTo!),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(Grid.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AvatarImage(
imageUrl: profile?.avatarUrl,
radius: 18,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
profile?.initial ?? '?',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
),
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: TextField(
controller: controller,
focusNode: focusNode,
minLines: 3,
maxLines: null,
autofocus: true,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
style: context.textTheme.bodyLarge,
decoration: InputDecoration(
hintText: _isReply
? context.l10n.pulsePostYourReply
: context.l10n.pulseWhatOnMind,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
vertical: Grid.half,
),
),
),
),
],
),
),
),
],
),
),
);
}
}
/// A read-only preview of the note being replied to, laid out like a list
/// row (avatar + name + time + content) but non-interactive. Capped in
/// height so a long note doesn't push the editor off-screen.
class _ReplyContext extends ConsumerWidget {
final UserNote note;
const _ReplyContext({required this.note});
@override
Widget build(BuildContext context, WidgetRef ref) {
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);
return Padding(
padding: const EdgeInsets.fromLTRB(Grid.gutter, Grid.xs, Grid.gutter, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.pulseReplyingTo(displayName),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.xs),
// Mirror the NoteCard list-row layout, read-only. Shrink-wraps to
// the content; long notes are capped to ~3 lines via the parent
// page scroll + ellipsis on the content.
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AvatarImage(
imageUrl: profile?.avatarUrl,
radius: 18,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
(profile?.initial ?? displayName[0]).toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
),
),
),
const SizedBox(width: Grid.xs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
displayName,
maxLines: 1,
style: messageUsernameTextStyle,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: Grid.half),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: Grid.xxl),
child: Text(
formatPulseRelativeTime(
note.createdAt,
l10n: context.l10n,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: Grid.half),
// Rich content (images, links, mentions) like the list
// row, but clipped to a compact max height so a tall
// image or long note can't blow up the page.
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 132),
child: ClipRect(
child: Align(
alignment: Alignment.topLeft,
heightFactor: 1,
child: MessageContent(
content: note.content,
tags: note.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
),
),
),
),
],
),
),
],
),
const SizedBox(height: Grid.xs),
Divider(
height: 1,
color: context.colors.outlineVariant.withValues(alpha: 0.5),
),
],
),
);
}
String _shortPubkey(String pubkey) =>
pubkey.length >= 8 ? '${pubkey.substring(0, 8)}...' : pubkey;
}
+352
View File
@@ -0,0 +1,352 @@
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 'package:nostr/nostr.dart' as nostr;
import '../../shared/clipboard_utils.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.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 'compose_note_page.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 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);
// Clear the optimistic flag only once the refetched server state matches
// it, so the count never flickers back through the stale value mid-refetch.
useEffect(() {
if (pendingUpvote.value != null &&
reaction.reactedByCurrentUser == pendingUpvote.value) {
pendingUpvote.value = null;
}
return null;
}, [reaction.reactedByCurrentUser]);
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.twelve),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => showUserProfileSheet(context, note.pubkey),
child: AvatarImage(
imageUrl: profile?.avatarUrl,
radius: 18,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
(profile?.initial ?? displayName[0]).toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
),
),
),
),
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: [
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
displayName,
maxLines: 1,
style: messageUsernameTextStyle,
overflow: TextOverflow.ellipsis,
),
),
if (isAgent) ...[
const SizedBox(width: Grid.half),
Icon(
LucideIcons.bot,
size: 13,
color: context.colors.primary,
),
],
],
),
),
const SizedBox(width: Grid.xxs),
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Grid.xl,
),
child: Text(
formatPulseRelativeTime(
note.createdAt,
l10n: context.l10n,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.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(
context.l10n.pulseReplyingTo(
_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,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
),
const SizedBox(height: Grid.xxs),
Row(
children: [
_ActionButton(
icon: effectiveUpvoted
? Icons.favorite
: LucideIcons.heart,
label: effectiveCount > 0 ? '$effectiveCount' : null,
color: effectiveUpvoted ? Colors.redAccent : null,
onTap: () async {
final next = !effectiveUpvoted;
pendingUpvote.value = next;
try {
await toggleNoteUpvote(
ref,
noteId: note.id,
isUpvoted: reaction.reactedByCurrentUser,
reactionEventId: reaction.currentUserReactionId,
);
onReactionChanged?.call();
} catch (_) {
// Revert the optimistic state on failure.
pendingUpvote.value = null;
}
},
),
_ActionButton(
icon: LucideIcons.messageCircle,
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ComposeNotePage(replyTo: note),
),
),
),
_ActionButton(
icon: LucideIcons.share,
onTap: () async {
await copyToClipboard(
context,
_shareUri(note),
message: context.l10n.pulseCopiedUri,
);
},
),
_ActionButton(
icon: LucideIcons.mail,
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),
),
);
},
),
],
),
],
),
),
],
),
);
}
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 ? context.l10n.pulseFollowing : context.l10n.pulseFollow,
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;
const _ActionButton({
required this.icon,
required this.onTap,
this.label,
this.color,
});
@override
Widget build(BuildContext context) {
final effectiveColor = color ?? context.colors.onSurfaceVariant;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(Radii.md),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.half,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: effectiveColor),
if (label != null) ...[
const SizedBox(width: 3),
Text(
label!,
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, {
AppLocalizations? l10n,
}) {
final date = DateTime.fromMillisecondsSinceEpoch(createdAt * 1000);
final diff = DateTime.now().difference(date);
if (diff.inMinutes < 1) return l10n?.pulseJustNow ?? 'just now';
if (diff.inHours < 1) {
return l10n?.pulseMinutes(diff.inMinutes) ?? '${diff.inMinutes}m';
}
if (diff.inDays < 1) {
return l10n?.pulseHours(diff.inHours) ?? '${diff.inHours}h';
}
if (diff.inDays < 7) {
return l10n?.pulseDays(diff.inDays) ?? '${diff.inDays}d';
}
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
final month = switch (l10n?.localeName) {
'zh_CN' => const [
'1月', '2月', '3月', '4月', '5月', '6月',
'7月', '8月', '9月', '10月', '11月', '12月',
][date.month - 1],
_ => months[date.month - 1],
};
return l10n?.pulseMonthDay(month, date.day) ?? '${months[date.month - 1]} ${date.day}';
}
@@ -0,0 +1,105 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../channels/channel_management_provider.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_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);
tags.addAll(buildCustomEmojiTags(text, ref.read(customEmojiListProvider)));
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];
}
+325
View File
@@ -0,0 +1,325 @@
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/l10n/l10n.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/filter_chip_bar.dart';
import '../../shared/widgets/bee_refresh_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import 'agent_activity_card.dart';
import 'compose_note_page.dart';
import 'note_card.dart';
import 'pulse_models.dart';
import 'pulse_provider.dart';
enum PulseTab { everyone, following, liked, agents, mine }
class PulsePage extends HookConsumerWidget {
const PulsePage({super.key});
@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: FrostedAppBar(title: Text(context.l10n.pulse)),
floatingActionButton: FloatingActionButton(
heroTag: 'pulse-compose-fab',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const ComposeNotePage()),
),
tooltip: context.l10n.pulseNewNote,
shape: const CircleBorder(),
child: const Icon(LucideIcons.plus),
),
body: Column(
children: [
SizedBox(height: frostedAppBarHeight(context)),
FilterChipBar<PulseTab>(
selected: active.value,
onSelected: (tab) => active.value = tab,
items: [
FilterChipItem(
id: PulseTab.everyone,
label: context.l10n.pulseEveryone,
icon: LucideIcons.radio,
),
FilterChipItem(
id: PulseTab.following,
label: context.l10n.pulseFollowing,
icon: LucideIcons.users,
),
FilterChipItem(
id: PulseTab.liked,
label: context.l10n.pulseLiked,
icon: LucideIcons.heart,
),
FilterChipItem(
id: PulseTab.agents,
label: context.l10n.pulseAgents,
icon: LucideIcons.bot,
),
FilterChipItem(
id: PulseTab.mine,
label: context.l10n.pulseMine,
icon: LucideIcons.user,
),
],
),
Expanded(
child: BeeRefreshIndicator(
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: context.l10n.pulseLoadFailed,
),
),
data: (notes) {
if (notes.isEmpty) {
return _MessageListShell(
child: _EmptyState(message: _emptyMessage(context, tab)),
);
}
if (tab == PulseTab.agents) {
final groups = groupAgentNotes(notes);
return ListView.separated(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.xs,
),
itemCount: groups.length,
separatorBuilder: (_, _) => const _NoteDivider(),
itemBuilder: (context, index) => AgentActivityCard(
group: groups[index],
reactions: reactions,
onReactionChanged: onReactionChanged,
),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.xs,
),
itemCount: notes.length,
separatorBuilder: (_, _) => const _NoteDivider(),
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(BuildContext context, PulseTab tab) => switch (tab) {
PulseTab.everyone => context.l10n.pulseEmptyEveryone,
PulseTab.following => context.l10n.pulseEmptyFollowing,
PulseTab.liked => context.l10n.pulseEmptyLiked,
PulseTab.agents => context.l10n.pulseEmptyAgents,
PulseTab.mine => context.l10n.pulseEmptyMine,
};
}
class _MessageListShell extends StatelessWidget {
final Widget child;
const _MessageListShell({required this.child});
@override
Widget build(BuildContext context) {
return ListView(
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.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.xs,
),
itemCount: 5,
separatorBuilder: (_, _) => const _NoteDivider(),
itemBuilder: (_, _) => Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.twelve),
child: Container(
height: 64,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest.withValues(
alpha: 0.55,
),
borderRadius: BorderRadius.circular(Radii.md),
),
),
),
);
}
}
class _NoteDivider extends StatelessWidget {
const _NoteDivider();
@override
Widget build(BuildContext context) {
return Divider(
height: 1,
thickness: 1,
color: context.colors.outlineVariant.withValues(alpha: 0.5),
);
}
}
@@ -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());
}