mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user