Fix mobile composer input regressions (#5594)

## Summary

- reuse the channel member snapshot so first-use `@` suggestions appear
immediately
- reduce selection-only composer rebuilds so iOS selection handles stay
responsive
- make Return insert a newline, send only from the composer button, and
animate multiline growth with reduced-motion support

## Validation

- `just mobile-check`
- `just mobile-test` — 1,271 tests passed
- signed iPhone Release and Pixel 10 debug builds installed and launched
- `just ci` passed mobile, Rust, desktop, and web checks until the
unrelated `buzz-terminal` lifecycle test timed out waiting for `$0`;
reproduced unchanged in isolation

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz
Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-13 17:58:29 +01:00
committed by GitHub
co-authored by Fast Fizz
parent 8abc2baf0b
commit 98d3d77b42
13 changed files with 964 additions and 195 deletions
@@ -68,6 +68,68 @@ class ChannelMember {
}
}
String _channelMemberSnapshotKey({
required String relayBaseUrl,
required String? pubkey,
required String channelId,
}) =>
'${relayBaseUrl.toLowerCase()}::${pubkey?.toLowerCase() ?? 'anon'}::$channelId';
class _ChannelMembersSnapshotCache {
final _membersByKey = <String, List<ChannelMember>>{};
List<ChannelMember>? read({
required String relayBaseUrl,
required String? pubkey,
required String channelId,
}) =>
_membersByKey[_channelMemberSnapshotKey(
relayBaseUrl: relayBaseUrl,
pubkey: pubkey,
channelId: channelId,
)];
void write({
required String relayBaseUrl,
required String? pubkey,
required String channelId,
required List<ChannelMember> members,
}) {
_membersByKey[_channelMemberSnapshotKey(
relayBaseUrl: relayBaseUrl,
pubkey: pubkey,
channelId: channelId,
)] = List.unmodifiable(
members,
);
}
}
final _channelMembersSnapshotCacheProvider =
Provider<_ChannelMembersSnapshotCache>((ref) {
return _ChannelMembersSnapshotCache();
});
/// Uses the relay-backed member list when connected, but keeps the channel
/// snapshot visible while a reconnect temporarily interrupts the refresh.
///
/// The provider retains its current value while disconnected and also keeps a
/// relay/account/channel-scoped snapshot for consumers that mount during the
/// reconnect window. An empty member list is authoritative only after a
/// connected fetch completes.
List<ChannelMember> channelMembersForAutocomplete({
required AsyncValue<List<ChannelMember>> membersAsync,
required SessionStatus sessionStatus,
required List<ChannelMember> cachedMembers,
}) {
final loadedMembers = membersAsync.asData?.value;
if (loadedMembers == null) return cachedMembers;
if (sessionStatus != SessionStatus.connected && loadedMembers.isEmpty) {
return cachedMembers;
}
return loadedMembers;
}
@immutable
class ChannelCanvas {
final String? content;
@@ -421,17 +483,59 @@ final channelDetailsProvider = FutureProvider.family<ChannelDetails, String>((
final channelMembersProvider = FutureProvider.autoDispose
.family<List<ChannelMember>, String>((ref, channelId) async {
ref.watch(channelMembershipUpdateProvider(channelId));
final session = ref.watch(relaySessionProvider.notifier);
final relayBaseUrl = ref.watch(relayConfigProvider).baseUrl;
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
final snapshotCache = ref.read(_channelMembersSnapshotCacheProvider);
// Re-fetch only after reconnect completes. During the disconnected
// interval this provider has no session dependency, so its current value
// remains visible to every consumer rather than becoming AsyncData([]).
ref.listen(relaySessionProvider, (previous, next) {
if (next.status == SessionStatus.connected &&
previous?.status != SessionStatus.connected) {
ref.invalidateSelf();
}
});
final sessionState = ref.read(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) {
final cachedMembers = snapshotCache.read(
relayBaseUrl: relayBaseUrl,
pubkey: pubkey,
channelId: channelId,
);
if (cachedMembers != null) return cachedMembers;
final channelListMembers = ref
.read(channelsProvider.notifier)
.cachedMembersForChannel(channelId);
if (channelListMembers.isNotEmpty) {
snapshotCache.write(
relayBaseUrl: relayBaseUrl,
pubkey: pubkey,
channelId: channelId,
members: channelListMembers,
);
return channelListMembers;
}
return const [];
}
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.channelMembers(channelId),
);
if (events.isEmpty) return const [];
if (events.isEmpty) {
snapshotCache.write(
relayBaseUrl: relayBaseUrl,
pubkey: pubkey,
channelId: channelId,
members: const [],
);
return const [];
}
final event = events.first;
final joinedAt = DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
isUtc: true,
);
return membersFromEvent(event)
final members = membersFromEvent(event)
.map(
(m) => ChannelMember(
pubkey: m.pubkey,
@@ -440,6 +544,13 @@ final channelMembersProvider = FutureProvider.autoDispose
),
)
.toList();
snapshotCache.write(
relayBaseUrl: relayBaseUrl,
pubkey: pubkey,
channelId: channelId,
members: members,
);
return members;
});
/// Channel canvas (kind:40100 for the channel).
@@ -9,7 +9,8 @@ import '../../shared/relay/relay.dart';
import '../../shared/theme/theme_provider.dart';
import '../../shared/utils/string_utils.dart';
import 'channel.dart';
import 'channel_management_provider.dart' show channelDetailsProvider;
import 'channel_management_provider.dart'
show ChannelMember, channelDetailsProvider;
import 'channel_mutes/channel_mutes_provider.dart';
import '../../shared/read_state/read_state_provider.dart';
import 'thread_follows/thread_follows_provider.dart';
@@ -49,6 +50,16 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
Set<String> _authoredRootIds = {};
String? _threadInterestPubkey;
bool _hasLoaded = false;
String? _memberSnapshotRelayBaseUrl;
String? _memberSnapshotPubkey;
Map<String, List<ChannelMember>> _memberSnapshotsByChannelId = const {};
/// The member snapshot already returned while loading the channel list.
///
/// Mention autocomplete can use this synchronously while its independent
/// channel-member refresh is still in flight.
List<ChannelMember> cachedMembersForChannel(String channelId) =>
_memberSnapshotsByChannelId[channelId] ?? const [];
Map<String, int> get latestObservedByChannel =>
Map.unmodifiable(_latestObservedByChannel);
@@ -62,7 +73,14 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
@override
Future<List<Channel>> build() async {
ref.watch(relayConfigProvider);
final relayBaseUrl = ref.watch(relayConfigProvider).baseUrl;
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
if (_memberSnapshotRelayBaseUrl != relayBaseUrl ||
_memberSnapshotPubkey != pubkey) {
_memberSnapshotRelayBaseUrl = relayBaseUrl;
_memberSnapshotPubkey = pubkey;
_memberSnapshotsByChannelId = const {};
}
final connected = Completer<void>();
final sessionState = ref.read(relaySessionProvider);
final waitingForInitialConnection =
@@ -151,6 +169,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
.whereType<String>()
.toSet()
.toList();
_cacheMemberSnapshots(memberships, replaceAll: true);
if (channelIds.isEmpty) {
if (subscribeLive) await _subscribeLive(const []);
return const [];
@@ -234,6 +253,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
limit: channelIds.length,
),
);
if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents);
final memberCounts = <String, int>{};
for (final event in memberEvents) {
final chId = event.getTagValue('d');
@@ -356,6 +376,40 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
return channels;
}
void _cacheMemberSnapshots(
Iterable<NostrEvent> events, {
bool replaceAll = false,
}) {
final latestByChannelId = <String, NostrEvent>{};
for (final event in events) {
final channelId = event.getTagValue('d');
if (channelId == null) continue;
final current = latestByChannelId[channelId];
if (current == null || event.createdAt > current.createdAt) {
latestByChannelId[channelId] = event;
}
}
final snapshots = replaceAll
? <String, List<ChannelMember>>{}
: Map<String, List<ChannelMember>>.of(_memberSnapshotsByChannelId);
snapshots.addAll({
for (final entry in latestByChannelId.entries)
entry.key: List.unmodifiable([
for (final member in membersFromEvent(entry.value))
ChannelMember(
pubkey: member.pubkey,
role: member.role,
joinedAt: DateTime.fromMillisecondsSinceEpoch(
entry.value.createdAt * 1000,
isUtc: true,
),
),
]),
});
_memberSnapshotsByChannelId = Map.unmodifiable(snapshots);
}
Future<Set<String>> _fetchHiddenDmIds(
RelaySessionNotifier session,
String myPk,
@@ -31,7 +31,10 @@ class ComposeBar extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useMemoized(_MarkdownEditingController.new);
useListenable(controller);
final composerText = useListenableSelector(
controller,
() => controller.text,
);
useEffect(() => controller.dispose, [controller]);
// Restore and persist unsent text as a local draft so the Activity
// inbox Drafts filter reflects real composer state.
@@ -198,6 +201,12 @@ class ComposeBar extends HookConsumerWidget {
final channelsAsync = ref.watch(channelsProvider);
final membersAsync = ref.watch(channelMembersProvider(channelId));
final sessionStatus = ref.watch(relaySessionProvider).status;
final cachedMembers = channelsAsync.asData == null
? const <ChannelMember>[]
: ref
.read(channelsProvider.notifier)
.cachedMembersForChannel(channelId);
final currentPubkey = ref.watch(currentPubkeyProvider);
final userCache = ref.watch(userCacheProvider);
final isDmChannel =
@@ -220,7 +229,11 @@ class ComposeBar extends HookConsumerWidget {
}, [controller, agentMentionLabelsKey]);
useEffect(
() {
final memberList = membersAsync.asData?.value ?? <ChannelMember>[];
final memberList = channelMembersForAutocomplete(
membersAsync: membersAsync,
sessionStatus: sessionStatus,
cachedMembers: cachedMembers,
);
final pubkeys = [
...memberList.map((m) => m.pubkey),
...?relayAgents?.map((a) => a.pubkey),
@@ -233,6 +246,7 @@ class ComposeBar extends HookConsumerWidget {
},
[
membersAsync.asData?.value.length,
cachedMembers.length,
relayAgents?.length,
agentOwners?.length,
],
@@ -241,16 +255,22 @@ class ComposeBar extends HookConsumerWidget {
// Typing indicator broadcast — throttled to one event per 3 seconds.
final lastTypingSentMs = useRef(0);
final isModifyingText = useRef(false);
final lastObservedEditingValue = useRef(controller.value);
// Detect @mention query and broadcast typing on text / selection change.
useEffect(() {
lastObservedEditingValue.value = controller.value;
void listener() {
if (isModifyingText.value) return;
final text = controller.text;
final sel = controller.selection;
final editingValue = controller.value;
final previousValue = lastObservedEditingValue.value;
lastObservedEditingValue.value = editingValue;
if (isModifyingText.value || editingValue == previousValue) return;
final text = editingValue.text;
final sel = editingValue.selection;
final textChanged = text != previousValue.text;
// Broadcast typing indicator (throttled).
if (text.isNotEmpty) {
if (textChanged && text.isNotEmpty) {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - lastTypingSentMs.value > _typingThrottleMs) {
lastTypingSentMs.value = now;
@@ -341,24 +361,34 @@ class ComposeBar extends HookConsumerWidget {
mentionMap.value[name] = candidate;
final start = mentionStartIdx.value.clamp(0, controller.text.length);
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '@$name ',
);
isModifyingText.value = true;
try {
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '@$name ',
);
} finally {
isModifyingText.value = false;
}
mentionQuery.value = null;
}
// Insert a selected channel into the text field.
void insertChannel(Channel channel) {
final start = channelStartIdx.value.clamp(0, controller.text.length);
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '#${channel.name} ',
);
isModifyingText.value = true;
try {
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '#${channel.name} ',
);
} finally {
isModifyingText.value = false;
}
channelQuery.value = null;
}
@@ -560,7 +590,7 @@ class ComposeBar extends HookConsumerWidget {
}
}
void queueAttachment(
final queueAttachment = useCallback((
XFile file,
_PendingAttachmentKind kind, {
bool deleteAfterUse = false,
@@ -575,7 +605,7 @@ class ComposeBar extends HookConsumerWidget {
deleteAfterUse: deleteAfterUse,
),
];
}
}, [draftRevision, uploadError, attachments]);
Future<void> pickThenQueue({
required Future<XFile?> Function() pick,
@@ -611,28 +641,28 @@ class ComposeBar extends HookConsumerWidget {
Future<void> retainAndQueueImages(List<XFile> images) =>
_retainAndQueueImages(context, images, queueImages);
Widget buildContextMenu(
BuildContext context,
EditableTextState editableTextState,
) {
void pasteImage() {
ContextMenuController.removeAny();
unawaited(() async {
try {
final image = await ref
.read(mediaUploadServiceProvider)
.readClipboardImage();
if (image != null && context.mounted) {
queueAttachment(image, _PendingAttachmentKind.image);
} else if (context.mounted) {
uploadError.value = 'Unable to read pasted image';
}
} catch (error) {
if (context.mounted) uploadError.value = _formatUploadError(error);
final pasteClipboardImage = useCallback(() {
ContextMenuController.removeAny();
unawaited(() async {
try {
final image = await ref
.read(mediaUploadServiceProvider)
.readClipboardImage();
if (image != null && context.mounted) {
queueAttachment(image, _PendingAttachmentKind.image);
} else if (context.mounted) {
uploadError.value = 'Unable to read pasted image';
}
}());
}
} catch (error) {
if (context.mounted) uploadError.value = _formatUploadError(error);
}
}());
}, [context, ref, queueAttachment, uploadError]);
final buildContextMenu = useCallback<EditableTextContextMenuBuilder>((
context,
editableTextState,
) {
if (defaultTargetPlatform == TargetPlatform.iOS &&
SystemContextMenu.isSupportedByField(editableTextState)) {
return SystemContextMenu.editableText(
@@ -641,7 +671,7 @@ class ComposeBar extends HookConsumerWidget {
if (clipboardHasImage.value)
IOSSystemContextMenuItemCustom(
title: 'Paste Image',
onPressed: pasteImage,
onPressed: pasteClipboardImage,
),
...SystemContextMenu.getDefaultItems(editableTextState),
],
@@ -653,14 +683,17 @@ class ComposeBar extends HookConsumerWidget {
clipboardHasImage.value) {
buttonItems.insert(
0,
ContextMenuButtonItem(label: 'Paste Image', onPressed: pasteImage),
ContextMenuButtonItem(
label: 'Paste Image',
onPressed: pasteClipboardImage,
),
);
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: buttonItems,
);
}
}, [clipboardHasImage, pasteClipboardImage]);
void uploadPastedImage(KeyboardInsertedContent content) {
final bytes = content.data;
@@ -791,6 +824,9 @@ class ComposeBar extends HookConsumerWidget {
? 320
: 250,
);
final resizeDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 140);
final suggestionOverlayController = useMemoized(
OverlayPortalController.new,
);
@@ -923,6 +959,7 @@ class ComposeBar extends HookConsumerWidget {
formattingOpen: showFormatting.value,
onCloseFormatting: () => showFormatting.value = false,
motionDuration: motionDuration,
resizeDuration: resizeDuration,
onFormat: applyFormat,
onMention: () {
attachmentSurface.value = _AttachmentSurface.closed;
@@ -946,7 +983,7 @@ class ComposeBar extends HookConsumerWidget {
showFormatting.value = true;
},
hasPendingUploads: hasPendingUploads,
canSend: controller.text.trim().isNotEmpty || hasAttachments,
canSend: composerText.trim().isNotEmpty || hasAttachments,
isSending: isSending.value,
),
),
@@ -123,13 +123,7 @@ class _ComposerOverlayPortal extends StatelessWidget {
child: ClipRect(
child: Padding(
padding: const EdgeInsets.only(bottom: Grid.xxs),
child: surface == _AttachmentSurface.closed
? _SuggestionPanelMotion(
duration: surfaceDuration,
alignment: Alignment.bottomLeft,
child: buildOverlayPanel(surface),
)
: buildOverlayPanel(surface),
child: buildOverlayPanel(surface),
),
),
),
@@ -42,7 +42,11 @@ void _useComposeDraftLifecycle({
controller.text = saved;
}
var lastPersistedText = controller.text;
void persistDraft() {
final text = controller.text;
if (text == lastPersistedText) return;
lastPersistedText = text;
draftRevision.value += 1;
ref
.read(composeDraftsProvider.notifier)
@@ -50,7 +54,7 @@ void _useComposeDraftLifecycle({
key: draftKey,
channelId: channelId,
threadHeadId: threadHeadId,
text: controller.text,
text: text,
);
}
@@ -100,9 +100,9 @@ void spliceAndMoveCursor(
final before = text.substring(0, start);
final after = text.substring(cursor);
controller.text = '$before$replacement$after';
controller.selection = TextSelection.collapsed(
offset: start + replacement.length,
controller.value = TextEditingValue(
text: '$before$replacement$after',
selection: TextSelection.collapsed(offset: start + replacement.length),
);
focusNode.requestFocus();
}
@@ -124,9 +124,9 @@ void _insertTriggerAtCursor(
final insert = needsSpace ? ' $trigger' : trigger;
final before = text.substring(0, cursor);
final after = text.substring(cursor);
controller.text = '$before$insert$after';
controller.selection = TextSelection.collapsed(
offset: cursor + insert.length,
controller.value = TextEditingValue(
text: '$before$insert$after',
selection: TextSelection.collapsed(offset: cursor + insert.length),
);
focusNode.requestFocus();
}
@@ -19,6 +19,7 @@ class _ComposeBarLayout extends StatelessWidget {
final bool formattingOpen;
final VoidCallback onCloseFormatting;
final Duration motionDuration;
final Duration resizeDuration;
final void Function(String prefix, [String? suffix]) onFormat;
final VoidCallback onMention;
final VoidCallback onChannel;
@@ -47,6 +48,7 @@ class _ComposeBarLayout extends StatelessWidget {
required this.formattingOpen,
required this.onCloseFormatting,
required this.motionDuration,
required this.resizeDuration,
required this.onFormat,
required this.onMention,
required this.onChannel,
@@ -105,34 +107,18 @@ class _ComposeBarLayout extends StatelessWidget {
// Keep the default state out of the focus system entirely so
// restored native focus cannot expand a newly opened channel.
if (isExpanded)
TextField(
controller: controller,
focusNode: focusNode,
textInputAction: TextInputAction.send,
contextMenuBuilder: contextMenuBuilder,
contentInsertionConfiguration: ContentInsertionConfiguration(
allowedMimeTypes: _pastedImageMimeTypes,
onContentInserted: onContentInserted,
),
onSubmitted: (_) => onSend(),
minLines: 1,
maxLines: 5,
style: context.textTheme.bodyLarge,
decoration: InputDecoration(
hintText: resolvedHint,
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.half,
vertical: Grid.half,
),
isDense: true,
),
)
resizeDuration == Duration.zero
? KeyedSubtree(
key: const ValueKey('composer-text-height-motion'),
child: _buildTextField(context),
)
: AnimatedSize(
key: const ValueKey('composer-text-height-motion'),
alignment: Alignment.topCenter,
duration: resizeDuration,
curve: Curves.easeOutCubic,
child: _buildTextField(context),
)
else
Row(
children: [
@@ -263,6 +249,43 @@ class _ComposeBarLayout extends StatelessWidget {
),
);
}
Widget _buildTextField(BuildContext context) {
return TextField(
controller: controller,
focusNode: focusNode,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
contextMenuBuilder: contextMenuBuilder,
// Flutter's Cupertino magnifier rebuilds its overlay on every
// selection-handle update. Keep the iOS handles and native edit menu,
// but let the handles track the finger directly here.
magnifierConfiguration: defaultTargetPlatform == TargetPlatform.iOS
? TextMagnifierConfiguration.disabled
: null,
contentInsertionConfiguration: ContentInsertionConfiguration(
allowedMimeTypes: _pastedImageMimeTypes,
onContentInserted: onContentInserted,
),
minLines: 1,
maxLines: 5,
style: context.textTheme.bodyLarge,
decoration: InputDecoration(
hintText: resolvedHint,
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.half,
vertical: Grid.half,
),
isDense: true,
),
);
}
}
/// Drag the compose bar downward to put the keyboard away.
@@ -16,6 +16,12 @@ class _MarkdownRule {
class _MarkdownEditingController extends TextEditingController {
final Set<String> _agentMentionNames = <String>{};
TextSpan? _cachedTextSpan;
String? _cachedText;
TextRange? _cachedComposingRange;
TextStyle? _cachedBaseStyle;
Color? _cachedOnSurface;
Color? _cachedSurface;
static final _rules = [
_MarkdownRule(
@@ -45,6 +51,7 @@ class _MarkdownEditingController extends TextEditingController {
_agentMentionNames
..clear()
..addAll(next);
_cachedTextSpan = null;
notifyListeners();
}
@@ -59,7 +66,16 @@ class _MarkdownEditingController extends TextEditingController {
withComposing && value.composing.isValid && !value.composing.isCollapsed
? value.composing
: TextRange.empty;
return TextSpan(
final colors = context.colors;
if (_cachedTextSpan case final cached?
when _cachedText == text &&
_cachedComposingRange == composingRange &&
_cachedBaseStyle == baseStyle &&
_cachedOnSurface == colors.onSurface &&
_cachedSurface == colors.surface) {
return cached;
}
final span = TextSpan(
style: baseStyle,
children: _buildMarkdownSpans(
context,
@@ -69,6 +85,13 @@ class _MarkdownEditingController extends TextEditingController {
composingRange: composingRange,
),
);
_cachedText = text;
_cachedComposingRange = composingRange;
_cachedBaseStyle = baseStyle;
_cachedOnSurface = colors.onSurface;
_cachedSurface = colors.surface;
_cachedTextSpan = span;
return span;
}
List<InlineSpan> _buildMarkdownSpans(
@@ -1,99 +1,5 @@
part of '../compose_bar.dart';
class _SuggestionPanelMotion extends HookWidget {
final Duration duration;
final Alignment alignment;
final Widget child;
const _SuggestionPanelMotion({
required this.duration,
required this.alignment,
required this.child,
});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final springController = useAnimationController(
initialValue: 1,
upperBound: 1.08,
);
final springValue = useAnimation(springController);
final previousChildKey = useRef<Key?>(child.key);
useEffect(() {
if (previousChildKey.value == child.key) return null;
previousChildKey.value = child.key;
if (reducedMotion) {
springController.value = 1;
} else {
springController
..stop()
..value = 0.9
..animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 320),
bounce: 0.18,
),
0.9,
1,
0,
snapToEnd: true,
),
);
}
return null;
}, [child.key, reducedMotion]);
return Transform.scale(
scale: springValue,
alignment: alignment,
child: AnimatedSize(
duration: duration,
curve: Curves.easeInOutCubic,
alignment: alignment,
child: AnimatedSwitcher(
duration: duration,
reverseDuration: duration,
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: alignment,
clipBehavior: Clip.none,
children: [...previousChildren, ?currentChild],
),
transitionBuilder: (child, animation) {
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeInOutCubic,
);
return AnimatedBuilder(
animation: curvedAnimation,
child: child,
builder: (context, child) => IgnorePointer(
ignoring: animation.status == AnimationStatus.reverse,
child: Opacity(
opacity: animation.value.clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, Grid.xs * (1 - animation.value)),
child: Transform.scale(
scale: 0.92 + (0.08 * curvedAnimation.value),
alignment: alignment,
child: child,
),
),
),
),
);
},
child: child,
),
),
);
}
}
class _MentionSuggestions extends StatelessWidget {
final List<MentionCandidate> suggestions;
final Map<String, UserProfile> userCache;
@@ -73,15 +73,24 @@ final mentionCandidatesProvider = Provider.family
ref,
args,
) {
final members =
ref.watch(channelMembersProvider(args.channelId)).asData?.value ??
const <ChannelMember>[];
final channelsAsync = ref.watch(channelsProvider);
final membersAsync = ref.watch(channelMembersProvider(args.channelId));
final sessionStatus = ref.watch(relaySessionProvider).status;
final cachedMembers = channelsAsync.asData == null
? const <ChannelMember>[]
: ref
.read(channelsProvider.notifier)
.cachedMembersForChannel(args.channelId);
final members = channelMembersForAutocomplete(
membersAsync: membersAsync,
sessionStatus: sessionStatus,
cachedMembers: cachedMembers,
);
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
final channels =
ref.watch(channelsProvider).asData?.value ?? const <Channel>[];
final channels = channelsAsync.asData?.value ?? const <Channel>[];
final userCache = ref.watch(userCacheProvider);
final currentPubkey = ref.watch(currentPubkeyProvider);
final searchResults =
@@ -222,6 +222,114 @@ void main() {
});
});
group('channelMembersProvider', () {
test('waits for the relay connection before fetching members', () async {
final session = _ConnectionAwareRelaySession();
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
final subscription = container.listen(
channelMembersProvider(_channelId),
(_, _) {},
);
addTearDown(subscription.close);
expect(
await container.read(channelMembersProvider(_channelId).future),
isEmpty,
);
expect(session.historyQueryCount, 0);
session.connect();
await container.pump();
final members = await container.read(
channelMembersProvider(_channelId).future,
);
expect(session.historyQueryCount, 1);
expect(members, hasLength(1));
expect(members.single.pubkey, _memberPubkey);
expect(members.single.role, 'admin');
});
test(
'keeps the provider member snapshot available during reconnect',
() async {
final session = _ConnectionAwareRelaySession();
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
final subscription = container.listen(
channelMembersProvider(_channelId),
(_, _) {},
);
addTearDown(subscription.close);
session.connect();
await container.pump();
final connectedMembers = await container.read(
channelMembersProvider(_channelId).future,
);
expect(connectedMembers, hasLength(1));
expect(session.historyQueryCount, 1);
session.setStatus(SessionStatus.reconnecting);
await container.pump();
final reconnectingMembers = container
.read(channelMembersProvider(_channelId))
.asData
?.value;
expect(reconnectingMembers, connectedMembers);
expect(session.historyQueryCount, 1);
},
);
test('keeps the member snapshot available during reconnect', () {
final cachedMembers = [
ChannelMember(
pubkey: _memberPubkey,
role: 'member',
joinedAt: DateTime.fromMillisecondsSinceEpoch(1000),
),
];
final refreshedMember = ChannelMember(
pubkey: _memberPubkey,
role: 'admin',
joinedAt: DateTime.fromMillisecondsSinceEpoch(2000),
);
expect(
channelMembersForAutocomplete(
membersAsync: const AsyncData([]),
sessionStatus: SessionStatus.connected,
cachedMembers: cachedMembers,
),
isEmpty,
);
expect(
channelMembersForAutocomplete(
membersAsync: const AsyncData([]),
sessionStatus: SessionStatus.reconnecting,
cachedMembers: cachedMembers,
),
same(cachedMembers),
);
expect(
channelMembersForAutocomplete(
membersAsync: AsyncData([refreshedMember]),
sessionStatus: SessionStatus.connected,
cachedMembers: cachedMembers,
),
[refreshedMember],
);
});
});
group('directory providers relay-config invalidation', () {
NostrEvent profile(String pubkey, String name) => NostrEvent(
id: '$pubkey-profile',
@@ -335,6 +443,55 @@ void main() {
});
}
const _channelId = '11111111-1111-4111-8111-111111111111';
const _memberPubkey =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
class _ConnectionAwareRelaySession extends RelaySessionNotifier {
int historyQueryCount = 0;
@override
SessionState build() =>
const SessionState(status: SessionStatus.disconnected);
void connect() {
state = const SessionState(status: SessionStatus.connected);
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
historyQueryCount++;
return [
NostrEvent(
id: 'members',
pubkey: 'owner',
createdAt: 1,
kind: 39002,
tags: const [
['d', _channelId],
['p', _memberPubkey, 'wss://relay.example', 'admin'],
],
content: '',
sig: 'sig',
),
];
}
void setStatus(SessionStatus status) {
state = SessionState(status: status);
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async => () {};
}
/// Fake [RelaySessionNotifier] that serves canned kind:0 profile events from
/// [queryRelay] and counts directory vs. search queries.
class _DirectoryFakeRelaySession extends RelaySessionNotifier {
@@ -21,6 +21,42 @@ import 'package:buzz/shared/relay/relay.dart';
void main() {
const myPk = 'me';
test(
'seeds members from the channel-list snapshot during reconnect',
() async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk, additionalPubkey: 'alice')],
metadata: [_meta(id: _channelA, name: 'general')],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final memberQueryCount = session.historyFilters
.where(
(filter) =>
filter.kinds.contains(39002) && filter.tags['#d'] != null,
)
.length;
session.setStatus(SessionStatus.reconnecting);
final members = await container.read(
channelMembersProvider(_channelA).future,
);
expect(members.map((member) => member.pubkey), [myPk, 'alice']);
expect(
session.historyFilters
.where(
(filter) =>
filter.kinds.contains(39002) && filter.tags['#d'] != null,
)
.length,
memberQueryCount,
);
},
);
test(
'subscribes per-channel with #h tags (only joined, non-archived)',
() async {
@@ -54,6 +90,25 @@ void main() {
},
);
test('retains channel-list member snapshots for immediate reuse', () async {
final joinedAt = DateTime.fromMillisecondsSinceEpoch(1000, isUtc: true);
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk, additionalPubkey: 'alice')],
metadata: [_meta(id: _channelA, name: 'general')],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final members = container
.read(channelsProvider.notifier)
.cachedMembersForChannel(_channelA);
expect(members, hasLength(2));
expect(members.map((member) => member.pubkey), [myPk, 'alice']);
expect(members.every((member) => member.joinedAt == joinedAt), isTrue);
});
test(
'refreshing an unchanged channel set issues zero new live REQs',
() async {
@@ -529,7 +584,11 @@ const _channelB = '22222222-2222-4222-8222-222222222222';
const _channelD = '44444444-4444-4444-8444-444444444444';
/// Build a kind:39002 membership event tagged with the channel id and member.
NostrEvent _membership(String channelId, String pubkey) => NostrEvent(
NostrEvent _membership(
String channelId,
String pubkey, {
String? additionalPubkey,
}) => NostrEvent(
id: 'mem-$channelId',
pubkey: 'creator',
createdAt: 1,
@@ -537,6 +596,7 @@ NostrEvent _membership(String channelId, String pubkey) => NostrEvent(
tags: [
['d', channelId],
['p', pubkey],
if (additionalPubkey != null) ['p', additionalPubkey],
],
content: '',
sig: 'sig',
@@ -173,8 +173,10 @@ Widget _buildComposeBar({
Future<List<ChannelMember>>? membersFuture,
List<AgentDirectoryEntry> relayAgents = const <AgentDirectoryEntry>[],
List<Channel> channels = const <Channel>[],
List<ChannelMember> cachedMembers = const <ChannelMember>[],
String? currentPubkey,
bool? supportsShowingSystemContextMenu,
bool? disableAnimations,
TextScaler? textScaler,
List<CustomEmoji> customEmoji = const <CustomEmoji>[],
RelayConfigNotifier Function()? relayConfig,
@@ -198,14 +200,22 @@ Widget _buildComposeBar({
relayConfig ?? _FakeRelayConfigNotifier.new,
),
savedPrefsProvider.overrideWithValue(_testPrefs),
channelsProvider.overrideWith(() => _FakeChannelsNotifier(channels)),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(channels, cachedMembers: cachedMembers),
),
],
child: MaterialApp(
theme: AppTheme.light(),
builder: supportsShowingSystemContextMenu == null && textScaler == null
builder:
supportsShowingSystemContextMenu == null &&
disableAnimations == null &&
textScaler == null
? null
: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
disableAnimations:
disableAnimations ??
MediaQuery.disableAnimationsOf(context),
supportsShowingSystemContextMenu:
supportsShowingSystemContextMenu ??
MediaQuery.of(context).supportsShowingSystemContextMenu,
@@ -396,8 +406,16 @@ class _RecordingRelaySocket extends RelaySocket {
class _FakeChannelsNotifier extends ChannelsNotifier {
final List<Channel> _channels;
final List<ChannelMember> _cachedMembers;
_FakeChannelsNotifier(this._channels);
_FakeChannelsNotifier(
this._channels, {
List<ChannelMember> cachedMembers = const [],
}) : _cachedMembers = cachedMembers;
@override
List<ChannelMember> cachedMembersForChannel(String channelId) =>
channelId == 'channel-1' ? _cachedMembers : const [];
@override
Future<List<Channel>> build() async => _channels;
@@ -406,6 +424,10 @@ class _FakeChannelsNotifier extends ChannelsNotifier {
Future<void> refresh() async {
state = AsyncData(_channels);
}
void notifyWithCopy() {
state = AsyncData([..._channels]);
}
}
void main() {
@@ -553,6 +575,116 @@ void main() {
);
});
testWidgets('return inserts a newline and sending stays on the button', (
tester,
) async {
var sendCount = 0;
String? sentContent;
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {
sendCount += 1;
sentContent = content;
},
),
);
await _expandComposer(tester);
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.keyboardType, TextInputType.multiline);
expect(textField.textInputAction, TextInputAction.newline);
expect(textField.onSubmitted, isNull);
await tester.enterText(find.byType(TextField), 'First line\nSecond line');
await tester.pumpAndSettle();
expect(sendCount, 0);
expect(textField.controller!.text, 'First line\nSecond line');
final sendButton = find
.ancestor(
of: find.byIcon(LucideIcons.arrowUp),
matching: find.byType(IconButton),
)
.hitTestable();
await tester.tap(sendButton);
await tester.pumpAndSettle();
expect(sendCount, 1);
expect(sentContent, 'First line\nSecond line');
});
testWidgets('smoothly resizes the text field when a new line is added', (
tester,
) async {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), 'First line');
await tester.pumpAndSettle();
final heightMotion = find.byKey(
const ValueKey('composer-text-height-motion'),
);
final animation = tester.widget<AnimatedSize>(heightMotion);
expect(animation.duration, const Duration(milliseconds: 140));
expect(animation.curve, Curves.easeOutCubic);
final oneLineHeight = tester.getSize(heightMotion).height;
await tester.enterText(
find.byType(TextField),
'First line\nSecond line\nThird line',
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 70));
final midResizeHeight = tester.getSize(heightMotion).height;
await tester.pumpAndSettle();
final threeLineHeight = tester.getSize(heightMotion).height;
expect(midResizeHeight, greaterThan(oneLineHeight));
expect(midResizeHeight, lessThan(threeLineHeight));
});
testWidgets('skips composer height motion when animations are disabled', (
tester,
) async {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
disableAnimations: true,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
expect(find.byType(AnimatedSize), findsNothing);
expect(
find.byKey(const ValueKey('composer-text-height-motion')),
findsOneWidget,
);
});
testWidgets('attachment control responds while the composer is expanding', (
tester,
) async {
@@ -702,6 +834,98 @@ void main() {
expect(textField.focusNode!.hasFocus, isTrue);
});
testWidgets('iOS selection handles resize a draft with the system menu', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
try {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
supportsShowingSystemContextMenu: true,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), 'abc def ghi');
await tester.pump();
final editableState = tester.state<EditableTextState>(
find.byType(EditableText),
);
final renderEditable = editableState.renderEditable;
Offset textPosition(int offset) {
final point = renderEditable
.getEndpointsForSelection(TextSelection.collapsed(offset: offset))
.single;
return renderEditable.localToGlobal(point.point) - const Offset(0, 2);
}
final wordPosition = textPosition(5);
await tester.tapAt(wordPosition, pointer: 7);
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(wordPosition, pointer: 7);
await tester.pumpAndSettle();
final controller = tester
.widget<TextField>(find.byType(TextField))
.controller!;
expect(
tester
.widget<TextField>(find.byType(TextField))
.magnifierConfiguration,
same(TextMagnifierConfiguration.disabled),
);
expect(
controller.selection,
const TextSelection(baseOffset: 4, extentOffset: 7),
);
final contextMenuBuilder = tester
.widget<TextField>(find.byType(TextField))
.contextMenuBuilder;
final container = ProviderScope.containerOf(
tester.element(find.byType(ComposeBar)),
);
(container.read(channelsProvider.notifier) as _FakeChannelsNotifier)
.notifyWithCopy();
await tester.pump();
expect(
tester.widget<TextField>(find.byType(TextField)).contextMenuBuilder,
same(contextMenuBuilder),
);
expect(tester.takeException(), isNull);
final endpoint = renderEditable
.getEndpointsForSelection(controller.selection)
.last;
final gesture = await tester.startGesture(
renderEditable.localToGlobal(endpoint.point),
pointer: 7,
);
await tester.pump();
await gesture.moveTo(textPosition(11));
await tester.pump();
await gesture.up();
await tester.pump();
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 11);
expect(tester.takeException(), isNull);
} finally {
await tester.pumpWidget(const SizedBox.shrink());
tester.view.reset();
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('composer controls use selection haptics', (tester) async {
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
@@ -788,6 +1012,141 @@ void main() {
);
});
testWidgets('shows cached member mentions before the refresh completes', (
tester,
) async {
final pendingMembers = Completer<List<ChannelMember>>();
addTearDown(() {
if (!pendingMembers.isCompleted) pendingMembers.complete(const []);
});
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
membersFuture: pendingMembers.future,
cachedMembers: [
ChannelMember(
pubkey: 'a' * 64,
role: 'member',
joinedAt: DateTime.fromMillisecondsSinceEpoch(1000),
displayName: 'Alice',
),
],
channels: [_makeCurrentChannel()],
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
await tester.tap(find.byIcon(LucideIcons.atSign));
await tester.pump();
expect(pendingMembers.isCompleted, isFalse);
expect(
find.byKey(const ValueKey('mention-suggestions-popover')),
findsOneWidget,
);
expect(find.text('Alice'), findsOneWidget);
});
testWidgets('dismisses mention suggestions in the selection frame', (
tester,
) async {
final signer = nostr.Keys.generate();
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(signer.nsec),
currentPubkey: signer.public,
relayAgents: [_testAgent('f' * 64)],
channels: [_makeCurrentChannel(), _makeSharedMemberChannel()],
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), '@');
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('mention-suggestions-popover')),
findsOneWidget,
);
await tester.tap(find.text('Helper Bot'));
await tester.pump();
expect(
find.byKey(const ValueKey('mention-suggestions-popover')),
findsNothing,
);
expect(find.byType(AnimatedSize), findsOneWidget);
expect(
find.byKey(const ValueKey('composer-text-height-motion')),
findsOneWidget,
);
final controller = tester
.widget<TextField>(find.byType(TextField))
.controller!;
expect(controller.text, '@Helper Bot ');
expect(controller.selection, const TextSelection.collapsed(offset: 12));
// Rendering the selected agent chip notifies the editor again. That
// display-only update must not restart the completed mention query.
await tester.pump(const Duration(milliseconds: 300));
expect(
find.byKey(const ValueKey('mention-suggestions-popover')),
findsNothing,
);
});
testWidgets('reuses rich text layout for selection-only movement', (
tester,
) async {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), 'abc def ghi');
await tester.pump();
final textField = tester.widget<TextField>(find.byType(TextField));
final controller = textField.controller!;
final editableContext = tester.element(find.byType(EditableText));
final before = controller.buildTextSpan(
context: editableContext,
style: textField.style,
withComposing: true,
);
controller.selection = const TextSelection(
baseOffset: 4,
extentOffset: 7,
);
final after = controller.buildTextSpan(
context: editableContext,
style: textField.style,
withComposing: true,
);
expect(after, same(before));
});
testWidgets('native All Photos picker failures show an error', (
tester,
) async {
@@ -2438,8 +2797,17 @@ void main() {
)
as SystemContextMenu;
final pasteImage = menu.items.first as IOSSystemContextMenuItemCustom;
final rebuiltMenu =
textField.contextMenuBuilder!(
tester.element(find.byType(TextField)),
editableTextState,
)
as SystemContextMenu;
final rebuiltPasteImage =
rebuiltMenu.items.first as IOSSystemContextMenuItemCustom;
expect(pasteImage.title, 'Paste Image');
expect(rebuiltPasteImage.onPressed, same(pasteImage.onPressed));
expect(menu.items.skip(1), orderedEquals(defaultItems));
pasteImage.onPressed();
await tester.pumpAndSettle();
@@ -3433,6 +3801,29 @@ void main() {
expect(controller.selection.baseOffset, 13); // after "@Alice "
});
test('updates text and selection in one editor notification', () {
final controller = TextEditingController(text: '@ali');
controller.selection = const TextSelection.collapsed(offset: 4);
var notifications = 0;
controller.addListener(() => notifications += 1);
spliceAndMoveCursor(
controller,
FocusNode(),
start: 0,
replacement: '@Alice ',
);
expect(notifications, 1);
expect(
controller.value,
const TextEditingValue(
text: '@Alice ',
selection: TextSelection.collapsed(offset: 7),
),
);
});
test('replaces #channel query with channel name', () {
final controller = TextEditingController(text: 'see #gen for details');
controller.selection = const TextSelection.collapsed(offset: 8);