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,90 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
/// In-memory cache of other users' presence.
///
/// Subscribes to kind:20001 presence events over the relay WebSocket for
/// real-time updates. There is no longer a REST backstop — agents that
/// publish presence purely over WS are fine, and TTL expiry will be handled
/// by the relay-side `presence:true` filter extension when that lands.
class PresenceCacheNotifier extends Notifier<Map<String, String>> {
final Set<String> _tracked = {};
void Function()? _presenceUnsub;
int _subscriptionVersion = 0;
@override
Map<String, String> build() {
final sessionState = ref.watch(relaySessionProvider);
ref.onDispose(() {
_presenceUnsub?.call();
_presenceUnsub = null;
});
if (sessionState.status == SessionStatus.connected) {
_subscribePresenceUpdates();
}
return {};
}
/// Track presence for [pubkeys].
///
/// Currently a no-op for the actual fetch — we rely on live kind:20001
/// events. The tracked set is still used to filter incoming events so the
/// cache doesn't grow unbounded.
void track(List<String> pubkeys) {
final normalized = pubkeys.map((pk) => pk.toLowerCase()).toList();
_tracked.addAll(normalized);
// TODO(presence): once the relay supports a `presence:true` filter
// extension, issue a one-shot fetch here for the latest known state per
// pubkey. Until then, presence is "online whenever they publish".
}
/// Subscribe to kind:20001 presence events over WebSocket.
Future<void> _subscribePresenceUpdates() async {
_presenceUnsub?.call();
_presenceUnsub = null;
_subscriptionVersion++;
final version = _subscriptionVersion;
final session = ref.read(relaySessionProvider.notifier);
try {
final unsub = await session.subscribe(
const NostrFilter(kinds: [EventKind.presenceUpdate], limit: 0),
_handlePresenceEvent,
);
// Guard: if build() re-fired while we were awaiting, discard this
// subscription to avoid leaking it.
if (version != _subscriptionVersion) {
unsub();
return;
}
_presenceUnsub = unsub;
} catch (error) {
debugPrint(
'[PresenceCacheNotifier] presence subscription failed: $error',
);
}
}
void _handlePresenceEvent(NostrEvent event) {
final pubkey = event.pubkey.toLowerCase();
if (!_tracked.contains(pubkey)) return;
final status = event.content;
if (status != 'online' && status != 'away' && status != 'offline') return;
if (state[pubkey] == status) return;
final updated = Map<String, String>.from(state);
updated[pubkey] = status;
state = updated;
}
}
final presenceCacheProvider =
NotifierProvider<PresenceCacheNotifier, Map<String, String>>(
PresenceCacheNotifier.new,
);
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/masked_avatar_badge.dart';
import 'profile_provider.dart';
import 'user_profile.dart';
/// Matches desktop's sidebar profile card, whose avatar is 32px.
const _defaultAvatarSize = 32.0;
/// The visible dot is smaller than the notch it sits in, so a ring of
/// background separates it from the avatar. Desktop's `h-2 w-2` dot inside a
/// `h-3.5 w-3.5` badge frame.
const _presenceDotRatio = 8 / 14;
/// User avatar with a presence dot indicator, for use in the app bar.
///
/// The dot sits in a notch masked out of the avatar rather than overlapping it,
/// the same treatment desktop's `SidebarProfileCard` uses — so it needs no
/// background-coloured ring, and reads correctly over the themed top section.
class ProfileAvatar extends ConsumerWidget {
final VoidCallback? onTap;
final bool showPresence;
/// The avatar diameter in logical pixels; defaults to the 32px desktop match.
final double size;
const ProfileAvatar({
super.key,
this.onTap,
this.showPresence = true,
this.size = _defaultAvatarSize,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profileAsync = ref.watch(profileProvider);
final presenceAsync = ref.watch(presenceProvider);
final presence = presenceAsync.when(
data: (v) => v,
loading: () => 'online',
error: (_, _) => 'offline',
);
return profileAsync.when(
loading: () => _buildPlaceholder(context),
error: (_, _) => _buildAvatar(context, null, presence),
data: (profile) => _buildAvatar(context, profile, presence),
);
}
Widget _buildPlaceholder(BuildContext context) {
return CircleAvatar(
radius: size / 2,
backgroundColor: context.colors.primaryContainer,
);
}
Widget _buildAvatar(
BuildContext context,
UserProfile? profile,
String presence,
) {
return GestureDetector(
onTap: onTap,
child: MaskedAvatarBadge(
size: size,
geometry: AvatarBadgeMaskGeometry.presenceDot,
avatar: ClipOval(
child: ColoredBox(
color: context.colors.primaryContainer,
child: AvatarImageContent(
imageUrl: profile?.avatarUrl,
fallback: Text(
profile?.initial ?? '?',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
),
),
),
),
),
badge: showPresence
? Center(
child: FractionallySizedBox(
widthFactor: _presenceDotRatio,
heightFactor: _presenceDotRatio,
child: DecoratedBox(
decoration: BoxDecoration(
color: _presenceColor(context, presence),
shape: BoxShape.circle,
),
),
),
)
: null,
),
);
}
Color _presenceColor(BuildContext context, String presence) {
return switch (presence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
};
}
}
@@ -0,0 +1,168 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import 'user_profile.dart';
/// The current user's profile (kind:0 metadata) loaded over the relay
/// WebSocket. Returns null when no nsec is configured or when the user has
/// not yet published a profile.
class ProfileNotifier extends AsyncNotifier<UserProfile?> {
@override
Future<UserProfile?> build() {
ref.watch(relayConfigProvider);
ref.watch(relaySessionProvider);
return _fetch();
}
Future<UserProfile?> _fetch() async {
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) return null;
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(NostrFilters.profile(myPk));
if (events.isEmpty) return null;
final data = ProfileData.fromEvent(events.first);
return UserProfile(
pubkey: data.pubkey,
displayName: data.displayName,
avatarUrl: data.avatarUrl,
about: data.about,
nip05Handle: data.nip05,
);
}
Future<void> refresh() async {
state = await AsyncValue.guard(_fetch);
}
}
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(
ProfileNotifier.new,
);
/// Presence status for the current user.
///
/// Sends a heartbeat every 60s while the app is active by publishing a
/// kind:20001 presence event over the relay WebSocket. Watches
/// [appLifecycleProvider] to send "away" when backgrounded.
class PresenceNotifier extends AsyncNotifier<String> {
static const _heartbeatInterval = Duration(seconds: 60);
static const _preferenceKeyPrefix = 'buzz_presence_preference_';
Timer? _heartbeatTimer;
String? _preferencePubkey;
String? _manualPresence;
@override
Future<String> build() {
ref.watch(relaySessionProvider);
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
if (_preferencePubkey != pubkey) {
_preferencePubkey = pubkey;
final stored = pubkey == null
? null
: ref
.read(savedPrefsProvider)
.getString('$_preferenceKeyPrefix$pubkey');
_manualPresence = stored == 'away' || stored == 'offline' ? stored : null;
}
final lifecycle = ref.watch(appLifecycleProvider);
ref.onDispose(() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
});
final manualPresence = _manualPresence;
if (manualPresence != null) {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
return _setPresence(manualPresence);
}
if (lifecycle == AppLifecycleState.resumed) {
_startHeartbeat();
return _setPresence('online');
} else if (lifecycle == AppLifecycleState.paused ||
lifecycle == AppLifecycleState.detached) {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
return _setPresence('away');
}
// Default: we don't know. Reflect the most recent state we set, or
// 'offline' if never set.
return Future.value('offline');
}
void _startHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) {
_setPresence('online');
});
}
/// Updates the current user's presence preference and publishes it.
///
/// Online restores automatic lifecycle-driven presence. Away and Offline
/// remain selected until the user chooses another value.
Future<void> setPresence(String status) async {
if (status != 'online' && status != 'away' && status != 'offline') return;
_manualPresence = status == 'online' ? null : status;
final pubkey = ref.read(myPubkeyProvider)?.toLowerCase();
if (pubkey != null) {
await ref
.read(savedPrefsProvider)
.setString('$_preferenceKeyPrefix$pubkey', _manualPresence ?? 'auto');
}
if (_manualPresence == null &&
ref.read(appLifecycleProvider) == AppLifecycleState.resumed) {
_startHeartbeat();
} else {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
state = AsyncData(status);
await _setPresence(status);
}
/// Publish a kind:20001 presence event. Returns the requested status
/// optimistically — failures are silently absorbed and the next heartbeat
/// will retry.
Future<String> _setPresence(String status) async {
final sessionState = ref.read(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return status;
final config = ref.read(relayConfigProvider);
final relay = SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: config.nsec,
);
try {
await relay.submit(
kind: EventKind.presenceUpdate,
content: status,
tags: const [],
);
} catch (_) {
// Heartbeat will retry.
}
return status;
}
Future<void> refresh() async {
// No-op: presence is driven by heartbeats and lifecycle, not pulled.
}
}
final presenceProvider = AsyncNotifierProvider<PresenceNotifier, String>(
PresenceNotifier.new,
);
@@ -0,0 +1,245 @@
import 'dart:math' as math;
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/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/emoji_picker.dart';
import 'user_status.dart';
import 'user_status_provider.dart';
/// The emoji well and the text field are sized to be the two things you reach
/// for, so they carry no borders — the sheet has no other controls to compete
/// with them.
const _emojiWellSize = 56.0;
const _emojiGlyphSize = 32.0;
const _saveButtonHeight = 52.0;
void showSetStatusSheet(BuildContext context, {UserStatus? currentStatus}) {
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _SetStatusSheet(currentStatus: currentStatus),
);
}
class _SetStatusSheet extends HookConsumerWidget {
final UserStatus? currentStatus;
const _SetStatusSheet({this.currentStatus});
@override
Widget build(BuildContext context, WidgetRef ref) {
final textController = useTextEditingController(
text: currentStatus?.text ?? '',
);
final emoji = useState(currentStatus?.emoji ?? '');
final text = useState(currentStatus?.text ?? '');
final isSaving = useState(false);
useEffect(() {
void listener() => text.value = textController.text;
textController.addListener(listener);
return () => textController.removeListener(listener);
}, [textController]);
final hasContent = text.value.trim().isNotEmpty || emoji.value.isNotEmpty;
final hasExistingStatus = currentStatus != null && !currentStatus!.isEmpty;
Future<void> handleSave() async {
if (isSaving.value) return;
isSaving.value = true;
try {
await ref
.read(userStatusProvider.notifier)
.setStatus(text.value, emoji.value);
if (context.mounted) Navigator.of(context).pop();
} finally {
isSaving.value = false;
}
}
Future<void> handleClear() async {
if (isSaving.value) return;
isSaving.value = true;
try {
await ref.read(userStatusProvider.notifier).clearStatus();
if (context.mounted) Navigator.of(context).pop();
} finally {
isSaving.value = false;
}
}
return Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
// The sheet ends in the Save button, so it owns its own breathing room
// above whichever is taller: the keyboard, or the home indicator.
Grid.gutter +
math.max(
MediaQuery.viewInsetsOf(context).bottom,
MediaQuery.viewPaddingOf(context).bottom,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.setStatus, style: context.textTheme.titleMedium),
const SizedBox(height: Grid.half),
Text(
context.l10n.statusDescription,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.twelve),
// The emoji well doubles as the picker's entry point, which is why
// there is no separate row of emoji suggestions below.
Row(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Semantics(
button: true,
label: context.l10n.chooseStatusEmoji,
child: InkWell(
borderRadius: BorderRadius.circular(Radii.lg),
onTap: () => showEmojiPicker(
context: context,
onSelect: (value) => emoji.value = value,
),
child: SizedBox.square(
dimension: _emojiWellSize,
child: Center(
child: _StatusEmojiPreview(emoji: emoji.value),
),
),
),
),
if (emoji.value.isNotEmpty)
Positioned(
top: -Grid.quarter,
right: -Grid.quarter,
child: SizedBox.square(
dimension: Grid.sm,
child: IconButton(
onPressed: isSaving.value
? null
: () => emoji.value = '',
tooltip: context.l10n.removeStatusEmoji,
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
backgroundColor: context.colors.surface,
minimumSize: const Size.square(Grid.sm),
maximumSize: const Size.square(Grid.sm),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
LucideIcons.x,
size: 14,
color: context.colors.onSurface,
),
),
),
),
],
),
const SizedBox(width: Grid.half),
Expanded(
child: TextField(
controller: textController,
autofocus: true,
style: context.textTheme.titleMedium,
decoration: InputDecoration(
hintText: context.l10n.statusHint,
hintStyle: context.textTheme.titleMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
// The theme outlines inputs; this one is the sheet's whole
// content, so every border state is cleared explicitly.
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: false,
contentPadding: EdgeInsets.zero,
),
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (hasContent) handleSave();
},
),
),
],
),
const SizedBox(height: Grid.gutter),
SizedBox(
width: double.infinity,
height: _saveButtonHeight,
child: FilledButton(
onPressed: hasContent && !isSaving.value ? handleSave : null,
child: Text(context.l10n.save),
),
),
// No Cancel \u2014 the sheet dismisses by swiping down.
if (hasExistingStatus)
Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: SizedBox(
width: double.infinity,
child: TextButton(
onPressed: isSaving.value ? null : handleClear,
child: Text(context.l10n.clearStatus),
),
),
),
],
),
);
}
}
class _StatusEmojiPreview extends ConsumerWidget {
final String emoji;
const _StatusEmojiPreview({required this.emoji});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (emoji.isEmpty) {
return Icon(
LucideIcons.smilePlus,
size: _emojiGlyphSize,
color: context.colors.onSurfaceVariant,
);
}
final palette = ref.watch(customEmojiListProvider);
final shortcode = normalizeShortcode(emoji);
if (shortcode != null) {
for (final entry in palette) {
if (entry.shortcode == shortcode) {
return CustomEmojiImage(
shortcode: shortcode,
url: entry.url,
size: _emojiGlyphSize,
);
}
}
}
return Text(emoji, style: const TextStyle(fontSize: _emojiGlyphSize));
}
}
@@ -0,0 +1,305 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/masked_avatar_badge.dart';
import 'profile_provider.dart';
import 'set_status_sheet.dart';
import 'user_status_provider.dart';
/// Desktop's settings-avatar treatment (`ProfileSettingsCard`): a large centred
/// avatar with a circular badge notched out of its bottom-right corner. Desktop
/// puts an edit-photo pencil in that badge; here it carries the status glyph and
/// opens the status sheet instead. The notch shape — including the fillets where
/// it meets the avatar's edge — comes from [AvatarBadgeMaskGeometry.badge].
class SettingsProfileHeader extends ConsumerWidget {
const SettingsProfileHeader({super.key});
static const _avatarSize = 128.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final profile = ref.watch(profileProvider).asData?.value;
final status = ref.watch(userStatusProvider).asData?.value;
final hasStatus = status != null && !status.isEmpty;
final presence = ref.watch(presenceProvider).value ?? 'offline';
void openStatusSheet() =>
showSetStatusSheet(context, currentStatus: status);
return Padding(
padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.sm),
child: Column(
children: [
MaskedAvatarBadge(
size: _avatarSize,
avatar: ColoredBox(
color: context.colors.primaryContainer,
child: AvatarImageContent(
imageUrl: profile?.avatarUrl,
fallback: Text(
profile?.initial ?? '?',
style: context.textTheme.displaySmall?.copyWith(
color: context.colors.onPrimaryContainer,
),
),
),
),
badge: _StatusBadge(
emoji: status?.emoji ?? '',
onTap: openStatusSheet,
),
),
const SizedBox(height: Grid.twelve),
Text(
profile?.label ?? context.l10n.yourProfile,
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
// Keep the status text visible even when no emoji is set. NIP-38
// permits text-only statuses, which the avatar badge cannot represent.
if (hasStatus)
GestureDetector(
onTap: openStatusSheet,
child: Padding(
padding: const EdgeInsets.only(
top: Grid.quarter,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.half,
),
child: Text(
status.text.isNotEmpty ? status.text : status.emoji,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
_PresencePill(
presence: presence,
onSelected: (nextPresence) => unawaited(
ref.read(presenceProvider.notifier).setPresence(nextPresence),
),
),
],
),
);
}
}
class _PresencePill extends StatelessWidget {
const _PresencePill({required this.presence, required this.onSelected});
final String presence;
final ValueChanged<String> onSelected;
@override
Widget build(BuildContext context) {
final effectivePresence = switch (presence) {
'online' || 'away' => presence,
_ => 'offline',
};
final (backgroundColor, foregroundColor) = switch (effectivePresence) {
'online' => (
context.appColors.success.withValues(alpha: 0.15),
context.appColors.success,
),
'away' => (
context.appColors.warning.withValues(alpha: 0.15),
context.appColors.warning,
),
_ => (
context.colors.onSurfaceVariant.withValues(alpha: 0.15),
context.colors.onSurfaceVariant,
),
};
final label = _presenceLabel(context, effectivePresence);
return Builder(
builder: (buttonContext) => Semantics(
button: true,
label: context.l10n.presenceLabel(label),
child: SizedBox(
key: const ValueKey('settings-presence-target'),
height: Grid.xl,
child: Material(
color: Colors.transparent,
child: InkWell(
key: const ValueKey('settings-presence-menu'),
borderRadius: BorderRadius.circular(Radii.full),
onTap: () async {
final selected = await showAnchoredPopover<String>(
context: buttonContext,
width: 176,
alignment: AnchoredPopoverAlignment.center,
offset: const Offset(0, Grid.half),
menuPadding: const EdgeInsets.symmetric(vertical: Grid.half),
surfaceKey: const ValueKey('settings-presence-popover'),
items: [
for (final option in const ['online', 'away', 'offline'])
PopupMenuItem<String>(
key: ValueKey('settings-presence-$option'),
value: option,
height: Grid.xl,
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _presenceColor(context, option),
shape: BoxShape.circle,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
_presenceLabel(context, option),
style: filterChipTextStyle.copyWith(
color: context.colors.onSurface,
fontWeight: option == effectivePresence
? FontWeight.w500
: FontWeight.w400,
),
),
),
if (option == effectivePresence)
Icon(
LucideIcons.check,
size: 16,
color: context.colors.primary,
),
],
),
),
],
);
if (buttonContext.mounted && selected != null) {
onSelected(selected);
}
},
child: Center(
child: Material(
key: const ValueKey('settings-presence-pill'),
color: backgroundColor,
borderRadius: BorderRadius.circular(Radii.full),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
child: Text(
label,
key: const ValueKey('settings-presence-label'),
style: filterChipTextStyle.copyWith(
color: foregroundColor,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
),
),
),
);
}
}
String _presenceLabel(BuildContext context, String presence) =>
switch (presence) {
'online' => context.l10n.online,
'away' => context.l10n.away,
_ => context.l10n.offline,
};
Color _presenceColor(BuildContext context, String presence) =>
switch (presence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
};
/// Fills the notch left by [MaskedAvatarBadge], so its size comes from the mask
/// geometry rather than being set here.
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.emoji, required this.onTap});
final String emoji;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: context.l10n.setStatusLabel,
child: GestureDetector(
onTap: onTap,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Center(child: _StatusGlyph(emoji: emoji)),
),
),
);
}
}
/// The status emoji, resolving `:shortcode:` values against the community's
/// custom emoji. Falls back to the add-status icon when no status is set.
class _StatusGlyph extends ConsumerWidget {
const _StatusGlyph({required this.emoji});
final String emoji;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (emoji.isEmpty) {
return Icon(
LucideIcons.smilePlus,
size: 20,
color: context.colors.onSurfaceVariant,
);
}
final shortcode = emoji.startsWith(':') && emoji.endsWith(':')
? emoji.substring(1, emoji.length - 1).toLowerCase()
: null;
if (shortcode != null) {
for (final entry in ref.watch(customEmojiListProvider)) {
if (entry.shortcode == shortcode) {
return CustomEmojiImage(
shortcode: shortcode,
url: entry.url,
size: 22,
);
}
}
return Icon(
LucideIcons.smile,
size: 20,
color: context.colors.onSurfaceVariant,
);
}
return Text(emoji, style: const TextStyle(fontSize: 20));
}
}
@@ -0,0 +1,90 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/crypto/nip_oa.dart';
import '../../shared/relay/relay.dart';
import 'user_profile.dart';
/// In-memory cache of user profiles, fetched in batches from the relay.
///
/// Lookups requested via [get] or [preload] are coalesced into a single
/// kind:0 batch query (NIP-01 `authors` filter) every 50ms.
class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
final Set<String> _pending = {};
Timer? _batchTimer;
@override
Map<String, UserProfile> build() {
ref.watch(relayConfigProvider);
ref.onDispose(() {
_batchTimer?.cancel();
_batchTimer = null;
});
return {};
}
/// Request a profile for [pubkey]. Returns immediately from cache if
/// available, otherwise schedules a batch fetch.
UserProfile? get(String pubkey) {
final cached = state[pubkey.toLowerCase()];
if (cached != null) return cached;
_scheduleFetch(pubkey.toLowerCase());
return null;
}
/// Preload profiles for a list of pubkeys (e.g. channel members).
void preload(List<String> pubkeys) {
final uncached = pubkeys
.map((pk) => pk.toLowerCase())
.where((pk) => !state.containsKey(pk) && !_pending.contains(pk))
.toList();
if (uncached.isEmpty) return;
_pending.addAll(uncached);
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
}
void _scheduleFetch(String pubkey) {
if (state.containsKey(pubkey) || _pending.contains(pubkey)) return;
_pending.add(pubkey);
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
}
Future<void> _flushPending() async {
_batchTimer = null;
if (_pending.isEmpty) return;
final pubkeys = _pending.toList();
_pending.clear();
try {
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.profilesBatch(pubkeys),
);
final updated = Map<String, UserProfile>.from(state);
for (final event in events) {
final data = ProfileData.fromEvent(event);
final pk = data.pubkey.toLowerCase();
updated[pk] = UserProfile(
pubkey: pk,
displayName: data.displayName,
avatarUrl: data.avatarUrl,
about: data.about,
nip05Handle: data.nip05,
ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey),
);
}
state = updated;
} catch (_) {
// Silently fail — we'll just show pubkeys.
}
}
}
final userCacheProvider =
NotifierProvider<UserCacheNotifier, Map<String, UserProfile>>(
UserCacheNotifier.new,
);
@@ -0,0 +1,48 @@
import 'package:flutter/foundation.dart';
@immutable
class UserProfile {
final String pubkey;
final String? displayName;
final String? avatarUrl;
final String? about;
final String? nip05Handle;
/// NIP-OA verified owner pubkey from the profile's `auth` tag; non-null
/// means this identity is an agent (mirrors desktop's `ownerPubkey`).
final String? ownerPubkey;
const UserProfile({
required this.pubkey,
this.displayName,
this.avatarUrl,
this.about,
this.nip05Handle,
this.ownerPubkey,
});
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
pubkey: json['pubkey'] as String,
displayName: json['display_name'] as String?,
avatarUrl: json['avatar_url'] as String?,
about: json['about'] as String?,
nip05Handle: json['nip05_handle'] as String?,
);
/// Short label: display name, or first 8 chars of pubkey.
String get label =>
displayName ??
'${pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey}...';
/// First letter for fallback avatar.
String get initial =>
(displayName?.isNotEmpty == true ? displayName! : pubkey)[0]
.toUpperCase();
}
/// Optional profile handle shown beside a message author's display name.
String? messageUsernameLabel(UserProfile? profile) {
final handle = profile?.nip05Handle?.trim();
if (handle != null && handle.isNotEmpty) return handle;
return null;
}
@@ -0,0 +1,472 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/relay/relay.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/utils/string_utils.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channel_management_provider.dart';
import '../channels/message_content.dart';
import 'presence_cache_provider.dart';
import 'user_cache_provider.dart';
import 'user_status_cache_provider.dart';
/// Show a user profile bottom sheet for the given [pubkey].
void showUserProfileSheet(BuildContext context, String pubkey) {
showBuzzModalBottomSheet<Channel>(
context: context,
isScrollControlled: true,
showDragHandle: false,
builder: (_) => UserProfileSheet(pubkey: pubkey),
).then((channel) {
if (channel == null || !context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
);
});
}
class UserProfileSheet extends HookConsumerWidget {
final String pubkey;
const UserProfileSheet({super.key, required this.pubkey});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pk = pubkey.toLowerCase();
final currentPubkey = ref.watch(currentPubkeyProvider);
// Watch cached profile, presence, and user status.
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final presenceMap = ref.watch(presenceCacheProvider);
final presence = presenceMap[pk] ?? 'offline';
final statusCache = ref.watch(userStatusCacheProvider);
final userStatus = statusCache[pk];
// Fetch about from the user's kind:0 profile event.
final aboutFuture = useMemoized(
() => ref
.read(relaySessionProvider.notifier)
.fetchHistory(NostrFilters.profile(pk))
.then((events) {
if (events.isEmpty) return '';
return ProfileData.fromEvent(events.first).about ?? '';
})
.catchError((_) => ''),
[pk],
);
final aboutSnapshot = useFuture(aboutFuture);
final about = aboutSnapshot.data ?? profile?.about ?? '';
// Ensure presence and status are tracked.
useEffect(() {
ref.read(presenceCacheProvider.notifier).track([pk]);
ref.read(userStatusCacheProvider.notifier).track([pk]);
ref.read(userCacheProvider.notifier).preload([pk]);
return null;
}, [pk]);
final copied = useState(false);
final isOpeningDirectMessage = useState(false);
final displayName = profile?.displayName;
final avatarUrl = profile?.avatarUrl;
final nip05 = profile?.nip05Handle;
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
Future<void> copyPublicKey() async {
await Clipboard.setData(ClipboardData(text: pubkey));
if (!context.mounted) return;
copied.value = true;
_showProfileCopyToast(context);
unawaited(
Future<void>.delayed(const Duration(seconds: 2), () {
if (context.mounted) copied.value = false;
}),
);
}
Future<void> openDirectMessage() async {
if (isOpeningDirectMessage.value) return;
isOpeningDirectMessage.value = true;
try {
final channel = await ref
.read(channelActionsProvider)
.openDm(pubkeys: [pk]);
if (!context.mounted) return;
Navigator.of(context).pop(channel);
} catch (_) {
// Silently fail — user tapped but DM open failed.
if (context.mounted) isOpeningDirectMessage.value = false;
}
}
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: Grid.xs),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Center the presence chip on the avatar's lower edge.
Padding(
padding: const EdgeInsets.only(bottom: Grid.xl / 2),
child: Stack(
clipBehavior: Clip.none,
children: [
Align(
alignment: Alignment.center,
child: FractionallySizedBox(
widthFactor: 0.5,
child: _ProfileAvatar(
avatarUrl: avatarUrl,
initial: initial,
),
),
),
Positioned(
left: 0,
right: 0,
bottom: -(Grid.xl / 2),
child: _ProfilePresenceChip(presence: presence),
),
],
),
),
const SizedBox(height: Grid.half),
// Display name — centered, large
Center(
child: Text(
displayName ?? shortPubkey(pubkey),
style: context.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
// Match Settings: status is quiet, centered copy directly
// below the profile name rather than a separate information row.
if (userStatus != null && !userStatus.isEmpty)
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.only(
top: Grid.xxs,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.xxs,
),
child: MessageContent(
content: userStatus.text.isNotEmpty
? '${userStatus.emoji.isNotEmpty ? '${userStatus.emoji} ' : ''}${userStatus.text}'
: userStatus.emoji,
baseStyle: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 2,
),
),
),
// NIP-05 handle — centered, secondary
if (nip05 != null && nip05.isNotEmpty) ...[
const SizedBox(height: Grid.half),
Center(
child: Text(
nip05,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
const SizedBox(height: Grid.xs + Grid.half),
Row(
children: [
if (pk != currentPubkey) ...[
Expanded(
child: _ProfileActionTile(
icon: isOpeningDirectMessage.value
? null
: LucideIcons.messageSquare,
label: isOpeningDirectMessage.value
? context.l10n.openingEllipsis
: context.l10n.messageAction,
isLoading: isOpeningDirectMessage.value,
onTap: openDirectMessage,
),
),
const SizedBox(width: Grid.twelve),
],
Expanded(
child: _ProfileActionTile(
icon: copied.value
? LucideIcons.check
: LucideIcons.key,
label: copied.value
? context.l10n.copied
: context.l10n.copyPublicKey,
onTap: copyPublicKey,
),
),
],
),
const SizedBox(height: Grid.xs),
// About / bio section
if (about.isNotEmpty) ...[
const SizedBox(height: Grid.xxs),
Divider(
color: context.colors.outlineVariant.withValues(
alpha: 0.3,
),
),
const SizedBox(height: Grid.xxs),
Text(
context.l10n.about,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.half),
Text(
about,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
),
),
),
),
],
),
);
}
}
/// Shows clipboard feedback above the bottom-sheet route. A regular SnackBar
/// belongs to the page Scaffold, which sits behind the modal sheet.
void _showProfileCopyToast(BuildContext context) {
final overlay = Overlay.of(context, rootOverlay: true);
final colors = context.colors;
final textStyle = context.textTheme.bodyMedium?.copyWith(
color: colors.onInverseSurface,
);
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (overlayContext) => Positioned(
left: Grid.gutter,
right: Grid.gutter,
bottom: MediaQuery.viewPaddingOf(overlayContext).bottom + Grid.xs,
child: Material(
color: colors.inverseSurface,
elevation: 6,
borderRadius: BorderRadius.circular(Radii.lg),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.twelve,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.check, size: 18, color: colors.onInverseSurface),
const SizedBox(width: Grid.xxs),
Text(context.l10n.publicKeyCopied, style: textStyle),
],
),
),
),
),
);
overlay.insert(entry);
Future<void>.delayed(const Duration(seconds: 2), () {
if (entry.mounted) entry.remove();
});
}
/// The read-only version of the presence control in Settings. A viewed profile
/// reflects its owner's availability but does not allow another user to change
/// it.
class _ProfilePresenceChip extends StatelessWidget {
const _ProfilePresenceChip({required this.presence});
final String presence;
@override
Widget build(BuildContext context) {
final effectivePresence = switch (presence) {
'online' || 'away' => presence,
_ => 'offline',
};
final backgroundColor = switch (effectivePresence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.onSurfaceVariant,
};
final label = _presenceLabel(context, effectivePresence);
return Semantics(
label: context.l10n.presenceLabel(label),
child: SizedBox(
height: Grid.xl,
child: Center(
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(Radii.full),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
child: Text(
label,
style: filterChipTextStyle.copyWith(
color: context.colors.onPrimary,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
);
}
}
class _ProfileActionTile extends StatelessWidget {
const _ProfileActionTile({
required this.icon,
required this.label,
required this.onTap,
this.isLoading = false,
});
final IconData? icon;
final String label;
final VoidCallback onTap;
final bool isLoading;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: isLoading
? null
: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
behavior: HitTestBehavior.opaque,
child: Container(
width: double.infinity,
height: 68 + (Grid.xxs * 2),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isLoading)
BuzzLoadingIndicator(
size: 22,
color: context.colors.onSurface,
semanticLabel: context.l10n.openingDirectMessage,
)
else
Icon(icon, size: 22, color: context.colors.onSurface),
const SizedBox(height: Grid.xxs),
Text(
label,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
);
}
String _presenceLabel(BuildContext context, String presence) =>
switch (presence) {
'online' => context.l10n.online,
'away' => context.l10n.away,
_ => context.l10n.offline,
};
class _ProfileAvatar extends StatelessWidget {
final String? avatarUrl;
final String initial;
const _ProfileAvatar({required this.avatarUrl, required this.initial});
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1,
child: ClipOval(
child: AvatarImageContent(
imageUrl: avatarUrl,
fallback: _AvatarFallback(initial: initial),
),
),
);
}
}
class _AvatarFallback extends StatelessWidget {
final String initial;
const _AvatarFallback({required this.initial});
@override
Widget build(BuildContext context) {
return Container(
color: context.colors.primaryContainer,
alignment: Alignment.center,
child: Text(
initial,
style: context.textTheme.displayLarge?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/foundation.dart';
import '../../shared/relay/nostr_models.dart';
/// A user's NIP-38 status (kind:30315, d=general).
@immutable
class UserStatus {
final String text;
final String emoji;
final int updatedAt;
const UserStatus({
required this.text,
required this.emoji,
required this.updatedAt,
});
factory UserStatus.fromEvent(NostrEvent event) {
final emojiTag = event.tags
.where((t) => t.length >= 2 && t[0] == 'emoji')
.firstOrNull;
return UserStatus(
text: event.content,
emoji: emojiTag?[1] ?? '',
updatedAt: event.createdAt,
);
}
bool get isEmpty => text.isEmpty && emoji.isEmpty;
}
@@ -0,0 +1,180 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import 'user_status.dart';
/// In-memory cache of other users' NIP-38 statuses (kind:30315, d=general).
///
/// Subscribes to kind:30315 events over WebSocket for real-time updates.
/// Falls back to a 120-second backstop refresh via fetchHistory.
class UserStatusCacheNotifier extends Notifier<Map<String, UserStatus?>> {
static const _refreshInterval = Duration(seconds: 120);
final Set<String> _tracked = {};
final Set<String> _pending = {};
Timer? _batchTimer;
Timer? _refreshTimer;
void Function()? _statusUnsub;
int _subscriptionVersion = 0;
@override
Map<String, UserStatus?> build() {
ref.watch(relayClientProvider);
final sessionState = ref.watch(relaySessionProvider);
ref.onDispose(() {
_batchTimer?.cancel();
_batchTimer = null;
_refreshTimer?.cancel();
_refreshTimer = null;
_statusUnsub?.call();
_statusUnsub = null;
});
if (sessionState.status == SessionStatus.connected) {
_subscribeStatusUpdates();
}
return {};
}
/// Track status for [pubkeys]. Fetches immediately if not cached,
/// and includes them in periodic refreshes.
void track(List<String> pubkeys) {
final normalized = pubkeys.map((pk) => pk.toLowerCase()).toList();
final uncached = normalized
.where((pk) => !state.containsKey(pk) && !_pending.contains(pk))
.toList();
_tracked.addAll(normalized);
_ensureRefreshTimer();
if (uncached.isEmpty) return;
_pending.addAll(uncached);
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
}
void _ensureRefreshTimer() {
_refreshTimer ??= Timer.periodic(_refreshInterval, (_) => _refreshAll());
}
Future<void> _subscribeStatusUpdates() async {
_statusUnsub?.call();
_statusUnsub = null;
_subscriptionVersion++;
final version = _subscriptionVersion;
final session = ref.read(relaySessionProvider.notifier);
try {
final unsub = await session.subscribe(
const NostrFilter(
kinds: [EventKind.userStatus],
tags: {
'#d': ['general'],
},
limit: 0,
),
_handleStatusEvent,
);
if (version != _subscriptionVersion) {
unsub();
return;
}
_statusUnsub = unsub;
} catch (error) {
debugPrint(
'[UserStatusCacheNotifier] status subscription failed: $error',
);
}
}
void _handleStatusEvent(NostrEvent event) {
// Defense-in-depth: guard d-tag even though filter includes it.
if (event.getTagValue('d') != 'general') return;
final pubkey = event.pubkey.toLowerCase();
if (!_tracked.contains(pubkey)) return;
final parsed = UserStatus.fromEvent(event);
final existing = state[pubkey];
// Staleness guard: discard if we already have a newer-or-equal event.
if (existing != null && existing.updatedAt >= parsed.updatedAt) return;
final status = parsed.isEmpty ? null : parsed;
final updated = Map<String, UserStatus?>.from(state);
updated[pubkey] = status;
state = updated;
}
/// Directly update a pubkey's cached status. Used by [UserStatusNotifier]
/// for optimistic updates after publishing.
void updateStatus(String pubkey, UserStatus? status) {
final pk = pubkey.toLowerCase();
final updated = Map<String, UserStatus?>.from(state);
updated[pk] = status;
state = updated;
}
Future<void> _refreshAll() async {
if (_tracked.isEmpty) return;
await _fetchStatuses(_tracked.toList());
}
Future<void> _flushPending() async {
_batchTimer = null;
if (_pending.isEmpty) return;
final pubkeys = _pending.toList();
_pending.clear();
await _fetchStatuses(pubkeys);
}
Future<void> _fetchStatuses(List<String> pubkeys) async {
try {
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilter(
kinds: const [EventKind.userStatus],
authors: pubkeys,
tags: const {
'#d': ['general'],
},
limit: pubkeys.length,
),
);
final updated = Map<String, UserStatus?>.from(state);
// Initialise requested pubkeys that had no events to null (cleared).
for (final pk in pubkeys) {
if (!updated.containsKey(pk)) {
updated[pk] = null;
}
}
for (final event in events) {
if (event.getTagValue('d') != 'general') continue;
final pk = event.pubkey.toLowerCase();
final parsed = UserStatus.fromEvent(event);
final existing = updated[pk];
if (existing != null && existing.updatedAt >= parsed.updatedAt) {
continue;
}
updated[pk] = parsed.isEmpty ? null : parsed;
}
state = updated;
} catch (_) {
// Silently fail — backstop will retry.
}
}
}
final userStatusCacheProvider =
NotifierProvider<UserStatusCacheNotifier, Map<String, UserStatus?>>(
UserStatusCacheNotifier.new,
);
@@ -0,0 +1,111 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../shared/relay/relay.dart';
import 'user_status.dart';
import 'user_status_cache_provider.dart';
/// Manages the current user's own NIP-38 status (kind:30315, d=general).
///
/// Fetches the existing status on build and provides [setStatus] / [clearStatus]
/// for publishing. Publishes via WebSocket (triggers fan-out). No heartbeat
/// needed — user status events are parameterised replaceable, not ephemeral.
class UserStatusNotifier extends AsyncNotifier<UserStatus?> {
@override
Future<UserStatus?> build() {
ref.watch(relayClientProvider);
ref.watch(relaySessionProvider);
return _fetch();
}
Future<UserStatus?> _fetch() async {
final config = ref.read(relayConfigProvider);
final nsec = config.nsec;
if (nsec == null || nsec.isEmpty) return null;
String pubkey;
try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
final keyPair = nostr.Keys(privkeyHex);
pubkey = keyPair.public.toLowerCase();
} catch (_) {
return null;
}
final sessionState = ref.read(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return null;
try {
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilter(
kinds: const [EventKind.userStatus],
authors: [pubkey],
tags: const {
'#d': ['general'],
},
limit: 1,
),
);
if (events.isEmpty) return null;
// Pick the most recent event.
final latest = events.reduce(
(a, b) => a.createdAt >= b.createdAt ? a : b,
);
final status = UserStatus.fromEvent(latest);
return status.isEmpty ? null : status;
} catch (_) {
return null;
}
}
Future<void> setStatus(String text, String emoji) async {
final trimmed = text.trim();
final config = ref.read(relayConfigProvider);
final nsec = config.nsec;
if (nsec == null || nsec.isEmpty) return;
final tags = <List<String>>[
['d', 'general'],
];
if (emoji.isNotEmpty) {
tags.add(['emoji', emoji]);
}
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
final event = nostr.Event.from(
kind: EventKind.userStatus,
content: trimmed,
tags: tags,
secretKey: privkeyHex,
verify: false,
);
final session = ref.read(relaySessionProvider.notifier);
await session.publish(NostrEvent.fromJson(event.toMap()));
// Optimistic update: update own state immediately.
final newStatus = (trimmed.isNotEmpty || emoji.isNotEmpty)
? UserStatus(
text: trimmed,
emoji: emoji,
updatedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
)
: null;
state = AsyncValue.data(newStatus);
// Also update the shared cache so other UI reads stay consistent.
final keyPair = nostr.Keys(privkeyHex);
final pubkey = keyPair.public.toLowerCase();
ref.read(userStatusCacheProvider.notifier).updateStatus(pubkey, newStatus);
}
Future<void> clearStatus() => setStatus('', '');
}
final userStatusProvider =
AsyncNotifierProvider<UserStatusNotifier, UserStatus?>(
UserStatusNotifier.new,
);