mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
<!-- Draft PR body for #7382 · fix(mobile): hydrate and refresh relay presence snapshots · https://github.com/block/buzz/pull/7382 --> 🤖 ## Summary Mobile showed every person in a DM as **Offline** until they happened to publish a presence update while the app was connected. Presence was never loaded when the app opened, never refreshed, and a missing or failed read looked identical to actually being offline. This PR makes presence load and stay current: on connect or foreground, the app fetches the relay's presence snapshot for everyone visible, keeps the live subscription open while it does, and re-checks every 60 seconds while the app is in the foreground. A missing or failed read shows **Unknown** (no dot), not Offline. - Snapshot reads are authenticated like the rest of the session and batched by exact key (at most 100 per batch), instead of relying on live events only. - The live subscription opens before the snapshot, so updates published during the read are not dropped. Per-key revisions fence stale **query completions**: a snapshot that finishes after newer live evidence for that key does not overwrite it. - Refreshing every 60 foreground seconds observes relay lease expiry and retry failures; missing or failed evidence simply stays absent — it is never reported as Offline. - Disconnects, backgrounding, and account/community changes reset the cache cleanly. The app's existing subscription/recovery machinery is reused rather than duplicated. Mirrors Desktop's presence hydration (`desktop/src-tauri/src/commands/profile.rs::get_presence`, `desktop/src/features/presence/hooks.ts`). ### Presence contract: best effort between polls Presence is a best-effort estimate that the next foreground poll corrects. This PR does not give strict causal order between snapshots and live events: - Snapshots carry relay time. Live kind:20001 events carry the signer's time. These clocks are not comparable. - A live event at or below a snapshot's relay second is not applied directly. The client confirms it with a fresh snapshot. This works because the relay persists presence before fan-out. - An empty (all-Offline) snapshot carries no relay second. If the relay stored an Online event, then its fan-out was delayed past lease expiry and an empty snapshot, the delayed event can show Online/Away until the next 60-second poll. Base `main` has the same stale-delivery exposure with no correction at all. Strict ordering needs relay-issued ordering metadata on both paths, or a fresh confirmation for every live event. Both are protocol or latency tradeoffs and are out of scope here. ### Related issue - Fixes: N/A. Closest open duplicates: #4417 (larger presence bootstrap + UI patch), #5617. Related: #6200, #7295. - First slice of the mobile remote-agent parity port. This PR includes the Unknown rendering at all three consumers (DM header, channel tile, profile sheet). #7383 and #7526 stack on it and add test coverage only. ### Testing - Rebased on `main` `95b018c87` (2026-09-30). At stack tip `7c8a93081`: `just mobile-check` clean; full `just mobile-test` **2,593 passed**. Earlier receipts below apply to the pre-rebase head. - Regressions cover cold-open hydration, live/snapshot races, lease expiry, failed reads and retries, disconnect/background/scope changes, and >100-key batching. - At the branch head: `just mobile-check` and the full mobile test suite pass independently, and every broader CI gate has successful evidence after isolated recovery runs (no monolithic local `just ci` run) — receipts in the [exact-head evidence comment](https://github.com/block/buzz/pull/7382#issuecomment-5573112882). - Verification is automated-test level; no native device or simulator run is claimed. No layout change in this slice. ### Screenshots No layout change in this slice — the renders below show the same existing profile sheet, with the presence status now following hydrated relay data. Flutter production-widget test renders — not native-device screenshots or acceptance captures. | Scenario | Before | After | |---|---|---| | Existing profile opened cold, with a signed relay snapshot saying Online |  |  | <details> <summary>Capture provenance</summary> Rendered by the Flutter widget engine in a `flutter test` run (production widgets, production theme; no device or simulator). Before: this PR's declared base `3c7f288c60d67df78577b237e27c3dfc8831aaa1`. After: its head `a8e8028bfb9bb67de2db03ed448839e2f3ab2b0d`. </details> Signed-off-by: Logan Johnson <loganj@squareup.com>
483 lines
17 KiB
Dart
483 lines
17 KiB
Dart
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:hooks_riverpod/misc.dart' show ProviderListenable;
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../../shared/animated_avatar.dart';
|
|
import '../../shared/identity_names/identity_names.dart';
|
|
import '../../shared/identity_names/identity_names_provider.dart';
|
|
import '../../shared/relay/relay.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/utils/string_utils.dart';
|
|
import '../../shared/widgets/avatar_image.dart';
|
|
import '../../shared/widgets/buzz_action_tile.dart';
|
|
import '../../shared/widgets/modal_presentation.dart';
|
|
import '../../shared/widgets/progressive_animated_avatar.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 '../../shared/profile/user_cache_provider.dart';
|
|
import 'user_status_cache_provider.dart';
|
|
|
|
/// Show a user profile bottom sheet for the given [pubkey].
|
|
///
|
|
/// The sheet names [pubkey] as the opening surface did: within the live
|
|
/// comparison context [names] (for example a channel's members or a Pulse
|
|
/// timeline). Without it, the identity is compared only with itself.
|
|
void showUserProfileSheet(
|
|
BuildContext context,
|
|
String pubkey, {
|
|
ProviderListenable<IdentityNames>? names,
|
|
}) {
|
|
showBuzzModalBottomSheet<Channel>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: false,
|
|
builder: (_) => UserProfileSheet(pubkey: pubkey, names: names),
|
|
).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;
|
|
|
|
/// The opening surface's comparison context. The sheet watches it, so its
|
|
/// title follows profile and context changes while it is open. The caller
|
|
/// owns the context, so this sheet depends on no other feature's state.
|
|
final ProviderListenable<IdentityNames>? names;
|
|
|
|
const UserProfileSheet({super.key, required this.pubkey, this.names});
|
|
|
|
@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];
|
|
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);
|
|
|
|
// Canonical npub for the copy action; null when [pubkey] is not a valid
|
|
// identity, in which case the copy tile is disabled — an invalid key is
|
|
// never placed on the clipboard.
|
|
final npub = fullNpub(pubkey);
|
|
|
|
// The contextual label from the opening surface, so the sheet names the
|
|
// identity exactly as the row that was tapped.
|
|
final opener = names;
|
|
final displayName =
|
|
(opener != null ? ref.watch(opener) : watchIdentityNames(ref, {pk}))
|
|
.labelFor(pk);
|
|
final avatarUrl = profile?.avatarUrl;
|
|
final nip05 = profile?.nip05Handle;
|
|
final initial =
|
|
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
|
|
|
|
Future<void> copyPublicKey() async {
|
|
if (npub == null) return;
|
|
await Clipboard.setData(ClipboardData(text: npub));
|
|
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,
|
|
isAgent: profile?.isAgent == true,
|
|
),
|
|
),
|
|
),
|
|
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,
|
|
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: BuzzActionTile(
|
|
icon: isOpeningDirectMessage.value
|
|
? null
|
|
: LucideIcons.messageSquare,
|
|
label: isOpeningDirectMessage.value
|
|
? 'Opening…'
|
|
: 'Message',
|
|
isLoading: isOpeningDirectMessage.value,
|
|
loadingSemanticLabel: 'Opening direct message',
|
|
onTap: openDirectMessage,
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.twelve),
|
|
],
|
|
Expanded(
|
|
child: BuzzActionTile(
|
|
icon: copied.value
|
|
? LucideIcons.check
|
|
: LucideIcons.key,
|
|
label: copied.value ? 'Copied' : 'Copy public key',
|
|
isEnabled: npub != null,
|
|
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(
|
|
'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('Public key copied', 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' || 'offline' => presence,
|
|
_ => null,
|
|
};
|
|
final backgroundColor = switch (effectivePresence) {
|
|
'online' => context.appColors.success,
|
|
'away' => context.appColors.warning,
|
|
_ => context.colors.onSurfaceVariant,
|
|
};
|
|
final label = switch (effectivePresence) {
|
|
'online' => 'Online',
|
|
'away' => 'Away',
|
|
'offline' => 'Offline',
|
|
_ => 'Unknown',
|
|
};
|
|
|
|
return Semantics(
|
|
label: 'Presence: $label',
|
|
excludeSemantics: true,
|
|
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 _ProfileAvatar extends HookWidget {
|
|
final String? avatarUrl;
|
|
final String initial;
|
|
final bool isAgent;
|
|
|
|
const _ProfileAvatar({
|
|
required this.avatarUrl,
|
|
required this.initial,
|
|
required this.isAgent,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final animatedAvatar = parseAnimatedAvatarUrl(avatarUrl);
|
|
final stoppedAnimationUrl = useState<String?>(null);
|
|
final isPlaying =
|
|
animatedAvatar != null &&
|
|
stoppedAnimationUrl.value != animatedAvatar.animationUrl;
|
|
|
|
return AspectRatio(
|
|
aspectRatio: 1,
|
|
child: GestureDetector(
|
|
key: const ValueKey('selected-profile-avatar'),
|
|
onTap: animatedAvatar == null
|
|
? null
|
|
: () => stoppedAnimationUrl.value =
|
|
stoppedAnimationUrl.value == animatedAvatar.animationUrl
|
|
? null
|
|
: animatedAvatar.animationUrl,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final avatar = isPlaying
|
|
? ProgressiveAnimatedAvatar(
|
|
key: ValueKey(animatedAvatar.animationUrl),
|
|
descriptor: animatedAvatar,
|
|
fallback: _AvatarFallback(initial: initial),
|
|
)
|
|
: AvatarImageContent(
|
|
imageUrl: animatedAvatar?.posterUrl ?? avatarUrl,
|
|
fallback: _AvatarFallback(initial: initial),
|
|
);
|
|
if (!isAgent) return ClipOval(child: avatar);
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(
|
|
constraints.biggest.shortestSide * 0.3,
|
|
),
|
|
child: avatar,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|