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