mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
## Summary One identity can be paired with several communities, but mobile keyed several caches by pubkey only. Mobile keeps a single `ProviderScope` and doesn't remount on a community switch, so one community's data showed up in another, and some of it was published to the other community's relay. - **Channel groups**: the second community showed the first community's groups. - **Read marks** (contexts, publishable set, NIP-RS client and slot IDs): the first community's context IDs were merged into, and published to, the second community's read-state slot. - **Inbox done/unread rows and thread follows**: one shared 500-entry cap, so activity in one community evicted another's entries. - **Channel stars and mutes**: union-merged across communities and published to every relay. Every one of these is now keyed by pubkey + normalized community relay, the same way channel sort already is. **Switching communities.** The relay session rebuilds for the new community before the previous community's managers are retired. Each manager (groups, read state, stars, mutes) now checks that the session still serves its own community before it publishes, merges a fetch, or handles a live event. Previously, a pending publish flushed on dispose, or an in-flight fetch, could cross into the next community. **Old pubkey-only entries.** Channel IDs can repeat across communities, so nothing tells us which community an old channel-keyed entry belongs to: - **Groups, stars, mutes and read marks** are dropped on upgrade, and each community restores its own from its relay. Any change that never reached a relay is lost, and a read mark the relay doesn't hold shows as unread once. That's an accepted tradeoff: such changes would be very old by now. - **Inbox, thread follows, and read-state slot and client IDs** seed every community, and the old entry is kept. The inbox and follows entries are event IDs, which only ever match their own community. The slot ID keeps each community publishing to the slot it already has on its relay. This PR also removes the **Pulse** feed (`features/pulse/` and the Nostr filters only it used). Nothing in the app navigates to it anymore, and its providers were among the community-unscoped caches. ### Related issue None found. ### Testing - Each affected store has a regression test through the real provider that switches A → B → A. For groups, read marks, stars and mutes, the test sets a value in A, switches to B while A's change is still pending, and checks two cases: B's data arriving live and B answering A's in-flight fetch. It asserts that nothing sent to B contains A's IDs, B shows only its own value, and A comes back unchanged. Each guard was removed in turn to confirm a test fails without it, except two that the current switch order never reaches: the groups merge guard and the read-state live-event guard. - Upgrade tests cover each kind of old entry: dropped for groups, stars, mutes and read marks, seeded for inbox, follows and slot IDs. - `flutter analyze` is clean, and the full mobile suite passes in the pre-push hook at `89ebfcc12`. - Not yet exercised in the app. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
489 lines
17 KiB
Dart
489 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:buzz/shared/theme/buzz_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). Without it,
|
|
/// the identity is compared only with itself.
|
|
void showUserProfileSheet(
|
|
BuildContext context,
|
|
String pubkey, {
|
|
ProviderListenable<IdentityNames>? names,
|
|
WidgetBuilder? contextualActions,
|
|
}) {
|
|
showBuzzModalBottomSheet<Channel>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: false,
|
|
builder: (_) => UserProfileSheet(
|
|
pubkey: pubkey,
|
|
names: names,
|
|
contextualActions: contextualActions,
|
|
),
|
|
).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;
|
|
|
|
/// Optional actions supplied by the opening surface, below the profile tiles.
|
|
final WidgetBuilder? contextualActions;
|
|
|
|
const UserProfileSheet({
|
|
super.key,
|
|
required this.pubkey,
|
|
this.names,
|
|
this.contextualActions,
|
|
});
|
|
|
|
@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);
|
|
|
|
// Keep the opening surface's comparison context and qualified identity.
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
if (about.trim().isNotEmpty) ...[
|
|
const SizedBox(height: Grid.xxs),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: Text(
|
|
about,
|
|
textAlign: TextAlign.center,
|
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
// Keep the current status centered beneath the profile identity.
|
|
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
|
|
: BuzzIcons.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
|
|
? BuzzIcons.check
|
|
: BuzzIcons.key,
|
|
label: copied.value ? 'Copied' : 'Copy public key',
|
|
isEnabled: npub != null,
|
|
onTap: copyPublicKey,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: Grid.xs),
|
|
if (contextualActions != null)
|
|
Builder(builder: contextualActions!),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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(BuzzIcons.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: !usesInsetAgentArtwork(avatarUrl)
|
|
? avatar
|
|
: Transform.scale(
|
|
scale: animatedAvatarArtworkScale,
|
|
child: avatar,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AvatarFallback extends StatelessWidget {
|
|
final String initial;
|
|
|
|
const _AvatarFallback({required this.initial});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
color: context.controlSurface,
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
initial,
|
|
style: context.textTheme.displayLarge?.copyWith(
|
|
color: context.colors.onPrimaryContainer,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|