Polish mobile profiles, DMs, and sheets (#5401)

## Summary
- add poster-first, tap-to-toggle animated avatars on profile surfaces
while preserving transparent/static behavior elsewhere
- align mobile DM headers, membership actions, and invisible agent
recipient addressing with established desktop semantics
- polish titled sheets and status editing, preserve native iOS sheet
corners, and batch relay reads to improve review-build responsiveness

## Snapshots

<table>
  <tr>
    <th>Profile avatar</th>
    <th>Agent DM header and composer</th>
  </tr>
  <tr>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--profile-avatar.png"
width="360" alt="Mobile profile settings with animated avatar
surface"></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--agent-dm.png"
width="360" alt="Agent direct message with masked presence and normal
composer"></td>
  </tr>
  <tr>
    <th>Members sheet</th>
    <th>Status editor</th>
  </tr>
  <tr>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--members-sheet.png"
width="360" alt="Members bottom sheet with centered title and padded
content"></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--status-sheet.png"
width="360" alt="Status editor bottom sheet with duration and quick
statuses"></td>
  </tr>
  <tr>
    <th colspan="2">Switch Community</th>
  </tr>
  <tr>
<td colspan="2" align="center"><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--switch-community.png"
width="720" alt="Switch Community bottom sheet with centered title and
aligned Edit action"></td>
  </tr>
</table>

## Validation
- `just mobile-check`
- `just mobile-test` (1,283 tests)
- installed and reviewed isolated debug builds on iPhone and Pixel

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-14 04:16:04 +01:00
committed by GitHub
parent 5743562896
commit b30f1f6129
52 changed files with 3432 additions and 724 deletions

View File

@@ -72,23 +72,28 @@ class _InboxRow extends HookConsumerWidget {
final revealAmount = useState(0.0);
final isDragging = useState(false);
final labelHapticFired = useRef(false);
final userCache = ref.watch(userCacheProvider);
final profile = userCache[item.item.pubkey.toLowerCase()];
final senderPubkey = item.item.pubkey.toLowerCase();
final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags);
final relevantPubkeys = {senderPubkey, ...mentionPubkeys};
final profiles = <String, UserProfile?>{
for (final pubkey in relevantPubkeys)
pubkey: ref.watch(userCacheProvider.select((cache) => cache[pubkey])),
};
final profile = profiles[senderPubkey];
final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey);
final profileMentionNames = {
for (final pubkey in mentionedPubkeysFromTags(item.item.tags))
if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true)
pubkey: userCache[pubkey]!.displayName!.trim(),
for (final pubkey in mentionPubkeys)
if (profiles[pubkey]?.displayName?.trim().isNotEmpty == true)
pubkey: profiles[pubkey]!.displayName!.trim(),
};
final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags);
final knownAgentPubkeys = channel == null
? ref.watch(knownAgentPubkeysProvider)
: ref.watch(agentMentionPubkeysProvider(channel!.id));
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: knownAgentPubkeys,
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
for (final pubkey in mentionPubkeys)
if (profiles[pubkey]?.ownerPubkey != null) pubkey,
],
);
final mentionNames = mentionNamesWithDirectoryLabels(

View File

@@ -214,48 +214,55 @@ class ActivityNotifier extends AsyncNotifier<HomeFeedResponse> {
if (channel.isDm && channel.isMember) channel.id,
];
final results = await Future.wait([
final filters = <NostrFilter>[
// Mentions of me on user-visible channel content.
session.fetchHistory(
NostrFilter(
kinds: const [9, 40002, 1, 45001, 45003],
tags: {
'#p': [myPk],
},
limit: 50,
),
NostrFilter(
kinds: const [9, 40002, 1, 45001, 45003],
tags: {
'#p': [myPk],
},
limit: 50,
),
// Workflow approvals addressed to me.
session.fetchHistory(
NostrFilter(
kinds: const [46010, 46011, 46012],
tags: {
'#p': [myPk],
},
limit: 20,
),
NostrFilter(
kinds: const [46010, 46011, 46012],
tags: {
'#p': [myPk],
},
limit: 20,
),
// Agent job lifecycle events addressed to me.
session.fetchHistory(
NostrFilter(
kinds: const [43001, 43002, 43003, 43004, 43005, 43006],
tags: {
'#p': [myPk],
},
limit: 20,
),
NostrFilter(
kinds: const [43001, 43002, 43003, 43004, 43005, 43006],
tags: {
'#p': [myPk],
},
limit: 20,
),
// Recent DM traffic (filtered to other senders below).
if (dmChannelIds.isEmpty)
Future.value(const <NostrEvent>[])
else
session.fetchHistory(
NostrFilter(kinds: const [9], tags: {'#h': dmChannelIds}, limit: 30),
),
]);
if (dmChannelIds.isNotEmpty)
NostrFilter(kinds: const [9], tags: {'#h': dmChannelIds}, limit: 30),
];
// The HTTP bridge keeps each NIP-01 filter's independent limit while
// executing the batch with bounded server-side concurrency. One request
// here replaces the four simultaneous websocket history subscriptions that
// otherwise compete with channel and preference startup sync.
final events = await _queryWithWebSocketFallback(session, filters);
bool isFromOther(NostrEvent e) =>
e.pubkey.toLowerCase() != myPk.toLowerCase();
bool isAddressedToMe(NostrEvent event) => event.tags.any(
(tag) =>
tag.length > 1 &&
tag[0] == 'p' &&
tag[1].toLowerCase() == myPk.toLowerCase(),
);
const mentionKinds = {9, 40002, 1, 45001, 45003};
const needsActionKinds = {46010, 46011, 46012};
const agentActivityKinds = {43001, 43002, 43003, 43004, 43005, 43006};
final dmChannelIdSet = dmChannelIds.toSet();
// Dedupe across sources by event id, keeping the higher-priority
// category (needs_action > mention > agent_activity > activity).
@@ -271,10 +278,38 @@ class ActivityNotifier extends AsyncNotifier<HomeFeedResponse> {
}
}
add(results[1], 'needs_action');
add(results[0].where(isFromOther), 'mention');
add(results[2], 'agent_activity');
add(results[3].where(isFromOther), 'activity');
add(
events.where(
(event) =>
needsActionKinds.contains(event.kind) && isAddressedToMe(event),
),
'needs_action',
);
add(
events.where(
(event) =>
mentionKinds.contains(event.kind) &&
isAddressedToMe(event) &&
isFromOther(event),
),
'mention',
);
add(
events.where(
(event) =>
agentActivityKinds.contains(event.kind) && isAddressedToMe(event),
),
'agent_activity',
);
add(
events.where(
(event) =>
event.kind == 9 &&
dmChannelIdSet.contains(event.channelId) &&
isFromOther(event),
),
'activity',
);
final items = byId.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
@@ -299,6 +334,41 @@ class ActivityNotifier extends AsyncNotifier<HomeFeedResponse> {
);
}
Future<List<NostrEvent>> _queryWithWebSocketFallback(
RelaySessionNotifier session,
List<NostrFilter> filters,
) async {
try {
return await session.queryRelay(filters);
} catch (error) {
debugPrint(
'[ActivityNotifier] batched history query failed; '
'using bounded websocket fallback: $error',
);
}
const fallbackConcurrency = 4;
final events = <NostrEvent>[];
for (var start = 0; start < filters.length; start += fallbackConcurrency) {
final end = start + fallbackConcurrency < filters.length
? start + fallbackConcurrency
: filters.length;
final results = await Future.wait(
filters.sublist(start, end).map((filter) async {
try {
return await session.fetchHistory(filter);
} catch (_) {
return const <NostrEvent>[];
}
}),
);
for (final result in results) {
events.addAll(result);
}
}
return events;
}
FeedItem _feedItem(NostrEvent event, {required String category}) {
return FeedItem(
id: event.id,

View File

@@ -200,6 +200,7 @@ class ChannelActionsSheet extends ConsumerWidget {
onTap: () async {
final shouldClose = await showBuzzModalBottomSheet<bool>(
context: context,
title: 'Manage channel',
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(

View File

@@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/animated_avatar.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
@@ -17,6 +18,7 @@ import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../../shared/widgets/masked_avatar_badge.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/skeleton.dart';
@@ -338,11 +340,12 @@ class ChannelDetailPage extends HookConsumerWidget {
],
),
actions: [
_MembersButton(
channelId: resolvedChannel.id,
channel: resolvedChannel,
currentPubkey: currentPubkey,
),
if (_showsMembersAction(resolvedChannel))
_MembersButton(
channelId: resolvedChannel.id,
channel: resolvedChannel,
currentPubkey: currentPubkey,
),
IconButton(
color: context.colors.primary,
onPressed: () async {
@@ -524,6 +527,7 @@ class ChannelDetailPage extends HookConsumerWidget {
channelId: channel.id,
content: content,
mentionPubkeys: mentionPubkeys,
channel: resolvedChannel,
mediaTags: mediaTags,
),
),

View File

@@ -1,5 +1,16 @@
part of '../channel_detail_page.dart';
const _dmHeaderAvatarSize = 32.0;
const _dmPresenceDotRatio = 8 / 14;
bool _showsMembersAction(Channel channel) {
if (!channel.isDm) return true;
final participants = channel.participantPubkeys
.map((pubkey) => pubkey.toLowerCase())
.toSet();
return participants.length != 2;
}
double _scaledTextHeight(BuildContext context, TextStyle style) {
final scaledFontSize = MediaQuery.textScalerOf(
context,
@@ -8,15 +19,15 @@ double _scaledTextHeight(BuildContext context, TextStyle style) {
}
double _dmAppBarTitleContentHeight(BuildContext context) {
const titleStyle = channelTitleTextStyle;
final presenceStyle = context.textTheme.bodySmall;
if (presenceStyle == null) {
return 30;
final titleStyle = context.textTheme.titleSmall;
final presenceStyle = context.textTheme.bodyMedium;
if (titleStyle == null || presenceStyle == null) {
return _dmHeaderAvatarSize;
}
final textHeight =
_scaledTextHeight(context, titleStyle) +
_scaledTextHeight(context, presenceStyle);
return textHeight > 30 ? textHeight : 30;
return textHeight > _dmHeaderAvatarSize ? textHeight : _dmHeaderAvatarSize;
}
class _MembersButton extends ConsumerWidget {
@@ -41,6 +52,7 @@ class _MembersButton extends ConsumerWidget {
onPressed: () {
showBuzzModalBottomSheet<void>(
context: context,
title: 'Members',
isScrollControlled: true,
showDragHandle: true,
builder: (_) =>
@@ -80,8 +92,6 @@ class _DmAppBarTitle extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final profiles = ref.watch(userCacheProvider);
final presenceMap = ref.watch(presenceCacheProvider);
final normalizedCurrent = currentPubkey?.toLowerCase();
String? otherPubkey;
@@ -92,7 +102,18 @@ class _DmAppBarTitle extends ConsumerWidget {
}
}
final profile = otherPubkey != null ? profiles[otherPubkey] : null;
final profile = ref.watch(
userCacheProvider.select(
(profiles) => otherPubkey == null ? null : profiles[otherPubkey],
),
);
final presence = ref.watch(
presenceCacheProvider.select(
(presenceMap) => otherPubkey == null
? 'offline'
: (presenceMap[otherPubkey] ?? 'offline'),
),
);
if (otherPubkey != null) {
if (profile == null) {
@@ -102,14 +123,12 @@ class _DmAppBarTitle extends ConsumerWidget {
}
final avatarUrl = profile?.avatarUrl;
final animatedAvatar = parseAnimatedAvatarUrl(avatarUrl);
final initial =
profile?.initial ??
(channel.participants.isNotEmpty
? channel.participants.first[0].toUpperCase()
: '?');
final presence = otherPubkey != null
? (presenceMap[otherPubkey] ?? 'offline')
: 'offline';
final presenceLabel = switch (presence) {
'online' => 'Online',
'away' => 'Away',
@@ -118,16 +137,17 @@ class _DmAppBarTitle extends ConsumerWidget {
return Row(
children: [
SizedBox(
width: 30,
height: 30,
child: Stack(
clipBehavior: Clip.none,
children: [
AvatarImage(
imageUrl: avatarUrl,
radius: 14,
backgroundColor: context.colors.primaryContainer,
MaskedAvatarBadge(
key: const ValueKey('dm-header-avatar'),
size: _dmHeaderAvatarSize,
geometry: AvatarBadgeMaskGeometry.presenceDot,
avatar: ClipOval(
child: ColoredBox(
color: animatedAvatar == null
? context.colors.primaryContainer
: Colors.transparent,
child: AvatarImageContent(
imageUrl: animatedAvatar?.posterUrl ?? avatarUrl,
fallback: Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
@@ -136,27 +156,23 @@ class _DmAppBarTitle extends ConsumerWidget {
),
),
),
Positioned(
right: -1,
bottom: -1,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: switch (presence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
},
shape: BoxShape.circle,
border: Border.all(
color: context.colors.surface,
width: 1.5,
),
),
),
),
badge: Center(
child: FractionallySizedBox(
widthFactor: _dmPresenceDotRatio,
heightFactor: _dmPresenceDotRatio,
child: DecoratedBox(
decoration: BoxDecoration(
color: switch (presence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
},
shape: BoxShape.circle,
),
),
],
),
),
),
const SizedBox(width: Grid.xxs),
@@ -176,7 +192,8 @@ class _DmAppBarTitle extends ConsumerWidget {
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: channelTitleTextStyle,
key: const ValueKey('dm-header-name'),
style: context.textTheme.titleSmall,
),
),
if (channel.isEphemeral) ...[
@@ -187,7 +204,8 @@ class _DmAppBarTitle extends ConsumerWidget {
),
Text(
presenceLabel,
style: context.textTheme.bodySmall?.copyWith(
key: const ValueKey('dm-header-presence'),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),

View File

@@ -34,22 +34,30 @@ class _MessageBubble extends ConsumerWidget {
(profile?.ownerPubkey != null &&
profile?.ownerPubkey == currentPubkey?.toLowerCase());
// Build mention names map from event p-tags.
final userCache = ref.watch(userCacheProvider);
// Watch only profiles referenced by this message. A batched profile fetch
// should not rebuild every visible message just because an unrelated user
// was added to the shared cache.
final normalizedMentionPubkeys = {
for (final pubkey in message.mentionPubkeys) pubkey.toLowerCase(),
};
final mentionProfiles = <String, UserProfile?>{
for (final pubkey in normalizedMentionPubkeys)
pubkey: ref.watch(userCacheProvider.select((cache) => cache[pubkey])),
};
final knownAgentPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(
agentMentionPubkeysProvider(currentChannelId),
),
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
for (final entry in mentionProfiles.entries)
if (entry.value?.ownerPubkey != null) entry.key,
],
);
final mentionNames = <String, String>{};
final agentMentionPubkeys = <String>{};
for (final mpk in message.mentionPubkeys) {
final normalizedPubkey = mpk.toLowerCase();
final p = userCache[normalizedPubkey];
final p = mentionProfiles[normalizedPubkey];
if (p?.displayName != null) {
mentionNames[normalizedPubkey] = p!.displayName!;
}

View File

@@ -16,10 +16,19 @@ class ChannelTypingIndicator extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userCache = ref.watch(userCacheProvider);
final normalizedPubkeys = {
for (final entry in entries) entry.pubkey.toLowerCase(),
};
final profiles = {
for (final pubkey in normalizedPubkeys)
pubkey: ref.watch(userCacheProvider.select((cache) => cache[pubkey])),
};
final userCache = {
for (final entry in profiles.entries) entry.key: ?entry.value,
};
final names = entries.map((entry) {
final profile =
userCache[entry.pubkey.toLowerCase()] ??
profiles[entry.pubkey.toLowerCase()] ??
ref.read(userCacheProvider.notifier).get(entry.pubkey.toLowerCase());
return profile?.label ?? shortPubkey(entry.pubkey);
}).toList();

View File

@@ -19,6 +19,7 @@ import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/bee_refresh_indicator.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/buzz_titled_sheet_layout.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/modal_presentation.dart';
@@ -303,7 +304,8 @@ class ChannelsPage extends HookConsumerWidget {
ref.invalidate(communityIconProvider);
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
showCloseButton: false,
showDragHandle: false,
builder: (_) => const _CommunitySwitcherSheet(),
);
}

View File

@@ -137,8 +137,6 @@ class _DmAvatar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final profiles = ref.watch(userCacheProvider);
final presenceMap = ref.watch(presenceCacheProvider);
final normalizedCurrent = currentPubkey?.toLowerCase();
final otherPubkeys = [
for (final pk in channel.participantPubkeys)
@@ -171,7 +169,18 @@ class _DmAvatar extends ConsumerWidget {
}
final otherPubkey = visiblePubkeys.isNotEmpty ? visiblePubkeys.first : null;
final profile = otherPubkey != null ? profiles[otherPubkey] : null;
final profile = ref.watch(
userCacheProvider.select(
(profiles) => otherPubkey == null ? null : profiles[otherPubkey],
),
);
final presence = ref.watch(
presenceCacheProvider.select(
(presenceMap) => otherPubkey == null
? 'offline'
: (presenceMap[otherPubkey] ?? 'offline'),
),
);
// Trigger fetches if not cached yet.
if (otherPubkey != null) {
@@ -187,10 +196,6 @@ class _DmAvatar extends ConsumerWidget {
(channel.participants.isNotEmpty
? channel.participants.first[0].toUpperCase()
: '?');
final presence = otherPubkey != null
? (presenceMap[otherPubkey] ?? 'offline')
: 'offline';
return SizedBox(
width: _kDmAvatarSize,
height: _kDmAvatarSize,

View File

@@ -10,149 +10,109 @@ class _CommunitySwitcherSheet extends HookConsumerWidget {
final isEditing = useState(false);
return SafeArea(
child: Column(
child: BuzzTitledSheetLayout(
key: const Key('community-switcher-sheet'),
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.xxs,
title: 'Switch Community',
titleKey: const Key('community-switcher-title'),
showDragHandle: true,
trailing: SizedBox(
key: const Key('community-switcher-edit'),
width: 56,
height: 44,
child: TextButton(
onPressed: () => isEditing.value = !isEditing.value,
style: TextButton.styleFrom(
minimumSize: const Size(56, 44),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 32),
child: Row(
children: [
Expanded(
child: Text(
'Switch Community',
key: const Key('community-switcher-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Padding(
padding: const EdgeInsets.only(right: Grid.half),
child: ConstrainedBox(
key: const Key('community-switcher-edit'),
constraints: const BoxConstraints(
minWidth: 48,
minHeight: 32,
),
child: TextButton(
onPressed: () => isEditing.value = !isEditing.value,
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: Grid.half,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(isEditing.value ? 'Done' : 'Edit'),
),
),
),
],
child: Text(isEditing.value ? 'Done' : 'Edit'),
),
),
child: communitiesAsync.when(
loading: () => const SizedBox(
height: 120,
child: Center(
child: BuzzLoadingIndicator(
size: 40,
semanticLabel: 'Loading communities',
),
),
),
Flexible(
child: communitiesAsync.when(
loading: () => const SizedBox(
height: 120,
child: Center(
child: BuzzLoadingIndicator(
size: 40,
semanticLabel: 'Loading communities',
error: (e, _) => Padding(
padding: const EdgeInsets.all(Grid.xs),
child: Text('Error loading communities: $e'),
),
data: (communities) {
final activeId = activeAsync.value?.id;
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: Grid.xs),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Material(
key: const Key('community-switcher-options'),
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.card),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (
var index = 0;
index < communities.length;
index++
) ...[
if (index > 0) const _CommunitySwitcherDivider(),
_CommunitySwitcherTile(
community: communities[index],
isActive: communities[index].id == activeId,
isEditing: isEditing.value,
onTap: isEditing.value
? null
: () async {
final community = communities[index];
if (community.id != activeId) {
await ref
.read(communityListProvider.notifier)
.switchCommunity(community.id);
}
if (context.mounted) {
Navigator.of(context).pop();
}
},
onRemove: () => _confirmRemoveCommunity(
context,
ref,
communities[index],
closeSheetAfterRemoval:
communities[index].id == activeId,
),
),
],
if (communities.isNotEmpty)
const _CommunitySwitcherDivider(),
_AddCommunityTile(
onTap: () {
final nav = Navigator.of(
context,
rootNavigator: true,
);
ref.read(pairingProvider.notifier).reset();
Navigator.of(context).pop();
nav.push(
MaterialPageRoute<void>(
builder: (_) =>
const PairingPage(addingCommunity: true),
),
);
},
),
],
),
),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(Grid.xs),
child: Text('Error loading communities: $e'),
),
data: (communities) {
final activeId = activeAsync.value?.id;
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: Grid.xs),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
),
child: Material(
key: const Key('community-switcher-options'),
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.card),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (
var index = 0;
index < communities.length;
index++
) ...[
if (index > 0) const _CommunitySwitcherDivider(),
_CommunitySwitcherTile(
community: communities[index],
isActive: communities[index].id == activeId,
isEditing: isEditing.value,
onTap: isEditing.value
? null
: () async {
final community = communities[index];
if (community.id != activeId) {
await ref
.read(
communityListProvider.notifier,
)
.switchCommunity(community.id);
}
if (context.mounted) {
Navigator.of(context).pop();
}
},
onRemove: () => _confirmRemoveCommunity(
context,
ref,
communities[index],
closeSheetAfterRemoval:
communities[index].id == activeId,
),
),
],
if (communities.isNotEmpty)
const _CommunitySwitcherDivider(),
_AddCommunityTile(
onTap: () {
final nav = Navigator.of(
context,
rootNavigator: true,
);
ref.read(pairingProvider.notifier).reset();
Navigator.of(context).pop();
nav.push(
MaterialPageRoute<void>(
builder: (_) =>
const PairingPage(addingCommunity: true),
),
);
},
),
],
),
),
),
);
},
),
),
],
);
},
),
),
);
}

View File

@@ -87,6 +87,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget {
case _QuickAction.createChannel:
final created = await showBuzzModalBottomSheet<Channel>(
context: context,
title: 'Create a new channel',
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,
@@ -98,6 +99,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget {
case _QuickAction.newDm:
final opened = await showBuzzModalBottomSheet<Channel>(
context: context,
title: 'New message',
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,

View File

@@ -91,14 +91,6 @@ class _CreateChannelSheet extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Create a new $kindLabel',
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
),
const SizedBox(height: Grid.sm),
_CreateChannelFieldLabel(label: 'Name'),
const SizedBox(height: Grid.xxs),
_CreateChannelFieldShell(
@@ -537,14 +529,6 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'New message',
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
),
const SizedBox(height: Grid.xs),
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: isSubmitting.value ? null : queryFocusNode.requestFocus,

View File

@@ -279,53 +279,34 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// see every channel as having no messages. Skipped on backstop refreshes since
// live subscriptions keep lastMessageAt current after the initial load.
if (fetchLastMessage) {
final lastMessageResults = await Future.wait(
channels.map((channel) async {
if (!channel.isMember || channel.isArchived) return null;
try {
if (channel.isDm) {
final events = await session.fetchHistory(
NostrFilter(
kinds: EventKind.channelMessageEventKinds,
tags: {
'#h': [channel.id],
},
limit: 1,
),
);
if (events.isEmpty) return null;
return MapEntry(channel.id, events.first.createdAt);
}
final events = await session.fetchHistory(
NostrFilter(
kinds: EventKind.channelMessageEventKinds,
tags: {
'#h': [channel.id],
},
limit: 20,
),
);
for (final event in events) {
if (shouldNotifyForEvent(
event,
myPk,
mutedChannelIds: _mutedChannelIds(),
channelId: channel.id,
)) {
return MapEntry(channel.id, event.createdAt);
}
}
return null;
} catch (_) {
return null;
}
}),
);
final activeChannels = [
for (final channel in channels)
if (channel.isMember && !channel.isArchived) channel,
];
final channelById = {
for (final channel in activeChannels) channel.id: channel,
};
final events = await _fetchLastMessageEvents(session, activeChannels);
final lastMessageMap = <String, int>{};
for (final entry
in lastMessageResults.whereType<MapEntry<String, int>>()) {
lastMessageMap[entry.key] = entry.value;
final mutedChannelIds = _mutedChannelIds();
for (final event in events) {
final channelId = event.channelId;
if (channelId == null) continue;
final channel = channelById[channelId];
if (channel == null) continue;
if (!channel.isDm &&
!shouldNotifyForEvent(
event,
myPk,
mutedChannelIds: mutedChannelIds,
channelId: channelId,
)) {
continue;
}
final current = lastMessageMap[channelId];
if (current == null || event.createdAt > current) {
lastMessageMap[channelId] = event.createdAt;
}
}
for (var i = 0; i < channels.length; i++) {
@@ -410,6 +391,70 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
_memberSnapshotsByChannelId = Map.unmodifiable(snapshots);
}
/// Fetches each channel's independent latest-message window in one HTTP
/// bridge request. The relay preserves NIP-01 per-filter limits while
/// executing the filters with bounded concurrency, avoiding an unbounded
/// burst of websocket REQs on communities with many channels.
Future<List<NostrEvent>> _fetchLastMessageEvents(
RelaySessionNotifier session,
List<Channel> channels,
) async {
if (channels.isEmpty) return const [];
final filters = [
for (final channel in channels)
NostrFilter(
kinds: EventKind.channelMessageEventKinds,
tags: {
'#h': [channel.id],
},
limit: channel.isDm ? 1 : 20,
),
];
return _fetchChannelHistoryBatch(
session,
filters,
operation: 'latest-message query',
);
}
Future<List<NostrEvent>> _fetchChannelHistoryBatch(
RelaySessionNotifier session,
List<NostrFilter> filters, {
required String operation,
}) async {
if (filters.isEmpty) return const [];
try {
return await session.queryRelay(filters);
} catch (error) {
debugPrint(
'[ChannelsNotifier] batched $operation failed; '
'using bounded websocket fallback: $error',
);
}
const fallbackConcurrency = 4;
final events = <NostrEvent>[];
for (var start = 0; start < filters.length; start += fallbackConcurrency) {
final end = min(start + fallbackConcurrency, filters.length);
final results = await Future.wait(
filters.sublist(start, end).map((filter) async {
try {
return await session.fetchHistory(filter);
} catch (_) {
return const <NostrEvent>[];
}
}),
);
for (final result in results) {
events.addAll(result);
}
}
return events;
}
Future<Set<String>> _fetchHiddenDmIds(
RelaySessionNotifier session,
String myPk,
@@ -615,47 +660,34 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
debugPrint('[ChannelsNotifier] unread catch-up skipped: $error');
return;
}
final futures = <Future<void>>[];
for (final channel in channels) {
if (!channel.isMember || channel.isArchived) continue;
final readAt = readState.effectiveTimestamp(channel.id);
futures.add(
_catchUpUnreadEventsForChannel(
session,
channel,
myPk,
readAt,
mutedChannelIds,
),
);
}
const batchSize = 5;
for (var i = 0; i < futures.length; i += batchSize) {
await Future.wait(futures.sublist(i, min(i + batchSize, futures.length)));
}
state = state.whenData((channels) => List<Channel>.of(channels));
}
Future<void> _catchUpUnreadEventsForChannel(
RelaySessionNotifier session,
Channel channel,
String myPk,
int? readAt,
Set<String> mutedChannelIds,
) async {
try {
final events = await session.fetchHistory(
final activeChannels = [
for (final channel in channels)
if (channel.isMember && !channel.isArchived) channel,
];
final channelById = {
for (final channel in activeChannels) channel.id: channel,
};
final readAtByChannel = {
for (final channel in activeChannels)
channel.id: readState.effectiveTimestamp(channel.id),
};
final filters = [
for (final channel in activeChannels)
NostrFilter(
kinds: EventKind.channelMessageEventKinds,
tags: {
'#h': [channel.id],
},
since: readAt == null ? 0 : readAt + 1,
since: (readAtByChannel[channel.id] ?? -1) + 1,
limit: _unreadCatchUpLimit,
),
];
try {
final events = await _fetchChannelHistoryBatch(
session,
filters,
operation: 'unread catch-up',
);
for (final event in events) {
@@ -665,6 +697,11 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
for (final event in events) {
final channelId = event.channelId;
if (channelId == null) continue;
final channel = channelById[channelId];
if (channel == null) continue;
final readAt = readAtByChannel[channelId];
if (event.pubkey.toLowerCase() == myPk.toLowerCase()) continue;
if (readAt != null && event.createdAt <= readAt) continue;
if (!shouldNotifyForEvent(
@@ -681,10 +718,10 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
_recordUnreadEvent(channel, event, myPk);
}
} catch (error) {
debugPrint(
'[ChannelsNotifier] unread catch-up failed for ${channel.id}: $error',
);
debugPrint('[ChannelsNotifier] unread catch-up failed: $error');
}
state = state.whenData((channels) => List<Channel>.of(channels));
}
void _handleLiveEvent(NostrEvent event) {

View File

@@ -109,8 +109,6 @@ class ManageChannelSheet extends HookConsumerWidget {
child: ListView(
shrinkWrap: true,
children: [
Text('Manage channel', style: context.textTheme.titleMedium),
const SizedBox(height: Grid.xxs),
Text(
'Basic management for ${channel.name}.',
style: context.textTheme.bodySmall?.copyWith(

View File

@@ -35,6 +35,7 @@ class MembersSheet extends HookConsumerWidget {
final userCache = ref.watch(userCacheProvider);
final typingBotPubkeys = ref.watch(workingBotPubkeysProvider(channel.id));
final statusCache = ref.watch(userStatusCacheProvider);
final bottomClearance = Grid.md + MediaQuery.viewPaddingOf(context).bottom;
// Determine if the current user can manage members.
final currentMember = allMembers.cast<ChannelMember?>().firstWhere(
@@ -82,93 +83,78 @@ class MembersSheet extends HookConsumerWidget {
}, [allMembers.length]);
return Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Members', style: context.textTheme.titleMedium),
const SizedBox(height: Grid.xxs),
if (!channel.isDm) ...[const Divider(height: 1)],
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: membersAsync.when(
data: (_) => ListView(
shrinkWrap: true,
padding: const EdgeInsets.only(top: Grid.xxs),
children: [
if (people.isNotEmpty) ...[
_SectionLabel(label: 'People — ${people.length}'),
for (final member in people)
_MemberTile(
member: member,
currentPubkey: currentPubkey,
profile: userCache[member.pubkey.toLowerCase()],
canManage: canManage,
isSelf:
member.pubkey.toLowerCase() ==
currentPubkey?.toLowerCase(),
channelId: channel.id,
userStatus: statusCache[member.pubkey.toLowerCase()],
),
],
if (bots.isNotEmpty) ...[
const SizedBox(height: Grid.xxs),
_SectionLabel(label: 'Bots — ${bots.length}'),
for (final bot in bots)
_MemberTile(
member: bot,
currentPubkey: currentPubkey,
profile: userCache[bot.pubkey.toLowerCase()],
canManage: canManage,
isSelf: false,
channelId: channel.id,
isWorking: typingBotPubkeys.contains(
bot.pubkey.toLowerCase(),
),
onViewActivity: () => openActivity(bot),
onActivityTap:
typingBotPubkeys.contains(
bot.pubkey.toLowerCase(),
)
? () => openActivity(bot)
: null,
),
],
if (people.isEmpty && bots.isEmpty)
Center(
child: Text(
'No members found.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
loading: () => const Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: 'Loading members',
key: const ValueKey('members-sheet-content-padding'),
padding: EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, 0),
child: ConstrainedBox(
key: const ValueKey('members-sheet-viewport'),
constraints: BoxConstraints(maxHeight: 400 + bottomClearance),
child: membersAsync.when(
data: (_) => ListView(
key: const ValueKey('members-sheet-list'),
shrinkWrap: true,
padding: EdgeInsets.only(top: Grid.xxs, bottom: bottomClearance),
children: [
if (people.isNotEmpty) ...[
_SectionLabel(label: 'People · ${people.length}'),
for (final member in people)
_MemberTile(
member: member,
currentPubkey: currentPubkey,
profile: userCache[member.pubkey.toLowerCase()],
canManage: canManage,
isSelf:
member.pubkey.toLowerCase() ==
currentPubkey?.toLowerCase(),
channelId: channel.id,
userStatus: statusCache[member.pubkey.toLowerCase()],
),
),
error: (error, _) => Center(
],
if (bots.isNotEmpty) ...[
const SizedBox(height: Grid.xxs),
_SectionLabel(label: 'Agents · ${bots.length}'),
for (final bot in bots)
_MemberTile(
member: bot,
currentPubkey: currentPubkey,
profile: userCache[bot.pubkey.toLowerCase()],
canManage: canManage,
isSelf: false,
channelId: channel.id,
isWorking: typingBotPubkeys.contains(
bot.pubkey.toLowerCase(),
),
onViewActivity: () => openActivity(bot),
onActivityTap:
typingBotPubkeys.contains(bot.pubkey.toLowerCase())
? () => openActivity(bot)
: null,
),
],
if (people.isEmpty && bots.isEmpty)
Center(
child: Text(
error.toString(),
'No members found.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
color: context.colors.onSurfaceVariant,
),
),
),
],
),
loading: () => const Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: 'Loading members',
),
),
error: (error, _) => Center(
child: Text(
error.toString(),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
),
),
),
);
@@ -185,11 +171,10 @@ class _SectionLabel extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(top: Grid.half, bottom: Grid.half),
child: Text(
label.toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(
label,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
);
@@ -305,17 +290,13 @@ class _MemberTile extends ConsumerWidget {
final canChangeRole = showManagementActions && !member.isBot;
showBuzzModalBottomSheet<void>(
context: context,
title: label,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Text(label, style: context.textTheme.titleSmall),
),
const SizedBox(height: Grid.xxs),
if (onViewActivity != null)
ListTile(
leading: Icon(

View File

@@ -0,0 +1,26 @@
import 'channel.dart';
/// Semantic recipients for an outgoing mobile message.
///
/// Explicit mentions are always preserved. In a DM, every current recipient
/// is also addressed with a `p` tag without inserting visible `@mentions` into
/// the composer. Non-DM channels remain explicit-only.
List<String> messageMentionPubkeys({
required Channel channel,
required String? senderPubkey,
required Iterable<String> explicitMentions,
required Iterable<String> dmRecipientPubkeys,
}) {
final sender = senderPubkey?.toLowerCase();
final candidates = <String>[
...explicitMentions,
if (channel.isDm) ...dmRecipientPubkeys,
];
final seen = <String>{?sender};
return [
for (final candidate in candidates)
if (candidate.trim().isNotEmpty && seen.add(candidate.toLowerCase()))
candidate.toLowerCase(),
];
}

View File

@@ -4,7 +4,9 @@ import '../../shared/relay/relay.dart';
import '../channels/channel_management_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel.dart';
import 'channel_messages_provider.dart';
import 'message_mention_pubkeys.dart';
/// Sends messages by signing an event with the user's nsec and publishing it
/// over the relay's NIP-42-authenticated WebSocket session.
@@ -48,14 +50,26 @@ class SendMessage {
String? parentEventId,
String? rootEventId,
List<String>? mentionPubkeys,
Channel? channel,
List<List<String>> mediaTags = const [],
}) async {
_ensureDeliveryValid();
// Use explicitly passed pubkeys, or resolve @mentions against
// channel members to avoid matching the wrong user.
final resolvedMentions =
final explicitMentions =
mentionPubkeys ?? await _resolveMentions(content, channelId);
final authorPubkey = _signedEventRelay.pubkey;
final dmRecipientPubkeys = channel?.isDm == true
? await _fetchDmRecipientPubkeys(channelId, channel!, authorPubkey)
: null;
final resolvedMentions = dmRecipientPubkeys != null
? messageMentionPubkeys(
channel: channel!,
senderPubkey: authorPubkey,
explicitMentions: explicitMentions,
dmRecipientPubkeys: dmRecipientPubkeys,
)
: explicitMentions;
// Normalize mentions: lowercase, deduplicate, exclude self (matching
// the desktop's normalizeMentionPubkeys).
@@ -94,6 +108,36 @@ class SendMessage {
}
}
/// Resolve every identity that is actually a current member of this DM.
///
/// Membership is authoritative for delivery. The channel metadata's `p`
/// tags can lag membership changes, so they are only used when the membership
/// snapshot is unavailable.
Future<Set<String>> _fetchDmRecipientPubkeys(
String channelId,
Channel channel,
String? authorPubkey,
) async {
List<ChannelMember>? members;
try {
members = await _fetchMembers(channelId);
} catch (_) {
// Fall back to metadata below so an unavailable membership query does
// not block ordinary DM sends.
}
final author = authorPubkey?.toLowerCase();
final participants = members != null && members.isNotEmpty
? members.map((member) => member.pubkey)
: channel.participantPubkeys;
return {
for (final participant in participants)
if (participant.trim().isNotEmpty &&
participant.toLowerCase() != author)
participant.toLowerCase(),
};
}
void _ensureDeliveryValid() {
if (_isDeliveryValid?.call() == false) {
throw StateError(

View File

@@ -375,6 +375,9 @@ class ThreadDetailPage extends HookConsumerWidget {
}, [itemScrollController, replies.length]);
final channelsAsync = ref.watch(channelsProvider);
final channel = channelsAsync.value
?.where((candidate) => candidate.id == channelId)
.firstOrNull;
final channelNamesMap = <String, String>{};
channelsAsync.whenData((channels) {
for (final ch in channels) {
@@ -583,6 +586,7 @@ class ThreadDetailPage extends HookConsumerWidget {
channelId: channelId,
content: content,
mentionPubkeys: mentionPubkeys,
channel: channel,
parentEventId: threadHead.id,
rootEventId: effectiveRootId,
mediaTags: mediaTags,

View File

@@ -64,6 +64,7 @@ class HomePage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabIndex = useState(0);
final visitedTabs = useRef(<int>{0});
final tabContentTransitionDirection = useRef(1.0);
final tabContentTransitionController = useAnimationController(
duration: _tabContentTransitionDuration,
@@ -96,8 +97,14 @@ class HomePage extends HookConsumerWidget {
}
},
),
ActivityPage(tabReselection: activityReselection),
SearchPage(tabReselection: searchReselection),
if (visitedTabs.value.contains(1))
ActivityPage(tabReselection: activityReselection)
else
const SizedBox.shrink(),
if (visitedTabs.value.contains(2))
SearchPage(tabReselection: searchReselection)
else
const SizedBox.shrink(),
];
final settingsTransitionGradient = tabIndex.value == 0
@@ -195,6 +202,7 @@ class HomePage extends HookConsumerWidget {
? 1
: -1;
unawaited(HapticFeedback.selectionClick());
visitedTabs.value.add(i);
tabIndex.value = i;
if (reducedMotion) {
tabContentTransitionController.value = 1;

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/animated_avatar.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/masked_avatar_badge.dart';
@@ -63,6 +64,7 @@ class ProfileAvatar extends ConsumerWidget {
UserProfile? profile,
String presence,
) {
final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl);
return GestureDetector(
onTap: onTap,
child: MaskedAvatarBadge(
@@ -70,9 +72,12 @@ class ProfileAvatar extends ConsumerWidget {
geometry: AvatarBadgeMaskGeometry.presenceDot,
avatar: ClipOval(
child: ColoredBox(
color: context.colors.primaryContainer,
key: const ValueKey('profile-avatar-background'),
color: animatedAvatar == null
? context.colors.primaryContainer
: Colors.transparent,
child: AvatarImageContent(
imageUrl: profile?.avatarUrl,
imageUrl: animatedAvatar?.posterUrl ?? profile?.avatarUrl,
fallback: Text(
profile?.initial ?? '?',
style: context.textTheme.labelMedium?.copyWith(

View File

@@ -1,40 +1,62 @@
import 'dart:math' as math;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.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/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
import '../../shared/widgets/buzz_titled_sheet_layout.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;
const _emojiWellSize = 48.0;
const _emojiGlyphSize = 28.0;
const _statusPresets = [
(text: 'In a meeting', emoji: '\u{1F5E3}\u{FE0F}'),
(text: 'Commuting', emoji: '\u{1F68C}'),
(text: 'Out sick', emoji: '\u{1F912}'),
(text: 'Vacationing', emoji: '\u{1F3D6}\u{FE0F}'),
(text: 'Working remotely', emoji: '\u{1F3E0}'),
];
enum _StatusDuration {
oneHour('1 hour', Duration(hours: 1)),
eightHours('8 hours', Duration(hours: 8)),
oneDay('1 day', Duration(days: 1)),
oneWeek('1 week', Duration(days: 7)),
custom('Custom', null);
const _StatusDuration(this.label, this.duration);
final String label;
final Duration? duration;
}
void showSetStatusSheet(BuildContext context, {UserStatus? currentStatus}) {
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
showCloseButton: false,
showDragHandle: false,
builder: (_) => _SetStatusSheet(currentStatus: currentStatus),
);
}
class _SetStatusSheet extends HookConsumerWidget {
final UserStatus? currentStatus;
const _SetStatusSheet({this.currentStatus});
final UserStatus? currentStatus;
@override
Widget build(BuildContext context, WidgetRef ref) {
final textController = useTextEditingController(
@@ -42,6 +64,15 @@ class _SetStatusSheet extends HookConsumerWidget {
);
final emoji = useState(currentStatus?.emoji ?? '');
final text = useState(currentStatus?.text ?? '');
final currentExpiration = currentStatus?.expirationDateTime;
final duration = useState(
currentExpiration == null
? _StatusDuration.oneDay
: _StatusDuration.custom,
);
final customUntil = useState(
currentExpiration ?? DateTime.now().add(_StatusDuration.oneDay.duration!),
);
final isSaving = useState(false);
useEffect(() {
@@ -53,13 +84,18 @@ class _SetStatusSheet extends HookConsumerWidget {
final hasContent = text.value.trim().isNotEmpty || emoji.value.isNotEmpty;
final hasExistingStatus = currentStatus != null && !currentStatus!.isEmpty;
DateTime expiresAt() => switch (duration.value) {
_StatusDuration.custom => customUntil.value,
final preset => DateTime.now().add(preset.duration!),
};
Future<void> handleSave() async {
if (isSaving.value) return;
if (isSaving.value || !hasContent) return;
isSaving.value = true;
try {
await ref
.read(userStatusProvider.notifier)
.setStatus(text.value, emoji.value);
.setStatus(text.value, emoji.value, expiresAt: expiresAt());
if (context.mounted) Navigator.of(context).pop();
} finally {
isSaving.value = false;
@@ -77,135 +113,244 @@ class _SetStatusSheet extends HookConsumerWidget {
}
}
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('Set a status', style: context.textTheme.titleMedium),
const SizedBox(height: Grid.half),
Text(
'Let others know what you\u2019re up to.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.twelve),
Future<void> pickCustomUntil() async {
final picked = await _showNativeDateTimePicker(
context,
initial: customUntil.value,
);
if (picked == null) return;
duration.value = _StatusDuration.custom;
customUntil.value = picked;
}
// The emoji well doubles as the picker's entry point, which is why
// there is no separate row of emoji suggestions below.
Row(
Future<void> chooseDuration() async {
FocusScope.of(context).unfocus();
final selected = await _showStatusDurationSheet(
context,
selected: duration.value,
);
if (selected == null || !context.mounted) return;
if (selected == _StatusDuration.custom) {
await pickCustomUntil();
} else {
duration.value = selected;
}
}
final headerAction = SizedBox.square(
dimension: 44,
child: IconButton(
key: const ValueKey('save-status-button'),
tooltip: 'Save status',
onPressed: hasContent && !isSaving.value ? handleSave : null,
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor: context.colors.surfaceContainerHighest,
foregroundColor: context.colors.onSurface,
disabledForegroundColor: context.colors.onSurfaceVariant.withValues(
alpha: 0.38,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.dialog),
),
),
icon: const Icon(LucideIcons.check, size: 22),
),
);
return SafeArea(
top: false,
child: BuzzTitledSheetLayout(
title: 'Set a status',
showDragHandle: true,
leading: headerAction,
child: SingleChildScrollView(
padding: EdgeInsets.only(
// Keep the sheet at its resting height while the keyboard
// overlays its lower portion. Adding viewInsets here makes the
// modal itself grow upward by the full keyboard height.
bottom: Grid.gutter + MediaQuery.viewPaddingOf(context).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Semantics(
button: true,
label: 'Choose a status emoji',
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),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: _StatusInput(
controller: textController,
emoji: emoji.value,
enabled: !isSaving.value,
onChooseEmoji: () => showEmojiPicker(
context: context,
onSelect: (value) => emoji.value = 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: 'Remove status emoji',
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,
),
),
),
onRemoveEmoji: () => emoji.value = '',
onSubmitted: handleSave,
),
),
AppListCard(
label: 'Duration',
children: [
AppListRow(
icon: LucideIcons.clock3,
title: 'Duration',
value: duration.value.label,
trailing: const Icon(LucideIcons.chevronDown, size: 18),
onTap: chooseDuration,
),
if (duration.value == _StatusDuration.custom)
AppListRow(
icon: LucideIcons.calendarClock,
title: 'Until',
value: _formatUntil(customUntil.value),
onTap: pickCustomUntil,
),
],
),
const SizedBox(width: Grid.half),
Expanded(
child: TextField(
controller: textController,
autofocus: true,
style: context.textTheme.titleMedium,
decoration: InputDecoration(
hintText: 'What\u2019s your status?',
hintStyle: context.textTheme.titleMedium?.copyWith(
color: context.colors.onSurfaceVariant,
AppListCard(
label: 'Quick statuses',
children: [
for (final preset in _statusPresets)
AppListRowRaw(
leading: SizedBox(
width: 22,
child: Center(
child: Text(
preset.emoji,
style: const TextStyle(fontSize: 20),
),
),
),
title: Text(
preset.text,
style: context.textTheme.bodyLarge,
),
onTap: () {
textController.text = preset.text;
emoji.value = preset.emoji;
},
),
// 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();
},
),
],
),
if (hasExistingStatus)
AppListCard(
children: [
AppListRow(
icon: LucideIcons.trash2,
title: 'Clear status',
titleColor: context.colors.error,
onTap: isSaving.value ? null : handleClear,
),
],
),
],
),
const SizedBox(height: Grid.gutter),
),
),
);
}
}
SizedBox(
width: double.infinity,
height: _saveButtonHeight,
child: FilledButton(
onPressed: hasContent && !isSaving.value ? handleSave : null,
child: const Text('Save'),
class _StatusInput extends ConsumerWidget {
const _StatusInput({
required this.controller,
required this.emoji,
required this.enabled,
required this.onChooseEmoji,
required this.onRemoveEmoji,
required this.onSubmitted,
});
final TextEditingController controller;
final String emoji;
final bool enabled;
final VoidCallback onChooseEmoji;
final VoidCallback onRemoveEmoji;
final VoidCallback onSubmitted;
@override
Widget build(BuildContext context, WidgetRef ref) {
return DecoratedBox(
key: const ValueKey('status-input-outline'),
decoration: BoxDecoration(
border: Border.all(
color: context.colors.outlineVariant.withValues(alpha: 0.8),
),
borderRadius: BorderRadius.circular(Radii.card),
),
child: Row(
children: [
SizedBox.square(
dimension: _emojiWellSize,
child: Stack(
clipBehavior: Clip.none,
children: [
Semantics(
button: true,
label: 'Choose a status emoji',
child: InkWell(
borderRadius: BorderRadius.circular(Radii.card),
onTap: enabled ? onChooseEmoji : null,
child: Center(child: _StatusEmojiPreview(emoji: emoji)),
),
),
if (emoji.isNotEmpty)
Positioned(
top: -Grid.half,
right: -Grid.half,
child: SizedBox.square(
dimension: Grid.sm,
child: IconButton(
onPressed: enabled ? onRemoveEmoji : null,
tooltip: 'Remove status emoji',
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,
),
),
),
),
],
),
),
// 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: const Text('Clear status'),
Expanded(
child: TextField(
controller: controller,
enabled: enabled,
autofocus: false,
style: context.textTheme.bodyLarge,
decoration: InputDecoration(
hintText: 'What\u2019s your status?',
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.only(
left: Grid.xxs,
right: Grid.xs,
),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => onSubmitted(),
),
),
],
),
);
@@ -213,10 +358,10 @@ class _SetStatusSheet extends HookConsumerWidget {
}
class _StatusEmojiPreview extends ConsumerWidget {
final String emoji;
const _StatusEmojiPreview({required this.emoji});
final String emoji;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (emoji.isEmpty) {
@@ -242,3 +387,103 @@ class _StatusEmojiPreview extends ConsumerWidget {
return Text(emoji, style: const TextStyle(fontSize: _emojiGlyphSize));
}
}
Future<_StatusDuration?> _showStatusDurationSheet(
BuildContext context, {
required _StatusDuration selected,
}) {
return showBuzzModalBottomSheet<_StatusDuration>(
context: context,
title: 'Duration',
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => SingleChildScrollView(
child: SafeArea(
top: false,
child: AppListCard(
children: [
for (final option in _StatusDuration.values)
AppListRow(
title: option.label,
trailing: option == selected
? const Icon(LucideIcons.check, size: 18)
: null,
onTap: () => Navigator.of(sheetContext).pop(option),
),
],
),
),
),
);
}
Future<DateTime?> _showNativeDateTimePicker(
BuildContext context, {
required DateTime initial,
}) async {
final now = DateTime.now();
final minimum = now.add(const Duration(minutes: 5));
final safeInitial = initial.isAfter(minimum) ? initial : minimum;
if (defaultTargetPlatform == TargetPlatform.iOS) {
var selected = safeInitial;
return showCupertinoModalPopup<DateTime>(
context: context,
builder: (pickerContext) => Material(
color: context.colors.surface,
child: SafeArea(
top: false,
child: SizedBox(
height: 300,
child: Column(
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.of(pickerContext).pop(selected),
child: const Text('Done'),
),
),
Expanded(
child: CupertinoDatePicker(
mode: CupertinoDatePickerMode.dateAndTime,
minimumDate: minimum,
initialDateTime: safeInitial,
use24hFormat: MediaQuery.alwaysUse24HourFormatOf(context),
onDateTimeChanged: (value) => selected = value,
),
),
],
),
),
),
),
);
}
final lastDate = now.add(const Duration(days: 365));
final androidInitial = safeInitial.isAfter(lastDate) ? lastDate : safeInitial;
final date = await showDatePicker(
context: context,
initialDate: androidInitial,
firstDate: now,
lastDate: lastDate,
);
if (date == null || !context.mounted) return null;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(androidInitial),
);
if (time == null) return null;
final selected = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
return selected.isBefore(minimum) ? minimum : selected;
}
String _formatUntil(DateTime value) =>
DateFormat('EEE, MMM d \u00B7 h:mm a').format(value);

View File

@@ -1,15 +1,18 @@
import 'dart:async';
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/animated_avatar.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.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 '../../shared/widgets/progressive_animated_avatar.dart';
import 'profile_provider.dart';
import 'set_status_sheet.dart';
import 'user_status_provider.dart';
@@ -19,7 +22,7 @@ import 'user_status_provider.dart';
/// 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 {
class SettingsProfileHeader extends HookConsumerWidget {
const SettingsProfileHeader({super.key});
static const _avatarSize = 128.0;
@@ -30,6 +33,13 @@ class SettingsProfileHeader extends ConsumerWidget {
final status = ref.watch(userStatusProvider).asData?.value;
final hasStatus = status != null && !status.isEmpty;
final presence = ref.watch(presenceProvider).value ?? 'offline';
final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl);
final stoppedAnimationUrl = useState<String?>(null);
final avatarUrl = animatedAvatar == null
? profile?.avatarUrl
: stoppedAnimationUrl.value == animatedAvatar.animationUrl
? animatedAvatar.posterUrl
: null;
void openStatusSheet() =>
showSetStatusSheet(context, currentStatus: status);
@@ -40,16 +50,31 @@ class SettingsProfileHeader extends ConsumerWidget {
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,
),
),
avatar: GestureDetector(
key: const ValueKey('settings-profile-avatar'),
onTap: animatedAvatar == null
? null
: () => stoppedAnimationUrl.value =
stoppedAnimationUrl.value == animatedAvatar.animationUrl
? null
: animatedAvatar.animationUrl,
child: ColoredBox(
key: const ValueKey('settings-profile-avatar-background'),
color: animatedAvatar == null
? context.colors.primaryContainer
: Colors.transparent,
child:
animatedAvatar != null &&
stoppedAnimationUrl.value != animatedAvatar.animationUrl
? ProgressiveAnimatedAvatar(
key: ValueKey(animatedAvatar.animationUrl),
descriptor: animatedAvatar,
fallback: _AvatarFallback(initial: profile?.initial),
)
: AvatarImageContent(
imageUrl: avatarUrl,
fallback: _AvatarFallback(initial: profile?.initial),
),
),
),
badge: _StatusBadge(
@@ -98,6 +123,22 @@ class SettingsProfileHeader extends ConsumerWidget {
}
}
class _AvatarFallback extends StatelessWidget {
const _AvatarFallback({required this.initial});
final String? initial;
@override
Widget build(BuildContext context) {
return Text(
initial ?? '?',
style: context.textTheme.displaySmall?.copyWith(
color: context.colors.onPrimaryContainer,
),
);
}
}
class _PresencePill extends StatelessWidget {
const _PresencePill({required this.presence, required this.onSelected});

View File

@@ -6,12 +6,14 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/animated_avatar.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';
@@ -372,7 +374,7 @@ class _ProfilePresenceChip extends StatelessWidget {
}
}
class _ProfileAvatar extends StatelessWidget {
class _ProfileAvatar extends HookWidget {
final String? avatarUrl;
final String initial;
@@ -380,12 +382,33 @@ class _ProfileAvatar extends StatelessWidget {
@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: ClipOval(
child: AvatarImageContent(
imageUrl: avatarUrl,
fallback: _AvatarFallback(initial: initial),
child: GestureDetector(
key: const ValueKey('selected-profile-avatar'),
onTap: animatedAvatar == null
? null
: () => stoppedAnimationUrl.value =
stoppedAnimationUrl.value == animatedAvatar.animationUrl
? null
: animatedAvatar.animationUrl,
child: ClipOval(
child: isPlaying
? ProgressiveAnimatedAvatar(
key: ValueKey(animatedAvatar.animationUrl),
descriptor: animatedAvatar,
fallback: _AvatarFallback(initial: initial),
)
: AvatarImageContent(
imageUrl: animatedAvatar?.posterUrl ?? avatarUrl,
fallback: _AvatarFallback(initial: initial),
),
),
),
);

View File

@@ -2,29 +2,54 @@ import 'package:flutter/foundation.dart';
import '../../shared/relay/nostr_models.dart';
const _minDateTimeUnixSeconds = -8640000000000;
const _maxDateTimeUnixSeconds = 8640000000000;
/// A user's NIP-38 status (kind:30315, d=general).
@immutable
class UserStatus {
final String text;
final String emoji;
final int updatedAt;
final int? expiresAt;
const UserStatus({
required this.text,
required this.emoji,
required this.updatedAt,
this.expiresAt,
});
factory UserStatus.fromEvent(NostrEvent event) {
final emojiTag = event.tags
.where((t) => t.length >= 2 && t[0] == 'emoji')
.firstOrNull;
final expirationTag = event.tags
.where((t) => t.length >= 2 && t[0] == 'expiration')
.firstOrNull;
return UserStatus(
text: event.content,
emoji: emojiTag?[1] ?? '',
updatedAt: event.createdAt,
expiresAt: int.tryParse(expirationTag?[1] ?? ''),
);
}
/// Converts [expiresAt] when it falls within Dart's supported date range.
///
/// NIP-40 expiration tags are untrusted integers and may exceed that range.
DateTime? get expirationDateTime {
final unixSeconds = expiresAt;
if (unixSeconds == null ||
unixSeconds < _minDateTimeUnixSeconds ||
unixSeconds > _maxDateTimeUnixSeconds) {
return null;
}
return DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000);
}
bool get isEmpty => text.isEmpty && emoji.isEmpty;
bool isExpiredAt(int unixSeconds) =>
expiresAt != null && expiresAt! <= unixSeconds;
}

View File

@@ -17,6 +17,7 @@ class UserStatusCacheNotifier extends Notifier<Map<String, UserStatus?>> {
final Set<String> _pending = {};
Timer? _batchTimer;
Timer? _refreshTimer;
Timer? _expirationTimer;
void Function()? _statusUnsub;
int _subscriptionVersion = 0;
@@ -30,6 +31,8 @@ class UserStatusCacheNotifier extends Notifier<Map<String, UserStatus?>> {
_batchTimer = null;
_refreshTimer?.cancel();
_refreshTimer = null;
_expirationTimer?.cancel();
_expirationTimer = null;
_statusUnsub?.call();
_statusUnsub = null;
});
@@ -101,13 +104,20 @@ class UserStatusCacheNotifier extends Notifier<Map<String, UserStatus?>> {
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 now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// An equal event still needs its expiration re-evaluated. Otherwise the
// periodic refresh keeps an expired status alive forever.
if (existing != null && existing.updatedAt > parsed.updatedAt) return;
if (existing != null &&
existing.updatedAt == parsed.updatedAt &&
!parsed.isExpiredAt(now)) {
return;
}
final status = parsed.isEmpty ? null : parsed;
final status = parsed.isEmpty || parsed.isExpiredAt(now) ? null : parsed;
final updated = Map<String, UserStatus?>.from(state);
updated[pubkey] = status;
state = updated;
_replaceState(updated);
}
/// Directly update a pubkey's cached status. Used by [UserStatusNotifier]
@@ -116,7 +126,7 @@ class UserStatusCacheNotifier extends Notifier<Map<String, UserStatus?>> {
final pk = pubkey.toLowerCase();
final updated = Map<String, UserStatus?>.from(state);
updated[pk] = status;
state = updated;
_replaceState(updated);
}
Future<void> _refreshAll() async {
@@ -161,17 +171,72 @@ class UserStatusCacheNotifier extends Notifier<Map<String, UserStatus?>> {
final pk = event.pubkey.toLowerCase();
final parsed = UserStatus.fromEvent(event);
final existing = updated[pk];
if (existing != null && existing.updatedAt >= parsed.updatedAt) {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
if (existing != null && existing.updatedAt > parsed.updatedAt) {
continue;
}
updated[pk] = parsed.isEmpty ? null : parsed;
if (existing != null &&
existing.updatedAt == parsed.updatedAt &&
!parsed.isExpiredAt(now)) {
continue;
}
updated[pk] = parsed.isEmpty || parsed.isExpiredAt(now) ? null : parsed;
}
state = updated;
_replaceState(updated);
} catch (_) {
// Silently fail — backstop will retry.
}
}
void _replaceState(Map<String, UserStatus?> updated) {
state = updated;
_scheduleNextExpiration();
}
void _scheduleNextExpiration() {
_expirationTimer?.cancel();
_expirationTimer = null;
DateTime? nextDeadline;
for (final status in state.values) {
final deadline = status?.expirationDateTime;
if (deadline == null) continue;
if (nextDeadline == null || deadline.isBefore(nextDeadline)) {
nextDeadline = deadline;
}
}
if (nextDeadline == null) return;
final remaining = nextDeadline.difference(DateTime.now());
_expirationTimer = Timer(
remaining.isNegative ? Duration.zero : remaining,
_expireDueStatuses,
);
}
void _expireDueStatuses() {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
var changed = false;
final updated = Map<String, UserStatus?>.from(state);
for (final entry in state.entries) {
final status = entry.value;
if (status != null && status.isExpiredAt(now)) {
updated[entry.key] = null;
changed = true;
}
}
if (changed) {
_replaceState(updated);
} else {
// Timer callbacks can land just before the Unix-second boundary.
_expirationTimer = Timer(
const Duration(milliseconds: 50),
_expireDueStatuses,
);
}
}
}
final userStatusCacheProvider =

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
@@ -11,11 +13,20 @@ import 'user_status_cache_provider.dart';
/// for publishing. Publishes via WebSocket (triggers fan-out). No heartbeat
/// needed — user status events are parameterised replaceable, not ephemeral.
class UserStatusNotifier extends AsyncNotifier<UserStatus?> {
Timer? _expirationTimer;
@override
Future<UserStatus?> build() {
Future<UserStatus?> build() async {
ref.watch(relayClientProvider);
ref.watch(relaySessionProvider);
return _fetch();
ref.onDispose(() {
_expirationTimer?.cancel();
_expirationTimer = null;
});
final status = await _fetch();
_scheduleExpiration(status);
return status;
}
Future<UserStatus?> _fetch() async {
@@ -55,13 +66,18 @@ class UserStatusNotifier extends AsyncNotifier<UserStatus?> {
(a, b) => a.createdAt >= b.createdAt ? a : b,
);
final status = UserStatus.fromEvent(latest);
return status.isEmpty ? null : status;
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return status.isEmpty || status.isExpiredAt(now) ? null : status;
} catch (_) {
return null;
}
}
Future<void> setStatus(String text, String emoji) async {
Future<void> setStatus(
String text,
String emoji, {
DateTime? expiresAt,
}) async {
final trimmed = text.trim();
final config = ref.read(relayConfigProvider);
final nsec = config.nsec;
@@ -73,6 +89,9 @@ class UserStatusNotifier extends AsyncNotifier<UserStatus?> {
if (emoji.isNotEmpty) {
tags.add(['emoji', emoji]);
}
if (expiresAt != null) {
tags.add(['expiration', '${expiresAt.millisecondsSinceEpoch ~/ 1000}']);
}
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
final event = nostr.Event.from(
@@ -92,9 +111,13 @@ class UserStatusNotifier extends AsyncNotifier<UserStatus?> {
text: trimmed,
emoji: emoji,
updatedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
expiresAt: expiresAt == null
? null
: expiresAt.millisecondsSinceEpoch ~/ 1000,
)
: null;
state = AsyncValue.data(newStatus);
_scheduleExpiration(newStatus);
// Also update the shared cache so other UI reads stay consistent.
final keyPair = nostr.Keys(privkeyHex);
@@ -103,6 +126,50 @@ class UserStatusNotifier extends AsyncNotifier<UserStatus?> {
}
Future<void> clearStatus() => setStatus('', '');
void _scheduleExpiration(UserStatus? status) {
_expirationTimer?.cancel();
_expirationTimer = null;
final deadline = status?.expirationDateTime;
if (deadline == null) return;
final remaining = deadline.difference(DateTime.now());
_expirationTimer = Timer(
remaining.isNegative ? Duration.zero : remaining,
_expireCurrentStatus,
);
}
void _expireCurrentStatus() {
final current = state.asData?.value;
if (current == null) return;
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
if (!current.isExpiredAt(now)) {
// Timer callbacks can land just before the Unix-second boundary.
_expirationTimer = Timer(
const Duration(milliseconds: 50),
_expireCurrentStatus,
);
return;
}
state = const AsyncValue.data(null);
final pubkey = _currentPubkey();
if (pubkey != null) {
ref.read(userStatusCacheProvider.notifier).updateStatus(pubkey, null);
}
}
String? _currentPubkey() {
final nsec = ref.read(relayConfigProvider).nsec;
if (nsec == null || nsec.isEmpty) return null;
try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
return nostr.Keys(privkeyHex).public.toLowerCase();
} catch (_) {
return null;
}
}
}
final userStatusProvider =

View File

@@ -61,6 +61,7 @@ class _AppearanceSection extends ConsumerWidget {
void _showAppearanceModeSheet(BuildContext context) {
showBuzzModalBottomSheet<void>(
context: context,
title: 'Appearance',
showDragHandle: true,
builder: (_) => const _AppearanceModeSheet(),
);
@@ -80,15 +81,6 @@ class _AppearanceModeSheet extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xxs,
),
child: Text('Appearance', style: context.textTheme.titleMedium),
),
for (final option in _modeOptions)
AppListRow(
icon: option.icon,

View File

@@ -0,0 +1,58 @@
// Animated avatar URL scheme shared with the desktop client.
//
// The static poster is the normal selected frame. The animated PNG URL lives
// in the fragment so clients that do not understand the scheme render only
// the poster:
//
// posterUrl#buzz-anim=encodedAnimationUrl
const _animatedAvatarSeparator = '#buzz-anim=';
/// The static poster and animated image URLs encoded in an avatar URL.
class AnimatedAvatarDescriptor {
const AnimatedAvatarDescriptor({
required this.posterUrl,
required this.animationUrl,
});
final String posterUrl;
final String animationUrl;
}
/// Parses the Buzz animated-avatar fragment scheme from [url].
///
/// Returns `null` when the poster or animation URL is missing, malformed, or
/// does not use HTTP(S).
AnimatedAvatarDescriptor? parseAnimatedAvatarUrl(String? url) {
if (url == null || url.isEmpty) return null;
final separatorIndex = url.indexOf(_animatedAvatarSeparator);
if (separatorIndex <= 0) return null;
final posterUrl = url.substring(0, separatorIndex);
final encodedAnimationUrl = url.substring(
separatorIndex + _animatedAvatarSeparator.length,
);
if (encodedAnimationUrl.isEmpty) return null;
final String animationUrl;
try {
animationUrl = Uri.decodeComponent(encodedAnimationUrl);
} on ArgumentError {
return null;
} on FormatException {
return null;
}
if (!_isHttpUrl(posterUrl) || !_isHttpUrl(animationUrl)) return null;
return AnimatedAvatarDescriptor(
posterUrl: posterUrl,
animationUrl: animationUrl,
);
}
bool _isHttpUrl(String value) {
final uri = Uri.tryParse(value);
return uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.host.isNotEmpty;
}

View File

@@ -177,6 +177,7 @@ class MediaImage extends ConsumerWidget {
final double? height;
final String? semanticLabel;
final ImageErrorWidgetBuilder? errorBuilder;
final ImageFrameBuilder? frameBuilder;
final FilterQuality filterQuality;
final double? decodeWidth;
final bool boundDecodeToLayout;
@@ -189,6 +190,7 @@ class MediaImage extends ConsumerWidget {
this.height,
this.semanticLabel,
this.errorBuilder,
this.frameBuilder,
this.filterQuality = FilterQuality.medium,
this.decodeWidth,
this.boundDecodeToLayout = true,
@@ -231,6 +233,7 @@ class MediaImage extends ConsumerWidget {
height: height,
semanticLabel: semanticLabel,
errorBuilder: errorBuilder,
frameBuilder: frameBuilder,
filterQuality: filterQuality,
gaplessPlayback: true,
);

View File

@@ -0,0 +1,97 @@
import 'dart:async';
import 'package:http/http.dart' as http;
/// Owns the reusable HTTP transport used for relay `/query` requests.
class RelayHttpQueryClient {
RelayHttpQueryClient({
http.Client? client,
http.Client Function()? clientFactory,
}) : _injectedClient = client,
_clientFactory = clientFactory ?? (() => http.Client());
final http.Client? _injectedClient;
final http.Client Function() _clientFactory;
_ClientGeneration? _currentGeneration;
final Set<_ClientGeneration> _generations = {};
Future<http.Response> post(
Uri url, {
required Map<String, String> headers,
required List<int> body,
required Duration timeout,
}) async {
final generation = _injectedClient == null
? (_currentGeneration ??= _createGeneration())
: null;
generation?.acquire();
try {
return await (_injectedClient ?? generation!.client)
.post(url, headers: headers, body: body)
.timeout(timeout);
} on TimeoutException {
if (identical(_currentGeneration, generation)) {
_currentGeneration = null;
}
generation?.retire();
rethrow;
} finally {
generation?.release();
}
}
void close() {
for (final generation in _generations.toList()) {
generation.close();
}
_currentGeneration = null;
_injectedClient?.close();
}
_ClientGeneration _createGeneration() {
late final _ClientGeneration generation;
generation = _ClientGeneration(
_clientFactory(),
onClosed: () => _generations.remove(generation),
);
_generations.add(generation);
return generation;
}
}
class _ClientGeneration {
_ClientGeneration(this.client, {required this.onClosed});
final http.Client client;
final void Function() onClosed;
int _activeRequests = 0;
bool _retired = false;
bool _closed = false;
void acquire() {
assert(!_closed);
_activeRequests++;
}
void release() {
assert(_activeRequests > 0);
_activeRequests--;
_closeIfIdle();
}
void retire() {
_retired = true;
_closeIfIdle();
}
void close() {
if (_closed) return;
_closed = true;
client.close();
onClosed();
}
void _closeIfIdle() {
if (_retired && _activeRequests == 0) close();
}
}

View File

@@ -14,6 +14,7 @@ import '../auth/auth.dart';
import 'nostr_models.dart';
import 'relay_client.dart';
import 'relay_closed_policy.dart';
import 'relay_http_query_client.dart';
import 'relay_provider.dart';
import 'relay_rate_limit_gate.dart';
import 'relay_socket.dart';
@@ -88,19 +89,23 @@ typedef RelaySocketFactory =
class RelaySessionNotifier extends Notifier<SessionState> {
RelaySessionNotifier({
http.Client? httpClient,
http.Client Function()? httpClientFactory,
RelaySocketFactory socketFactory = RelaySocket.new,
DateTime Function()? now,
RelayRateLimitGate? rateLimitGate,
RelayTimerFactory retryTimerFactory = Timer.new,
Future<void> Function(Duration) replayDelay = Future.delayed,
}) : _httpClient = httpClient,
}) : _httpQueryClient = RelayHttpQueryClient(
client: httpClient,
clientFactory: httpClientFactory,
),
_socketFactory = socketFactory,
_now = now ?? DateTime.now,
_rateLimitGate = rateLimitGate ?? RelayRateLimitGate(),
_retryTimerFactory = retryTimerFactory,
_replayDelay = replayDelay;
final http.Client? _httpClient;
final RelayHttpQueryClient _httpQueryClient;
final RelaySocketFactory _socketFactory;
final DateTime Function() _now;
final RelayRateLimitGate _rateLimitGate;
@@ -168,26 +173,22 @@ class RelaySessionNotifier extends Notifier<SessionState> {
final bodyBytes = utf8.encode(
jsonEncode(filters.map((filter) => filter.toJson()).toList()),
);
final client = _httpClient ?? http.Client();
final shouldCloseClient = _httpClient == null;
final response = await client
.post(
Uri.parse(url),
headers: {
'Authorization': buildNip98AuthHeader(
method: 'POST',
url: url,
bodyBytes: bodyBytes,
nsec: config.nsec,
),
'Content-Type': 'application/json',
},
body: bodyBytes,
)
.timeout(timeout)
.whenComplete(() {
if (shouldCloseClient) client.close();
});
// Reuse the session transport on success. A timeout rotates immediately
// for new queries, then closes the retired client after its peers finish.
final response = await _httpQueryClient.post(
Uri.parse(url),
headers: {
'Authorization': buildNip98AuthHeader(
method: 'POST',
url: url,
bodyBytes: bodyBytes,
nsec: config.nsec,
),
'Content-Type': 'application/json',
},
body: bodyBytes,
timeout: timeout,
);
if (response.statusCode < 200 || response.statusCode >= 300) {
_activateRateLimitGateFromHttpError(response.body);
throw RelayException(response.statusCode, response.body);
@@ -936,7 +937,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
_recentDeliveryKeys.clear();
_socket?.dispose();
_socket = null;
_httpClient?.close();
_httpQueryClient.close();
}
}

View File

@@ -40,6 +40,7 @@ void showRemindMeLaterSheet({
showBuzzModalBottomSheet<void>(
context: context,
title: 'Remind me about this message',
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
@@ -56,16 +57,6 @@ void showRemindMeLaterSheet({
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
left: Grid.half,
bottom: Grid.xxs,
),
child: Text(
'Remind me about this message',
style: Theme.of(sheetContext).textTheme.titleSmall,
),
),
for (final preset in reminderTimePresets)
ListTile(
leading: const Icon(LucideIcons.clock),

View File

@@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../animated_avatar.dart';
import '../relay/relay.dart';
/// A circular avatar that supports both remote URLs and inline image data.
@@ -27,13 +28,21 @@ class AvatarImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final animatedAvatar = parseAnimatedAvatarUrl(imageUrl);
return CircleAvatar(
radius: radius,
backgroundColor: backgroundColor,
// Animated avatar posters carry their own backdrop disc; preserve their
// transparent surroundings on static/list surfaces, matching desktop.
backgroundColor: animatedAvatar == null
? backgroundColor
: Colors.transparent,
child: ClipOval(
child: SizedBox.square(
dimension: radius * 2,
child: AvatarImageContent(imageUrl: imageUrl, fallback: fallback),
child: AvatarImageContent(
imageUrl: animatedAvatar?.posterUrl ?? imageUrl,
fallback: fallback,
),
),
),
);

View File

@@ -0,0 +1,121 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/theme.dart';
/// A titled sheet header with balanced actions and an exactly centered title.
class BuzzSheetHeader extends StatelessWidget {
const BuzzSheetHeader({
super.key,
this.title,
this.titleKey,
this.leading,
this.trailing,
this.showDragHandle = false,
});
final String? title;
final Key? titleKey;
final Widget? leading;
final Widget? trailing;
final bool showDragHandle;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(
top: Grid.xxs,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.xs,
),
child: SizedBox(
height: 56,
child: Stack(
alignment: Alignment.topCenter,
children: [
if (showDragHandle) const _SheetDragHandle(),
if (title case final title?)
Positioned(
left: 64,
right: 64,
bottom: 0,
height: 44,
child: Center(
child: Text(
title,
key: titleKey ?? const ValueKey('buzz-sheet-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
),
Align(
alignment: Alignment.bottomRight,
child: trailing ?? const _SheetCloseButton(),
),
if (leading case final leading?)
Align(alignment: Alignment.bottomLeft, child: leading),
],
),
),
);
}
}
class _SheetCloseButton extends StatelessWidget {
const _SheetCloseButton();
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 44,
child: IconButton(
tooltip: 'Close sheet',
onPressed: () {
unawaited(HapticFeedback.lightImpact());
Navigator.of(context).pop();
},
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor: context.colors.surfaceContainerHighest,
foregroundColor: context.colors.onSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.dialog),
),
),
icon: const Icon(LucideIcons.x, size: 22),
),
);
}
}
class _SheetDragHandle extends StatelessWidget {
const _SheetDragHandle();
@override
Widget build(BuildContext context) {
return Semantics(
label: MaterialLocalizations.of(context).modalBarrierDismissLabel,
container: true,
button: true,
onTap: () => Navigator.of(context).pop(),
child: Container(
key: const ValueKey('buzz-sheet-drag-handle'),
width: 32,
height: 4,
decoration: BoxDecoration(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(Radii.full),
),
),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
import 'buzz_sheet_header.dart';
import 'concentric_sheet_surface.dart';
/// Shared solid sheet surface with a centered navigation row.
class BuzzTitledSheetLayout extends StatelessWidget {
const BuzzTitledSheetLayout({
super.key,
required this.title,
required this.child,
this.leading,
this.trailing,
this.titleKey,
this.showDragHandle = false,
this.surfaceColor,
});
final String title;
final Widget child;
final Widget? leading;
final Widget? trailing;
final Key? titleKey;
final bool showDragHandle;
final Color? surfaceColor;
@override
Widget build(BuildContext context) {
final color = surfaceColor ?? context.colors.surface;
final paintsSurface = !ConcentricSheetSurface.providesSurfaceOf(context);
final sheet = SizedBox(
width: double.infinity,
child: ColoredBox(
key: const ValueKey('buzz-sheet-surface'),
color: paintsSurface ? color : Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BuzzSheetHeader(
title: title,
titleKey: titleKey,
leading: leading,
trailing: trailing,
showDragHandle: showDragHandle,
),
Flexible(child: child),
],
),
),
);
// The native iOS surface owns its exact iOS 26 container-concentric mask.
// A second fixed Flutter radius would visibly square off those corners.
if (!paintsSurface) return sheet;
return ClipRRect(
key: const ValueKey('buzz-sheet-surface-clip'),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
clipBehavior: Clip.antiAlias,
child: sheet,
);
}
}

View File

@@ -20,7 +20,14 @@ class ConcentricSheetSurface extends HookWidget {
final bool enabled;
final Color? color;
static bool providesSurfaceOf(BuildContext context) =>
context
.dependOnInheritedWidgetOfExactType<_ConcentricSheetSurfaceScope>()
?.providesSurface ??
false;
static const _surfaceChannel = MethodChannel('buzz/concentric_sheet_surface');
static const _nativeContentClipRadius = Radii.dialog * 2;
Future<bool> _checkNativeSurfaceSupport() async {
try {
@@ -48,7 +55,7 @@ class ConcentricSheetSurface extends HookWidget {
final nativeSurfaceSupported = useFuture(supportFuture).data ?? false;
if (!shouldCheckNativeSurface) {
return child;
return _ConcentricSheetSurfaceScope(providesSurface: false, child: child);
}
final surfaceColor = color ?? context.colors.surface;
@@ -83,9 +90,37 @@ class ConcentricSheetSurface extends HookWidget {
clipBehavior: Clip.antiAlias,
),
),
child,
ClipRSuperellipse(
key: const ValueKey('concentric-sheet-content-clip'),
// UIKit's container-concentric corner resolves substantially
// larger than its minimum radius on an edge-inset iOS sheet. Keep
// the Flutter content inside that continuous outline; a 24pt
// circular clip still lets scrolling rows show through the native
// corner cutouts.
borderRadius: BorderRadius.circular(
nativeSurfaceSupported ? _nativeContentClipRadius : Radii.dialog,
),
clipBehavior: Clip.antiAlias,
child: _ConcentricSheetSurfaceScope(
providesSurface: true,
child: child,
),
),
],
),
);
}
}
class _ConcentricSheetSurfaceScope extends InheritedWidget {
const _ConcentricSheetSurfaceScope({
required this.providesSurface,
required super.child,
});
final bool providesSurface;
@override
bool updateShouldNotify(_ConcentricSheetSurfaceScope oldWidget) =>
oldWidget.providesSurface != providesSurface;
}

View File

@@ -1,11 +1,9 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/theme.dart';
import 'buzz_sheet_header.dart';
import 'buzz_titled_sheet_layout.dart';
import 'concentric_sheet_surface.dart';
/// Shared motion for occasional modal UI.
@@ -23,10 +21,12 @@ const buzzModalAnimationStyle = AnimationStyle(
///
/// Sheets include the shared close control by default. On iOS, the surface
/// uses native concentric corners when available and paints a requested drag
/// handle inside that inset surface; other platforms retain Flutter's handle.
/// handle inside the shared header so its spacing is consistent on every
/// platform.
Future<T?> showBuzzModalBottomSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
String? title,
Color? backgroundColor,
String? barrierLabel,
double? elevation,
@@ -63,12 +63,16 @@ Future<T?> showBuzzModalBottomSheet<T>({
enabled: isIos,
color: surfaceColor,
child: _SheetContent(
title: title,
showCloseButton: showCloseButton,
showDragHandle: isIos && showDragHandle == true,
showDragHandle: showDragHandle == true,
surfaceColor: surfaceColor,
child: builder(sheetContext),
),
),
backgroundColor: isIos ? Colors.transparent : backgroundColor,
backgroundColor: isIos || title != null
? Colors.transparent
: backgroundColor,
barrierLabel: barrierLabel,
elevation: elevation,
shape: shape,
@@ -80,9 +84,9 @@ Future<T?> showBuzzModalBottomSheet<T>({
useRootNavigator: useRootNavigator,
isDismissible: isDismissible,
enableDrag: enableDrag,
// The iOS route is transparent so its stock handle sits outside the inset
// concentric surface. Paint it inside the surface above instead.
showDragHandle: isIos ? false : showDragHandle,
// The shared header owns the handle so Android does not reserve a second
// handle band above the title row and iOS keeps it inside its native inset.
showDragHandle: false,
useSafeArea: useSafeArea,
routeSettings: routeSettings,
transitionAnimationController: transitionAnimationController,
@@ -97,78 +101,55 @@ Future<T?> showBuzzModalBottomSheet<T>({
class _SheetContent extends StatelessWidget {
const _SheetContent({
required this.child,
required this.title,
required this.showCloseButton,
required this.showDragHandle,
required this.surfaceColor,
});
final Widget child;
final String? title;
final bool showCloseButton;
final bool showDragHandle;
final Color surfaceColor;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showCloseButton)
Padding(
padding: const EdgeInsets.only(
top: Grid.xxs,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.xs,
if (title == null || !showCloseButton) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showCloseButton)
BuzzSheetHeader(title: title, showDragHandle: showDragHandle)
else if (showDragHandle)
const Padding(
padding: EdgeInsets.only(top: Grid.xxs, bottom: Grid.xs),
child: _StandaloneSheetDragHandle(),
),
child: SizedBox(
height: 56,
child: Stack(
alignment: Alignment.topCenter,
children: [
if (showDragHandle) const _SheetDragHandle(),
Align(
alignment: Alignment.bottomRight,
child: SizedBox.square(
dimension: 44,
child: IconButton(
tooltip: 'Close sheet',
onPressed: () {
unawaited(HapticFeedback.lightImpact());
Navigator.of(context).pop();
},
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor:
context.colors.surfaceContainerHighest,
foregroundColor: context.colors.onSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.dialog),
),
),
icon: const Icon(LucideIcons.x, size: 22),
),
),
),
],
),
),
)
else if (showDragHandle)
const Padding(
padding: EdgeInsets.only(top: Grid.xxs, bottom: Grid.xs),
child: _SheetDragHandle(),
),
Flexible(child: child),
],
Flexible(child: child),
],
);
}
return BuzzTitledSheetLayout(
title: title!,
showDragHandle: showDragHandle,
surfaceColor: surfaceColor,
child: child,
);
}
}
class _SheetDragHandle extends StatelessWidget {
const _SheetDragHandle();
class _StandaloneSheetDragHandle extends StatelessWidget {
const _StandaloneSheetDragHandle();
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Drag handle',
label: MaterialLocalizations.of(context).modalBarrierDismissLabel,
container: true,
button: true,
onTap: () => Navigator.of(context).pop(),
child: Container(
key: const ValueKey('buzz-sheet-drag-handle'),
width: 32,

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../animated_avatar.dart';
import '../relay/media_image.dart';
import 'avatar_image.dart';
/// Paints an animated avatar's persisted poster until its first frame is ready.
///
/// Callers own playback policy, which keeps animation opt-in at individual
/// profile surfaces.
class ProgressiveAnimatedAvatar extends HookWidget {
const ProgressiveAnimatedAvatar({
super.key,
required this.descriptor,
required this.fallback,
this.fit = BoxFit.cover,
});
final AnimatedAvatarDescriptor descriptor;
final Widget fallback;
final BoxFit fit;
@override
Widget build(BuildContext context) {
final readyAnimationUrl = useState<String?>(null);
final scheduledAnimationUrl = useRef<String?>(null);
final animationKey = useMemoized(GlobalKey.new, [descriptor.animationUrl]);
final isReady = readyAnimationUrl.value == descriptor.animationUrl;
final animation = KeyedSubtree(
key: const ValueKey('progressive-animated-avatar-animation'),
child: MediaImage(
key: animationKey,
url: descriptor.animationUrl,
fit: fit,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if ((wasSynchronouslyLoaded || frame != null) &&
!isReady &&
scheduledAnimationUrl.value != descriptor.animationUrl) {
scheduledAnimationUrl.value = descriptor.animationUrl;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
readyAnimationUrl.value = descriptor.animationUrl;
}
});
}
return child;
},
),
);
if (isReady) {
return KeyedSubtree(
key: const ValueKey('progressive-animated-avatar-animation-ready'),
child: animation,
);
}
return Stack(
fit: StackFit.expand,
children: [
AvatarImageContent(
key: const ValueKey('progressive-animated-avatar-poster'),
imageUrl: descriptor.posterUrl,
fallback: fallback,
fit: fit,
),
Offstage(
key: const ValueKey('progressive-animated-avatar-animation-loading'),
offstage: true,
child: animation,
),
],
);
}
}

View File

@@ -10,11 +10,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
/// Records subscriptions and DM history queries for Activity projection tests.
class _RecordingSessionNotifier extends RelaySessionNotifier {
final List<List<String>> dmQueries = [];
final List<int> queryFilterCounts = [];
final List<NostrEvent> _history = [];
final List<({NostrFilter filter, void Function(NostrEvent) onEvent})>
_subscriptions = [];
Completer<void>? mentionFetchGate;
bool failNextMentionFetch = false;
bool failNextQueryRelay = false;
int mentionFetchCount = 0;
int activeMentionFetches = 0;
int maxActiveMentionFetches = 0;
@@ -51,6 +53,45 @@ class _RecordingSessionNotifier extends RelaySessionNotifier {
return _history.where((event) => _matches(filter, event)).toList();
}
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
queryFilterCounts.add(filters.length);
if (failNextQueryRelay) {
failNextQueryRelay = false;
throw StateError('transient HTTP query failure');
}
for (final filter in filters) {
final h = filter.tags['#h'];
if (h != null) dmQueries.add(h);
}
final isMentionFetch = filters.any(
(filter) => filter.tags.containsKey('#p') && filter.kinds.contains(40002),
);
if (isMentionFetch) {
mentionFetchCount += 1;
activeMentionFetches += 1;
if (activeMentionFetches > maxActiveMentionFetches) {
maxActiveMentionFetches = activeMentionFetches;
}
try {
final gate = mentionFetchGate;
if (gate != null) await gate.future;
if (failNextMentionFetch) {
failNextMentionFetch = false;
throw StateError('transient mention history failure');
}
} finally {
activeMentionFetches -= 1;
}
}
return _history
.where((event) => filters.any((filter) => _matches(filter, event)))
.toList();
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
@@ -159,6 +200,7 @@ void main() {
// Cold start: channels still loading, so the first fetch has no DM ids.
await container.read(activityProvider.future);
expect(session.dmQueries, isEmpty);
expect(session.queryFilterCounts, [3]);
// Channel list resolves with a DM → Activity must rebuild and query it.
channels.resolve([_dmChannel('dm1')]);
@@ -167,6 +209,7 @@ void main() {
expect(session.dmQueries, hasLength(1));
expect(session.dmQueries.single, ['dm1']);
expect(session.queryFilterCounts, [3, 4]);
});
test('does not query DMs when the resolved channel list has none', () async {
@@ -190,6 +233,30 @@ void main() {
expect(session.dmQueries, isEmpty);
});
test('falls back to websocket history when the HTTP batch fails', () async {
final session = _RecordingSessionNotifier()
..seed(_mentionEvent('fallback-mention', 1_700_000_001))
..failNextQueryRelay = true;
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new),
myPubkeyProvider.overrideWithValue('me_pk'),
relaySessionProvider.overrideWith(() => session),
channelsProvider.overrideWith(
() => _FixedChannelsNotifier(const <Channel>[]),
),
],
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final feed = await container.read(activityProvider.future);
expect(session.queryFilterCounts, [3]);
expect(session.mentionFetchCount, 1);
expect(feed.mentions.map((item) => item.id), ['fallback-mention']);
});
test(
'refreshes the inbox projection when addressed activity arrives',
() async {
@@ -308,7 +375,7 @@ void main() {
expect(session.maxActiveMentionFetches, 1);
});
test('retains the loaded inbox when a live refresh fails', () async {
test('recovers a live refresh through websocket history fallback', () async {
final session = _RecordingSessionNotifier()
..seed(_mentionEvent('existing', 1_700_000_001));
final container = ProviderContainer(
@@ -333,7 +400,10 @@ void main() {
await _waitFor(() => session.mentionFetchCount >= 2);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(container.read(inboxItemsProvider).single.id, 'existing');
expect(
container.read(inboxItemsProvider).map((item) => item.id),
containsAll(['existing', 'newer']),
);
});
}

View File

@@ -7,6 +7,8 @@ import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:buzz/features/channels/channel.dart';
@@ -35,6 +37,7 @@ import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:buzz/shared/widgets/skeleton.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -187,6 +190,7 @@ Widget _buildTestable({
TextScaler textScaler = TextScaler.noScaling,
bool disableAnimations = false,
RelaySessionNotifier? relaySessionNotifier,
http.Client? mediaClient,
}) {
final resolvedChannel = channel ?? _testChannel;
final fakeChannelsNotifier =
@@ -238,6 +242,12 @@ Widget _buildTestable({
relayClientProvider.overrideWithValue(
RelayClient(baseUrl: 'http://localhost:3000'),
),
if (mediaClient != null) ...[
mediaGetAuthServiceProvider.overrideWithValue(
MediaGetAuthService(baseUrl: 'https://relay.example', nsec: null),
),
mediaHttpClientProvider.overrideWithValue(mediaClient),
],
if (relaySessionNotifier != null)
relaySessionProvider.overrideWith(() => relaySessionNotifier),
// Compose bar drafts persist through SharedPreferences.
@@ -344,6 +354,86 @@ void main() {
});
group('ChannelDetailPage', () {
testWidgets('uses the shared 32px masked presence avatar in DM headers', (
tester,
) async {
final dmChannel = Channel(
id: _channelId,
name: 'DM',
channelType: 'dm',
visibility: 'private',
description: 'Direct message',
createdBy: 'self',
createdAt: DateTime(2025),
memberCount: 2,
participants: const ['Self', 'Alice'],
participantPubkeys: const ['self', 'alice'],
isMember: true,
);
await tester.pumpWidget(
_buildTestable(
messages: const [],
channel: dmChannel,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
final avatarFinder = find.byKey(const ValueKey('dm-header-avatar'));
final avatar = tester.widget<MaskedAvatarBadge>(avatarFinder);
expect(tester.getSize(avatarFinder), const Size.square(32));
expect(avatar.geometry, AvatarBadgeMaskGeometry.presenceDot);
expect(avatar.badge, isNotNull);
expect(
find.descendant(of: avatarFinder, matching: find.byType(ClipPath)),
findsOneWidget,
);
final name = tester.widget<Text>(
find.byKey(const ValueKey('dm-header-name')),
);
final presence = tester.widget<Text>(
find.byKey(const ValueKey('dm-header-presence')),
);
expect(name.style?.fontSize, 16);
expect(name.style?.fontWeight, FontWeight.w500);
expect(presence.style?.fontSize, 14);
expect(presence.style?.fontWeight, FontWeight.w400);
expect(find.byTooltip('View members'), findsNothing);
});
testWidgets('keeps the Members action for group DMs', (tester) async {
final dmChannel = Channel(
id: _channelId,
name: 'DM',
channelType: 'dm',
visibility: 'private',
description: 'Group direct message',
createdBy: 'self',
createdAt: DateTime(2025),
memberCount: 3,
participants: const ['Self', 'Alice', 'Bob'],
participantPubkeys: const ['self', 'alice', 'bob'],
isMember: true,
);
await tester.pumpWidget(
_buildTestable(
messages: const [],
channel: dmChannel,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
expect(find.byTooltip('View members'), findsOneWidget);
});
testWidgets(
'restores the previous channel replay priority after a nested pop',
(tester) async {
@@ -732,6 +822,12 @@ void main() {
joinedAt: DateTime(2025),
displayName: 'Alice',
),
ChannelMember(
pubkey: 'agent',
role: 'bot',
joinedAt: DateTime(2025),
displayName: 'Agent',
),
],
),
);
@@ -743,6 +839,65 @@ void main() {
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Member'), findsOneWidget);
expect(find.text('Owner'), findsOneWidget);
expect(find.text('People · 2'), findsOneWidget);
expect(find.text('Agents · 1'), findsOneWidget);
expect(find.text('PEOPLE — 2'), findsNothing);
expect(find.text('BOTS — 1'), findsNothing);
});
testWidgets('members sheet has no divider and pads a one-member list', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
messages: const [],
members: [
ChannelMember(
pubkey: 'self',
role: 'owner',
joinedAt: DateTime(2025),
displayName: 'Self',
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('View members'));
await tester.pumpAndSettle();
final contentPadding = find.byKey(
const ValueKey('members-sheet-content-padding'),
);
expect(contentPadding, findsOneWidget);
expect(
find.descendant(of: contentPadding, matching: find.byType(Divider)),
findsNothing,
);
expect(
(tester.widget<Padding>(contentPadding).padding as EdgeInsets).bottom,
0,
);
final viewport = tester.widget<ConstrainedBox>(
find.byKey(const ValueKey('members-sheet-viewport')),
);
expect(viewport.constraints.maxHeight, 400 + Grid.md);
expect(
(tester
.widget<ListView>(
find.byKey(const ValueKey('members-sheet-list')),
)
.padding!
as EdgeInsets)
.bottom,
Grid.md,
);
expect(
find.byKey(const ValueKey('buzz-sheet-surface-margin')),
findsNothing,
);
expect(find.text('People · 1'), findsOneWidget);
expect(tester.widget<Text>(find.text('People · 1')).style?.fontSize, 14);
});
testWidgets('hides composer for archived channels', (tester) async {
@@ -1029,6 +1184,46 @@ void main() {
);
});
testWidgets('keeps animated message avatars static and transparent', (
tester,
) async {
const posterUrl = 'https://relay.example/media/alice-poster.png';
const animationUrl = 'https://relay.example/media/alice-avatar.png';
final profileUrl =
'$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}';
final mediaClient = http_testing.MockClient(
(_) async => http.Response.bytes(_transparentPng, 200),
);
addTearDown(mediaClient.close);
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(id: 'animated-avatar', pubkey: 'alice', content: 'Hello'),
],
users: {
'alice': UserProfile(
pubkey: 'alice',
displayName: 'Alice',
avatarUrl: profileUrl,
),
},
mediaClient: mediaClient,
),
);
await tester.pumpAndSettle();
expect(
tester.widget<CircleAvatar>(find.byType(CircleAvatar)).backgroundColor,
Colors.transparent,
);
expect(tester.widget<MediaImage>(find.byType(MediaImage)).url, posterUrl);
expect(
find.byKey(const ValueKey('progressive-animated-avatar-animation')),
findsNothing,
);
});
testWidgets('long press opens the anchored reaction popover', (
tester,
) async {
@@ -2891,6 +3086,107 @@ void main() {
expect(find.byType(UserProfileSheet), findsOneWidget);
});
testWidgets(
'profile sheet shows the poster before autoplay and restores it on tap',
(tester) async {
const posterUrl = 'https://relay.example/media/alice-poster.png';
const animationUrl = 'https://relay.example/media/alice-avatar.png';
final profileUrl =
'$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}';
final animationResponse = Completer<http.Response>();
final mediaClient = http_testing.MockClient(
(request) => request.url.toString() == animationUrl
? animationResponse.future
: Future.value(http.Response.bytes(_transparentPng, 200)),
);
addTearDown(mediaClient.close);
await tester.pumpWidget(
_buildTestable(
messages: [
_huddleMsg(
id: 'sys-huddle-animated-avatar',
kind: EventKind.huddleStarted,
pubkey: 'alice',
),
],
users: {
'alice': UserProfile(
pubkey: 'alice',
displayName: 'Alice',
avatarUrl: profileUrl,
),
},
mediaClient: mediaClient,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(CircleAvatar));
await tester.pumpAndSettle();
expect(find.byType(UserProfileSheet), findsOneWidget);
expect(
find.byKey(const ValueKey('progressive-animated-avatar-poster')),
findsOneWidget,
);
expect(
find.byKey(
const ValueKey('progressive-animated-avatar-animation-loading'),
),
findsOneWidget,
);
animationResponse.complete(http.Response.bytes(_transparentPng, 200));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pumpAndSettle();
expect(
find.byKey(
const ValueKey('progressive-animated-avatar-animation-ready'),
),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('progressive-animated-avatar-poster')),
findsNothing,
);
await tester.tap(find.byKey(const ValueKey('selected-profile-avatar')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('progressive-animated-avatar-animation')),
findsNothing,
);
expect(
tester
.widget<MediaImage>(
find.descendant(
of: find.byKey(const ValueKey('selected-profile-avatar')),
matching: find.byType(MediaImage),
),
)
.url,
posterUrl,
);
await tester.tap(find.byKey(const ValueKey('selected-profile-avatar')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('progressive-animated-avatar-animation')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('progressive-animated-avatar-poster')),
findsNothing,
);
},
);
testWidgets('opens a profile sheet from a generic system avatar', (
tester,
) async {
@@ -6265,6 +6561,10 @@ List<String> _replayedChannelIds(_RecordingRelaySocket socket) => socket
)
.toList();
final _transparentPng = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
);
class _TestNavigatorObserver extends NavigatorObserver {
int pushCount = 0;

View File

@@ -909,11 +909,12 @@ void main() {
final options = find.byKey(const Key('community-switcher-options'));
expect(options, findsOneWidget);
final editButton = find.byKey(const Key('community-switcher-edit'));
expect(tester.getSize(editButton), const Size(56, 44));
expect(find.byTooltip('Close sheet'), findsNothing);
expect(
tester.getRect(options).top - tester.getRect(editButton).bottom,
closeTo(8, 0.01),
tester.getCenter(editButton).dx,
greaterThan(tester.getCenter(find.text('Switch Community')).dx),
);
expect(tester.getSize(editButton).height, 32);
expect(find.text('alpha.example.com'), findsOneWidget);
expect(find.text('bravo.example.com'), findsOneWidget);
expect(find.text('Rename'), findsNothing);
@@ -943,6 +944,10 @@ void main() {
final inactiveSelection = find.byKey(
const Key('community-switcher-selection-bravo'),
);
expect(
tester.getCenter(editButton).dx,
closeTo(tester.getCenter(activeSelection).dx, 0.01),
);
expect(tester.getSize(activeSelection), const Size.square(40));
expect(
tester.getSize(find.byKey(const Key('community-switcher-circle-alpha'))),

View File

@@ -306,6 +306,83 @@ void main() {
expect(channels.single.lastMessageAt?.millisecondsSinceEpoch, 20 * 1000);
});
test(
'loads all channel timestamps through one batched relay query',
() async {
final session = _FakeRelaySession(
memberships: [
_membership(_channelA, myPk),
_membership(_channelB, myPk),
],
metadata: [
_meta(id: _channelA, name: 'general'),
_meta(id: _channelB, name: 'direct', channelType: 'dm'),
],
recentMessages: const [
NostrEvent(
id: 'stream-message',
pubkey: 'alice',
createdAt: 30,
kind: EventKind.streamMessageV2,
tags: [
['h', _channelA],
],
content: 'hello',
sig: 'sig',
),
NostrEvent(
id: 'dm-message',
pubkey: 'alice',
createdAt: 40,
kind: 9,
tags: [
['h', _channelB],
],
content: 'hello privately',
sig: 'sig',
),
],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
final channels = await container.read(channelsProvider.future);
await _waitUntil(() => session.queryBatches.length == 2);
expect(session.queryBatches, hasLength(2));
expect(session.queryBatches.first, hasLength(2));
expect(
session.queryBatches.first
.map((filter) => filter.tags['#h']!.single)
.toSet(),
{_channelA, _channelB},
);
expect(session.queryBatches.last, hasLength(2));
expect(
session.queryBatches.last.every(
(filter) => filter.limit == 1000 && filter.since == 0,
),
isTrue,
);
expect(
session.historyFilters.where((filter) {
final kinds = filter.kinds.toSet();
return kinds.length == EventKind.channelMessageEventKinds.length &&
kinds.containsAll(EventKind.channelMessageEventKinds);
}),
isEmpty,
);
expect(
channels.firstWhere((channel) => channel.id == _channelA).lastMessageAt,
DateTime.fromMillisecondsSinceEpoch(30 * 1000, isUtc: true),
);
expect(
channels.firstWhere((channel) => channel.id == _channelB).lastMessageAt,
DateTime.fromMillisecondsSinceEpoch(40 * 1000, isUtc: true),
);
},
);
test('ephemeral (TTL) channels appear in the list', () async {
// Regression: previously the provider unconditionally dropped any channel
// with a `ttl` tag, which made TTL channels invisible on iOS even when the
@@ -668,15 +745,18 @@ class _FakeRelaySession extends RelaySessionNotifier {
required this.memberships,
required this.metadata,
this.hiddenDmEvents = const [],
this.recentMessages = const [],
this.membershipFailures = 0,
});
List<NostrEvent> memberships;
List<NostrEvent> metadata;
final List<NostrEvent> hiddenDmEvents;
final List<NostrEvent> recentMessages;
int membershipFailures;
final List<NostrFilter> historyFilters = [];
final List<List<NostrFilter>> queryBatches = [];
final List<NostrFilter> subscribeFilters = [];
final Map<int, (NostrFilter, void Function(NostrEvent))> _subscriptions = {};
int _nextSubscriptionKey = 0;
@@ -747,6 +827,33 @@ class _FakeRelaySession extends RelaySessionNotifier {
return const [];
}
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
queryBatches.add(filters);
return recentMessages.where((event) {
return filters.any((filter) {
if (!filter.kinds.contains(event.kind)) return false;
for (final entry in filter.tags.entries) {
final tagName = entry.key.startsWith('#')
? entry.key.substring(1)
: entry.key;
if (!event.tags.any(
(tag) =>
tag.length > 1 &&
tag[0] == tagName &&
entry.value.contains(tag[1]),
)) {
return false;
}
}
return true;
});
}).toList();
}
@override
Future<void Function()> subscribe(
NostrFilter filter,

View File

@@ -0,0 +1,82 @@
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/message_mention_pubkeys.dart';
import 'package:flutter_test/flutter_test.dart';
const _self = 'self';
const _agent = 'agent';
const _human = 'human';
void main() {
test('implicitly addresses every participating DM recipient', () {
expect(
messageMentionPubkeys(
channel: _channel(
type: 'dm',
participantPubkeys: const [_self, _agent, _human],
),
senderPubkey: _self,
explicitMentions: const [],
dmRecipientPubkeys: const [_agent, _human],
),
[_agent, _human],
);
});
test('preserves and deduplicates explicit mentions with DM recipients', () {
expect(
messageMentionPubkeys(
channel: _channel(
type: 'dm',
participantPubkeys: const [_self, _agent, _human],
),
senderPubkey: _self,
explicitMentions: const [_human, _agent],
dmRecipientPubkeys: const [_agent],
),
[_human, _agent],
);
});
test('addresses human DMs but not ordinary channel members', () {
expect(
messageMentionPubkeys(
channel: _channel(
type: 'dm',
participantPubkeys: const [_self, _human],
),
senderPubkey: _self,
explicitMentions: const [],
dmRecipientPubkeys: const [_human],
),
[_human],
);
expect(
messageMentionPubkeys(
channel: _channel(
type: 'stream',
participantPubkeys: const [_self, _agent],
),
senderPubkey: _self,
explicitMentions: const [],
dmRecipientPubkeys: const [_agent],
),
isEmpty,
);
});
}
Channel _channel({
required String type,
required List<String> participantPubkeys,
}) => Channel(
id: 'channel',
name: 'Conversation',
channelType: type,
visibility: 'private',
description: '',
createdBy: _self,
createdAt: DateTime(2025),
memberCount: participantPubkeys.length,
participantPubkeys: participantPubkeys,
isMember: true,
);

View File

@@ -3,6 +3,8 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/send_message_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
@@ -68,6 +70,149 @@ void main() {
expect(removedIds, [localMessages.single.id]);
});
test('final signed event addresses the current DM agent member', () async {
final session = _PendingPublishRelaySession();
final signingKey = nostr.Keys.generate().nsec;
final sender = nostr.Keys(
nostr.Nip19.decode(payload: signingKey).data,
).public;
final staleAgent = 'a' * 64;
final activeAgent = 'c' * 64;
final human = 'b' * 64;
final send = SendMessage(
signedEventRelay: SignedEventRelay(session: session, nsec: signingKey),
fetchMembers: (_) async => [
_member(sender),
_member(activeAgent),
_member(human),
],
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);
final result = send(
channelId: _channelId,
content: 'hello without a visible mention',
// Metadata still names the replaced agent. Delivery must follow the
// authoritative current membership snapshot instead.
channel: _dmChannel([sender, staleAgent, human]),
mentionPubkeys: const [],
);
await session.published;
expect(session.event.content, 'hello without a visible mention');
expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [
['p', activeAgent],
['p', human],
]);
session.accept();
await result;
});
test('final signed event addresses a human DM recipient', () async {
final session = _PendingPublishRelaySession();
final signingKey = nostr.Keys.generate().nsec;
final sender = nostr.Keys(
nostr.Nip19.decode(payload: signingKey).data,
).public;
final human = 'b' * 64;
final send = SendMessage(
signedEventRelay: SignedEventRelay(session: session, nsec: signingKey),
fetchMembers: (_) async => [_member(sender), _member(human)],
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);
final result = send(
channelId: _channelId,
content: 'hello human',
channel: _dmChannel([sender, human]),
mentionPubkeys: const [],
);
await session.published;
expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [
['p', human],
]);
session.accept();
await result;
});
test(
'falls back to metadata DM recipients when membership is empty',
() async {
final session = _PendingPublishRelaySession();
final signingKey = nostr.Keys.generate().nsec;
final sender = nostr.Keys(
nostr.Nip19.decode(payload: signingKey).data,
).public;
final recipient = 'b' * 64;
final send = SendMessage(
signedEventRelay: SignedEventRelay(session: session, nsec: signingKey),
fetchMembers: (_) async => const [],
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);
final result = send(
channelId: _channelId,
content: 'hello from an unavailable roster',
channel: _dmChannel([sender, recipient]),
mentionPubkeys: const [],
);
await session.published;
expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [
['p', recipient],
]);
session.accept();
await result;
},
);
test('falls back to metadata DM recipients when membership fails', () async {
final session = _PendingPublishRelaySession();
final signingKey = nostr.Keys.generate().nsec;
final sender = nostr.Keys(
nostr.Nip19.decode(payload: signingKey).data,
).public;
final recipientOne = 'b' * 64;
final recipientTwo = 'c' * 64;
final send = SendMessage(
signedEventRelay: SignedEventRelay(session: session, nsec: signingKey),
fetchMembers: (_) async => throw StateError('membership unavailable'),
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);
final result = send(
channelId: _channelId,
content: 'hello group',
channel: _dmChannel([sender, recipientOne, recipientTwo]),
mentionPubkeys: [recipientOne.toUpperCase()],
);
await session.published;
expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [
['p', recipientOne],
['p', recipientTwo],
]);
session.accept();
await result;
});
test('cancels delivery after the active community changes', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
@@ -95,6 +240,22 @@ void main() {
const _channelId = '11111111-1111-4111-8111-111111111111';
Channel _dmChannel(List<String> participantPubkeys) => Channel(
id: _channelId,
name: 'DM',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: participantPubkeys.first,
createdAt: DateTime(2025),
memberCount: participantPubkeys.length,
participantPubkeys: participantPubkeys,
isMember: true,
);
ChannelMember _member(String pubkey, {String role = 'member'}) =>
ChannelMember(pubkey: pubkey, role: role, joinedAt: DateTime(2025));
class _PendingPublishRelaySession extends RelaySessionNotifier {
final Completer<NostrEvent> _result = Completer<NostrEvent>();
final Completer<void> _published = Completer<void>();

View File

@@ -5,13 +5,20 @@ import 'package:buzz/features/profile/profile_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
void main() {
Widget harness({bool showPresence = true, String presence = 'online'}) {
Widget harness({
bool showPresence = true,
String presence = 'online',
String? avatarUrl,
}) {
return ProviderScope(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
profileProvider.overrideWith(
() => _FakeProfileNotifier(avatarUrl: avatarUrl),
),
presenceProvider.overrideWith(() => _FakePresenceNotifier(presence)),
],
child: MaterialApp(
@@ -31,6 +38,14 @@ void main() {
find.byType(MaskedAvatarBadge),
);
expect(badge.geometry, AvatarBadgeMaskGeometry.presenceDot);
expect(
tester
.widget<ColoredBox>(
find.byKey(const ValueKey('profile-avatar-background')),
)
.color,
AppTheme.light().colorScheme.primaryContainer,
);
expect(
tester.widget<ClipPath>(find.byType(ClipPath).last).clipper,
isA<AvatarBadgeMaskClipper>(),
@@ -86,12 +101,43 @@ void main() {
findsNothing,
);
});
testWidgets('keeps an animated avatar poster background transparent', (
tester,
) async {
const posterUrl = 'https://relay.example/media/poster.png';
const animationUrl = 'https://relay.example/media/animation.png';
final avatarUrl =
'$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}';
await tester.pumpWidget(harness(avatarUrl: avatarUrl));
await tester.pump();
expect(
tester
.widget<ColoredBox>(
find.byKey(const ValueKey('profile-avatar-background')),
)
.color,
Colors.transparent,
);
expect(
tester
.widget<AvatarImageContent>(find.byType(AvatarImageContent))
.imageUrl,
posterUrl,
);
});
}
class _FakeProfileNotifier extends ProfileNotifier {
_FakeProfileNotifier({this.avatarUrl});
final String? avatarUrl;
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'aabb', displayName: 'Test');
UserProfile(pubkey: 'aabb', displayName: 'Test', avatarUrl: avatarUrl);
}
class _FakePresenceNotifier extends PresenceNotifier {

View File

@@ -2,6 +2,8 @@ import 'package:buzz/features/profile/set_status_sheet.dart';
import 'package:buzz/features/profile/user_status.dart';
import 'package:buzz/features/profile/user_status_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -9,7 +11,41 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('removes only the emoji from an existing status', (tester) async {
testWidgets('keeps its resting top edge when the keyboard appears', (
tester,
) async {
addTearDown(tester.view.reset);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
customEmojiListProvider.overrideWithValue(const []),
userStatusProvider.overrideWith(() => _RecordingUserStatusNotifier()),
],
child: Builder(
builder: (context) => FilledButton(
onPressed: () => showSetStatusSheet(context),
child: const Text('Open status editor'),
),
),
),
);
await tester.tap(find.text('Open status editor'));
await tester.pumpAndSettle();
final restingTop = tester.getTopLeft(find.byType(BottomSheet)).dy;
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pumpAndSettle();
expect(
tester.getTopLeft(find.byType(BottomSheet)).dy,
closeTo(restingTop, 0.01),
);
});
testWidgets('uses Buzz rows, desktop presets, and a real duration', (
tester,
) async {
final statusNotifier = _RecordingUserStatusNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
@@ -38,6 +74,25 @@ void main() {
expect(find.text('\u{1F3AF}'), findsOneWidget);
expect(find.byTooltip('Remove status emoji'), findsOneWidget);
expect(find.byKey(const ValueKey('status-input-outline')), findsOneWidget);
expect(find.text('Visible in chats to everyone'), findsNothing);
expect(find.text('Let others know what you\u2019re up to.'), findsNothing);
expect(find.text('1 day'), findsOneWidget);
expect(find.text('In a meeting'), findsOneWidget);
expect(find.text('Commuting'), findsOneWidget);
expect(find.text('Out sick'), findsOneWidget);
expect(find.text('Vacationing'), findsOneWidget);
expect(find.text('Working remotely'), findsOneWidget);
await tester.tap(find.text('1 day'));
await tester.pumpAndSettle();
expect(find.text('1 hour'), findsOneWidget);
expect(find.text('8 hours'), findsOneWidget);
expect(find.text('1 week'), findsOneWidget);
expect(find.text('Custom'), findsOneWidget);
await tester.tap(find.text('8 hours'));
await tester.pumpAndSettle();
expect(find.text('8 hours'), findsOneWidget);
await tester.tap(find.byTooltip('Remove status emoji'));
await tester.pump();
@@ -49,24 +104,151 @@ void main() {
'Focusing',
);
await tester.tap(find.text('Save'));
final beforeSave = DateTime.now();
await tester.tap(find.byTooltip('Save status'));
await tester.pumpAndSettle();
expect(statusNotifier.savedText, 'Focusing');
expect(statusNotifier.savedEmoji, isEmpty);
expect(statusNotifier.savedExpiresAt, isNotNull);
expect(
statusNotifier.savedExpiresAt!.difference(beforeSave).inSeconds,
inInclusiveRange(
const Duration(hours: 8).inSeconds - 2,
const Duration(hours: 8).inSeconds + 2,
),
);
});
testWidgets('opens with an out-of-range remote expiration', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
customEmojiListProvider.overrideWithValue(const []),
userStatusProvider.overrideWith(() => _RecordingUserStatusNotifier()),
],
child: Builder(
builder: (context) => FilledButton(
onPressed: () => showSetStatusSheet(
context,
currentStatus: const UserStatus(
text: 'Focusing',
emoji: '',
updatedAt: 1,
expiresAt: 9_000_000_000_000,
),
),
child: const Text('Open status editor'),
),
),
),
);
await tester.tap(find.text('Open status editor'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Focusing'), findsOneWidget);
expect(find.text('1 day'), findsOneWidget);
});
testWidgets('clamps an existing custom date to the Android picker range', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final expiresAt = DateTime.now().add(const Duration(days: 730));
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
customEmojiListProvider.overrideWithValue(const []),
userStatusProvider.overrideWith(() => _RecordingUserStatusNotifier()),
],
child: Builder(
builder: (context) => FilledButton(
onPressed: () => showSetStatusSheet(
context,
currentStatus: UserStatus(
text: 'Sabbatical',
emoji: '\u{1F3DD}\u{FE0F}',
updatedAt: 1,
expiresAt: expiresAt.millisecondsSinceEpoch ~/ 1000,
),
),
child: const Text('Open status editor'),
),
),
),
);
await tester.tap(find.text('Open status editor'));
await tester.pumpAndSettle();
await tester.tap(find.text('Until'));
await tester.pumpAndSettle();
final picker = tester.widget<DatePickerDialog>(
find.byType(DatePickerDialog),
);
expect(picker.initialDate, picker.lastDate);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('custom duration reveals the native until picker', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final statusNotifier = _RecordingUserStatusNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
customEmojiListProvider.overrideWithValue(const []),
userStatusProvider.overrideWith(() => statusNotifier),
],
child: Builder(
builder: (context) => FilledButton(
onPressed: () => showSetStatusSheet(context),
child: const Text('Open status editor'),
),
),
),
);
await tester.tap(find.text('Open status editor'));
await tester.pumpAndSettle();
await tester.tap(find.text('1 day'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Custom'));
await tester.pumpAndSettle();
await tester.tap(find.text('Custom'));
await tester.pumpAndSettle();
expect(find.byType(CupertinoDatePicker), findsOneWidget);
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.text('Custom'), findsOneWidget);
expect(find.text('Until'), findsOneWidget);
debugDefaultTargetPlatformOverride = null;
});
}
class _RecordingUserStatusNotifier extends UserStatusNotifier {
String? savedText;
String? savedEmoji;
DateTime? savedExpiresAt;
@override
Future<UserStatus?> build() async => null;
@override
Future<void> setStatus(String text, String emoji) async {
Future<void> setStatus(
String text,
String emoji, {
DateTime? expiresAt,
}) async {
savedText = text;
savedEmoji = emoji;
savedExpiresAt = expiresAt;
}
}

View File

@@ -1,18 +1,128 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/settings_profile_header.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/features/profile/user_status.dart';
import 'package:buzz/features/profile/user_status_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:buzz/shared/relay/media_auth.dart';
import 'package:buzz/shared/relay/media_image.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('shows the poster until the animated avatar is ready', (
tester,
) async {
const posterUrl = 'https://relay.example/media/poster.png';
const animationUrl = 'https://relay.example/media/animation.png';
final profileUrl =
'$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}';
final animationResponse = Completer<http.Response>();
final client = http_testing.MockClient(
(request) => request.url.toString() == animationUrl
? animationResponse.future
: Future.value(http.Response.bytes(_transparentPng, 200)),
);
addTearDown(client.close);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(
() => _FakeProfileNotifier(avatarUrl: profileUrl),
),
presenceProvider.overrideWith(() => _FakePresenceNotifier('online')),
userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)),
customEmojiListProvider.overrideWithValue(const []),
mediaGetAuthServiceProvider.overrideWithValue(
MediaGetAuthService(baseUrl: 'https://relay.example', nsec: null),
),
mediaHttpClientProvider.overrideWithValue(client),
],
child: const SettingsProfileHeader(),
),
);
await tester.pump();
await tester.pump();
expect(
find.byKey(const ValueKey('progressive-animated-avatar-poster')),
findsOneWidget,
);
expect(
tester
.widget<ColoredBox>(
find.byKey(const ValueKey('settings-profile-avatar-background')),
)
.color,
Colors.transparent,
);
expect(
find.byKey(
const ValueKey('progressive-animated-avatar-animation-loading'),
),
findsOneWidget,
);
expect(
tester
.widgetList<MediaImage>(find.byType(MediaImage, skipOffstage: false))
.map((image) => image.url),
containsAll([posterUrl, animationUrl]),
);
animationResponse.complete(http.Response.bytes(_transparentPng, 200));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('progressive-animated-avatar-animation-ready')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('progressive-animated-avatar-poster')),
findsNothing,
);
expect(
tester
.widgetList<MediaImage>(find.byType(MediaImage))
.map((image) => image.url),
[animationUrl],
);
await tester.tap(find.byKey(const ValueKey('settings-profile-avatar')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('progressive-animated-avatar-animation')),
findsNothing,
);
expect(tester.widget<MediaImage>(find.byType(MediaImage)).url, posterUrl);
await tester.tap(find.byKey(const ValueKey('settings-profile-avatar')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('progressive-animated-avatar-animation')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('progressive-animated-avatar-poster')),
findsNothing,
);
});
testWidgets('uses a bounded icon for an unresolved status shortcode', (
tester,
) async {
@@ -145,15 +255,19 @@ void main() {
}
class _FakeProfileNotifier extends ProfileNotifier {
_FakeProfileNotifier({this.avatarUrl});
final String? avatarUrl;
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'aabb', displayName: 'Test');
UserProfile(pubkey: 'aabb', displayName: 'Test', avatarUrl: avatarUrl);
}
class _FakeUserStatusNotifier extends UserStatusNotifier {
_FakeUserStatusNotifier(this._status);
final UserStatus _status;
final UserStatus? _status;
@override
Future<UserStatus?> build() async => _status;
@@ -173,3 +287,8 @@ class _FakePresenceNotifier extends PresenceNotifier {
selected.add(status);
}
}
final _transparentPng = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAA'
'AAYAAjCB0C8AAAAASUVORK5CYII=',
);

View File

@@ -0,0 +1,235 @@
import 'package:buzz/features/profile/user_status_cache_provider.dart';
import 'package:buzz/features/profile/user_status.dart';
import 'package:buzz/features/profile/user_status_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
test(
'publishes the selected status expiration on the final signed event',
() async {
final keys = nostr.Keys.generate();
final relaySession = _RecordingRelaySession();
final expiresAt = DateTime.fromMillisecondsSinceEpoch(
1_900_000_000 * 1000,
);
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(
() => _FixedRelayConfigNotifier(keys.nsec),
),
relaySessionProvider.overrideWith(() => relaySession),
userStatusCacheProvider.overrideWith(_EmptyUserStatusCache.new),
],
);
addTearDown(container.dispose);
await container.read(userStatusProvider.future);
await container
.read(userStatusProvider.notifier)
.setStatus(' Focusing ', '\u{1F3AF}', expiresAt: expiresAt);
final event = relaySession.published.single;
expect(event.kind, EventKind.userStatus);
expect(event.content, 'Focusing');
expect(event.tags, contains(equals(['d', 'general'])));
expect(event.tags, contains(equals(['emoji', '\u{1F3AF}'])));
expect(event.tags, contains(equals(['expiration', '1900000000'])));
},
);
testWidgets('clears the current status and shared cache at expiration', (
tester,
) async {
final keys = nostr.Keys.generate();
final relaySession = _RecordingRelaySession();
final statusCache = _RecordingUserStatusCache();
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(
() => _FixedRelayConfigNotifier(keys.nsec),
),
relaySessionProvider.overrideWith(() => relaySession),
userStatusCacheProvider.overrideWith(() => statusCache),
],
);
addTearDown(container.dispose);
await container.read(userStatusProvider.future);
await container
.read(userStatusProvider.notifier)
.setStatus('Focusing', '🎯', expiresAt: DateTime.now());
expect(container.read(userStatusProvider).value, isNotNull);
expect(statusCache.updates.last.$2, isNotNull);
await tester.pump(const Duration(milliseconds: 1));
expect(container.read(userStatusProvider).value, isNull);
expect(statusCache.updates.last, (keys.public.toLowerCase(), null));
});
testWidgets('clears another user status without a new relay event', (
tester,
) async {
final container = ProviderContainer(
overrides: [
relayClientProvider.overrideWithValue(
RelayClient(baseUrl: 'https://relay.example'),
),
relaySessionProvider.overrideWith(_DisconnectedRelaySession.new),
],
);
addTearDown(container.dispose);
final cache = container.read(userStatusCacheProvider.notifier);
cache.updateStatus(
'alice',
UserStatus(
text: 'Focusing',
emoji: '',
updatedAt: 1,
expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
expect(container.read(userStatusCacheProvider)['alice'], isNotNull);
await tester.pump(const Duration(milliseconds: 1));
expect(container.read(userStatusCacheProvider)['alice'], isNull);
});
test('ignores an out-of-range expiration in the shared cache scheduler', () {
final container = ProviderContainer(
overrides: [
relayClientProvider.overrideWithValue(
RelayClient(baseUrl: 'https://relay.example'),
),
relaySessionProvider.overrideWith(_DisconnectedRelaySession.new),
],
);
addTearDown(container.dispose);
final status = UserStatus(
text: 'Focusing',
emoji: '',
updatedAt: 1,
expiresAt: 9_000_000_000_000,
);
expect(status.expirationDateTime, isNull);
expect(
() => container
.read(userStatusCacheProvider.notifier)
.updateStatus('alice', status),
returnsNormally,
);
expect(container.read(userStatusCacheProvider)['alice'], same(status));
});
test(
'ignores an out-of-range expiration in the current-user scheduler',
() async {
final keys = nostr.Keys.generate();
final relaySession = _StatusRelaySession(
NostrEvent(
id: 'status-1',
pubkey: keys.public,
createdAt: 1,
kind: EventKind.userStatus,
tags: const [
['d', 'general'],
['expiration', '9000000000000'],
],
content: 'Focusing',
sig: 'sig',
),
);
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(
() => _FixedRelayConfigNotifier(keys.nsec),
),
relaySessionProvider.overrideWith(() => relaySession),
userStatusCacheProvider.overrideWith(_EmptyUserStatusCache.new),
],
);
addTearDown(container.dispose);
final status = await container.read(userStatusProvider.future);
expect(status?.text, 'Focusing');
expect(status?.expiresAt, 9_000_000_000_000);
expect(status?.expirationDateTime, isNull);
},
);
}
class _FixedRelayConfigNotifier extends RelayConfigNotifier {
_FixedRelayConfigNotifier(this.nsec);
final String nsec;
@override
RelayConfig build() =>
RelayConfig(baseUrl: 'https://relay.example', nsec: nsec);
}
class _RecordingRelaySession extends RelaySessionNotifier {
final List<NostrEvent> published = [];
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => [];
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async {
published.add(event);
return event;
}
}
class _StatusRelaySession extends _RecordingRelaySession {
_StatusRelaySession(this.status);
final NostrEvent status;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => [status];
}
class _DisconnectedRelaySession extends RelaySessionNotifier {
@override
SessionState build() =>
const SessionState(status: SessionStatus.disconnected);
}
class _RecordingUserStatusCache extends UserStatusCacheNotifier {
final List<(String, UserStatus?)> updates = [];
@override
Map<String, UserStatus?> build() => {};
@override
void updateStatus(String pubkey, UserStatus? status) {
updates.add((pubkey, status));
}
}
class _EmptyUserStatusCache extends UserStatusCacheNotifier {
@override
Map<String, UserStatus?> build() => {};
}

View File

@@ -0,0 +1,36 @@
import 'package:buzz/shared/animated_avatar.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const posterUrl = 'https://relay.example/media/poster.png';
const animationUrl = 'https://relay.example/media/animation.png?loop=1';
test('parses the selected poster and animated PNG URLs', () {
final url = '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}';
final parsed = parseAnimatedAvatarUrl(url);
expect(parsed?.posterUrl, posterUrl);
expect(parsed?.animationUrl, animationUrl);
});
test('rejects malformed and non-http animated avatar URLs', () {
expect(parseAnimatedAvatarUrl(posterUrl), isNull);
expect(parseAnimatedAvatarUrl('$posterUrl#buzz-anim='), isNull);
expect(parseAnimatedAvatarUrl('$posterUrl#buzz-anim=%E0%A4%A'), isNull);
expect(parseAnimatedAvatarUrl('$posterUrl#buzz-anim=%'), isNull);
expect(
parseAnimatedAvatarUrl(
'$posterUrl#buzz-anim=${Uri.encodeComponent('javascript:alert(1)')}',
),
isNull,
);
expect(
parseAnimatedAvatarUrl(
'data:image/png;base64,xx#buzz-anim='
'${Uri.encodeComponent(animationUrl)}',
),
isNull,
);
});
}

View File

@@ -105,6 +105,91 @@ void main() {
);
});
test('queryRelay rotates the client after a timeout', () async {
final clients = <_ControlledHttpClient>[];
final session = RelaySessionNotifier(
httpClientFactory: () {
final client = _ControlledHttpClient();
clients.add(client);
return client;
},
);
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => session),
relayConfigProvider.overrideWith(
() => _FakeRelayConfigNotifier(
baseUrl: 'https://relay.example',
nsec: nostr.Keys.generate().nsec,
),
),
],
);
addTearDown(container.dispose);
container.read(relaySessionProvider);
await expectLater(
session.queryRelay(const [], timeout: Duration.zero),
throwsA(isA<TimeoutException>()),
);
expect(clients.single.closed, isTrue);
final nextQuery = session.queryRelay(const []);
expect(clients, hasLength(2));
clients.last.complete(http.Response('[]', 200));
expect(await nextQuery, isEmpty);
expect(clients.last.closed, isFalse);
});
test(
'queryRelay defers closing a timed-out client until peer queries finish',
() async {
final clients = <_QueuedControlledHttpClient>[];
final session = RelaySessionNotifier(
httpClientFactory: () {
final client = _QueuedControlledHttpClient();
clients.add(client);
return client;
},
);
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => session),
relayConfigProvider.overrideWith(
() => _FakeRelayConfigNotifier(
baseUrl: 'https://relay.example',
nsec: nostr.Keys.generate().nsec,
),
),
],
);
addTearDown(container.dispose);
container.read(relaySessionProvider);
await Future<void>.delayed(Duration.zero);
final timedOutQuery = session.queryRelay(
const [],
timeout: const Duration(milliseconds: 10),
);
final peerQuery = session.queryRelay(const []);
expect(clients.single.requestCount, 2);
await expectLater(timedOutQuery, throwsA(isA<TimeoutException>()));
expect(clients.single.closed, isFalse);
final nextQuery = session.queryRelay(const []);
expect(clients, hasLength(2));
clients.first.complete(1, http.Response('[]', 200));
expect(await peerQuery, isEmpty);
expect(clients.first.closed, isTrue);
clients.last.complete(0, http.Response('[]', 200));
expect(await nextQuery, isEmpty);
expect(clients.last.closed, isFalse);
},
);
test('queryRelay arms the rate-limit gate from a 429 retry hint', () async {
final gateTimers = <_ManualTimer>[];
final gate = RelayRateLimitGate(
@@ -1208,6 +1293,59 @@ void main() {
});
}
class _ControlledHttpClient extends http.BaseClient {
final _response = Completer<http.StreamedResponse>();
bool closed = false;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) =>
_response.future;
void complete(http.Response response) {
_response.complete(
http.StreamedResponse(
Stream.value(response.bodyBytes),
response.statusCode,
headers: response.headers,
reasonPhrase: response.reasonPhrase,
request: response.request,
),
);
}
@override
void close() => closed = true;
}
class _QueuedControlledHttpClient extends http.BaseClient {
final List<Completer<http.StreamedResponse>> _responses = [];
bool closed = false;
int get requestCount => _responses.length;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
final response = Completer<http.StreamedResponse>();
_responses.add(response);
return response.future;
}
void complete(int requestIndex, http.Response response) {
_responses[requestIndex].complete(
http.StreamedResponse(
Stream.value(response.bodyBytes),
response.statusCode,
headers: response.headers,
reasonPhrase: response.reasonPhrase,
request: response.request,
),
);
}
@override
void close() => closed = true;
}
class _QueryHarness {
final ProviderContainer container;
final RelaySessionNotifier session;

View File

@@ -4,17 +4,21 @@ import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
'<text x="16" y="24" text-anchor="middle">🦝</text></svg>';
Widget subject(String? imageUrl) => MaterialApp(
home: AvatarImage(
imageUrl: imageUrl,
radius: 16,
fallback: const Text('R'),
Widget subject(String? imageUrl, {Color? backgroundColor}) => ProviderScope(
child: MaterialApp(
home: AvatarImage(
imageUrl: imageUrl,
radius: 16,
backgroundColor: backgroundColor,
fallback: const Text('R'),
),
),
);
@@ -55,6 +59,28 @@ void main() {
expect(find.text('R'), findsOneWidget);
});
testWidgets('renders animated-avatar posters without an opaque background', (
tester,
) async {
const posterUrl = 'https://relay.example/media/poster.png';
const animationUrl = 'https://relay.example/media/animation.png';
final animatedUrl =
'$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}';
await tester.pumpWidget(subject(animatedUrl, backgroundColor: Colors.red));
expect(
tester.widget<CircleAvatar>(find.byType(CircleAvatar)).backgroundColor,
Colors.transparent,
);
expect(
tester
.widget<AvatarImageContent>(find.byType(AvatarImageContent))
.imageUrl,
posterUrl,
);
});
testWidgets('reuses parsed raster bytes across parent rebuilds', (
tester,
) async {

View File

@@ -3,6 +3,7 @@ import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/modal_presentation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -30,6 +31,10 @@ void main() {
),
findsOneWidget,
);
final contentClip = tester.widget<ClipRSuperellipse>(
find.byKey(const ValueKey('concentric-sheet-content-clip')),
);
expect(contentClip.borderRadius, BorderRadius.circular(Radii.dialog));
} finally {
debugDefaultTargetPlatformOverride = null;
}
@@ -75,6 +80,66 @@ void main() {
},
);
testWidgets('native titled sheets leave the concentric surface unobscured', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
const surfaceChannel = MethodChannel('buzz/concentric_sheet_surface');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
surfaceChannel,
(call) async => call.method == 'isSupported' ? true : null,
);
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
title: 'Members',
builder: (_) => const Text('Sheet body'),
),
child: const Text('Open sheet'),
),
),
),
),
);
await tester.tap(find.text('Open sheet'));
await tester.pumpAndSettle();
final nativeSurface = tester.widget<UiKitView>(find.byType(UiKitView));
expect(
nativeSurface.creationParams,
containsPair('color', lightColorScheme.surface.toARGB32()),
);
expect(nativeSurface.creationParams, isNot(contains('headerGradient')));
expect(
find.byKey(const ValueKey('buzz-sheet-header-gradient')),
findsNothing,
);
expect(
find.byKey(const ValueKey('buzz-sheet-surface-clip')),
findsNothing,
);
final contentClip = tester.widget<ClipRSuperellipse>(
find.byKey(const ValueKey('concentric-sheet-content-clip')),
);
expect(contentClip.borderRadius, BorderRadius.circular(Radii.dialog * 2));
expect(contentClip.clipBehavior, Clip.antiAlias);
expect(find.text('Members'), findsOneWidget);
} finally {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
surfaceChannel,
null,
);
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets(
'non-iOS sheets use Flutter drag handle and shared close control',
(tester) async {
@@ -88,6 +153,7 @@ void main() {
builder: (context) => FilledButton(
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
title: 'Sheet title',
showDragHandle: true,
builder: (_) => const Text('Sheet body'),
),
@@ -102,8 +168,51 @@ void main() {
await tester.pumpAndSettle();
final closeButton = find.byTooltip('Close sheet');
final title = find.byKey(const ValueKey('buzz-sheet-title'));
expect(closeButton, findsOneWidget);
expect(title, findsOneWidget);
expect(
find.byKey(const ValueKey('buzz-sheet-surface-clip')),
findsOneWidget,
);
expect(tester.getSize(closeButton), const Size.square(44));
expect(tester.widget<Text>(title).style?.fontSize, 16);
expect(
find.byKey(const ValueKey('buzz-sheet-header-gradient')),
findsNothing,
);
expect(
tester
.widget<ColoredBox>(
find.byKey(const ValueKey('buzz-sheet-surface')),
)
.color,
lightColorScheme.surface,
);
expect(
tester.getTopLeft(find.text('Sheet body')).dy -
tester
.getTopLeft(find.byKey(const ValueKey('buzz-sheet-surface')))
.dy,
80,
);
expect(find.byType(BackdropFilter), findsNothing);
expect(
tester.widget<BottomSheet>(find.byType(BottomSheet)).backgroundColor,
Colors.transparent,
);
expect(
tester.getCenter(title).dx,
closeTo(tester.getCenter(find.byType(BottomSheet)).dx, 0.01),
);
expect(
tester.getCenter(title).dy,
closeTo(tester.getCenter(closeButton).dy, 0.01),
);
expect(
tester.getCenter(closeButton).dx,
greaterThan(tester.getCenter(title).dx),
);
final closeGutter = find.ancestor(
of: closeButton,
matching: find.byWidgetPredicate(
@@ -125,17 +234,33 @@ void main() {
expect(gutterRect.right - closeRect.right, Grid.gutter);
expect(
tester.widget<BottomSheet>(find.byType(BottomSheet)).showDragHandle,
isTrue,
isFalse,
);
expect(
find.byKey(const ValueKey('buzz-sheet-drag-handle')),
findsNothing,
findsOneWidget,
);
expect(
tester.getTopLeft(closeButton).dy -
tester.getTopLeft(find.byType(BottomSheet)).dy,
Grid.gutter,
);
final dismissHandle = find.bySemanticsLabel('Dismiss').first;
expect(dismissHandle, findsOneWidget);
final semantics = tester.getSemantics(dismissHandle);
expect(semantics.flagsCollection.isButton, isTrue);
expect(
semantics.getSemanticsData().hasAction(SemanticsAction.tap),
isTrue,
);
tester.binding.performSemanticsAction(
SemanticsActionEvent(
type: SemanticsAction.tap,
viewId: tester.view.viewId,
nodeId: semantics.id,
),
);
expect(find.text('Sheet body'), findsOneWidget);
await tester.tap(closeButton);
await tester.pumpAndSettle();
expect(find.text('Sheet body'), findsNothing);
} finally {
debugDefaultTargetPlatformOverride = null;
@@ -178,7 +303,14 @@ void main() {
);
expect(internalHandle, findsOneWidget);
expect(tester.getSize(internalHandle), const Size(32, 4));
expect(find.bySemanticsLabel('Drag handle'), findsOneWidget);
final dismissHandle = find.bySemanticsLabel('Dismiss');
expect(dismissHandle, findsOneWidget);
final semantics = tester.getSemantics(dismissHandle);
expect(semantics.flagsCollection.isButton, isTrue);
expect(
semantics.getSemanticsData().hasAction(SemanticsAction.tap),
isTrue,
);
expect(find.byTooltip('Close sheet'), findsOneWidget);
expect(find.text('Sheet body'), findsOneWidget);
} finally {