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
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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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());
|
||||
}
|
||||
Reference in New Issue
Block a user