mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
26
mobile/lib/features/channels/message_mention_pubkeys.dart
Normal file
26
mobile/lib/features/channels/message_mention_pubkeys.dart
Normal 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(),
|
||||
];
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
58
mobile/lib/shared/animated_avatar.dart
Normal file
58
mobile/lib/shared/animated_avatar.dart
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
97
mobile/lib/shared/relay/relay_http_query_client.dart
Normal file
97
mobile/lib/shared/relay/relay_http_query_client.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
121
mobile/lib/shared/widgets/buzz_sheet_header.dart
Normal file
121
mobile/lib/shared/widgets/buzz_sheet_header.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
67
mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart
Normal file
67
mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
77
mobile/lib/shared/widgets/progressive_animated_avatar.dart
Normal file
77
mobile/lib/shared/widgets/progressive_animated_avatar.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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'))),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=',
|
||||
);
|
||||
|
||||
235
mobile/test/features/profile/user_status_provider_test.dart
Normal file
235
mobile/test/features/profile/user_status_provider_test.dart
Normal 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() => {};
|
||||
}
|
||||
36
mobile/test/shared/animated_avatar_test.dart
Normal file
36
mobile/test/shared/animated_avatar_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user