diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index d5ae326afa..f2726b9f13 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -9,6 +9,7 @@ import 'features/activity/inbox_local_state_provider.dart'; import 'features/activity/inbox_read_state.dart'; import 'features/channels/unread_badge/unread_badge_provider.dart'; import 'features/home/home_page.dart'; +import 'features/invites/invite_create_page.dart'; import 'features/pairing/pairing_page.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/deep_link_dispatcher.dart'; @@ -149,6 +150,7 @@ class App extends HookConsumerWidget { Widget _buildSettingsPage(BuildContext context) => SettingsPage( profileHeader: const SettingsProfileHeader(), + invitePageBuilder: (_) => const CommunityInvitePage(), identityRecoveryPageBuilder: (_) => const PairingPage(addingCommunity: true, identityRecoveryOnly: true), ); diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 7f9615d4ab..e33ba30c5d 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -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 = >{}; + + List? 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 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 channelMembersForAutocomplete({ + required AsyncValue> membersAsync, + required SessionStatus sessionStatus, + required List 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(( final channelMembersProvider = FutureProvider.autoDispose .family, 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). diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 5d7b7df866..094f37b3f7 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -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> { Set _authoredRootIds = {}; String? _threadInterestPubkey; bool _hasLoaded = false; + String? _memberSnapshotRelayBaseUrl; + String? _memberSnapshotPubkey; + Map> _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 cachedMembersForChannel(String channelId) => + _memberSnapshotsByChannelId[channelId] ?? const []; Map get latestObservedByChannel => Map.unmodifiable(_latestObservedByChannel); @@ -62,7 +73,14 @@ class ChannelsNotifier extends AsyncNotifier> { @override Future> 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(); final sessionState = ref.read(relaySessionProvider); final waitingForInitialConnection = @@ -151,6 +169,7 @@ class ChannelsNotifier extends AsyncNotifier> { .whereType() .toSet() .toList(); + _cacheMemberSnapshots(memberships, replaceAll: true); if (channelIds.isEmpty) { if (subscribeLive) await _subscribeLive(const []); return const []; @@ -234,6 +253,7 @@ class ChannelsNotifier extends AsyncNotifier> { limit: channelIds.length, ), ); + if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents); final memberCounts = {}; for (final event in memberEvents) { final chId = event.getTagValue('d'); @@ -356,6 +376,40 @@ class ChannelsNotifier extends AsyncNotifier> { return channels; } + void _cacheMemberSnapshots( + Iterable events, { + bool replaceAll = false, + }) { + final latestByChannelId = {}; + 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 + ? >{} + : Map>.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> _fetchHiddenDmIds( RelaySessionNotifier session, String myPk, diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 99af48d44b..d6cea8bd03 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -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 [] + : 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 ?? []; + 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 pickThenQueue({ required Future Function() pick, @@ -611,28 +641,28 @@ class ComposeBar extends HookConsumerWidget { Future retainAndQueueImages(List 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(( + 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, ), ), diff --git a/mobile/lib/features/channels/compose_bar/dock.dart b/mobile/lib/features/channels/compose_bar/dock.dart index f19fe19e46..267aa6cb64 100644 --- a/mobile/lib/features/channels/compose_bar/dock.dart +++ b/mobile/lib/features/channels/compose_bar/dock.dart @@ -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), ), ), ), diff --git a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart index 88d1c8edac..dd58be9da4 100644 --- a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart +++ b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart @@ -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, ); } diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index d5592a13e5..1c324a6457 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -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(); } diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart index 029a4368e2..4d9177663c 100644 --- a/mobile/lib/features/channels/compose_bar/layout.dart +++ b/mobile/lib/features/channels/compose_bar/layout.dart @@ -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. diff --git a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart index bbca57037a..ebd4cd1902 100644 --- a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart +++ b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart @@ -16,6 +16,12 @@ class _MarkdownRule { class _MarkdownEditingController extends TextEditingController { final Set _agentMentionNames = {}; + 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 _buildMarkdownSpans( diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index 7b8e7c175b..9d05858ab8 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -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(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 suggestions; final Map userCache; diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 6e94459231..6ef0c7ec6a 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -73,15 +73,24 @@ final mentionCandidatesProvider = Provider.family ref, args, ) { - final members = - ref.watch(channelMembersProvider(args.channelId)).asData?.value ?? - const []; + final channelsAsync = ref.watch(channelsProvider); + final membersAsync = ref.watch(channelMembersProvider(args.channelId)); + final sessionStatus = ref.watch(relaySessionProvider).status; + final cachedMembers = channelsAsync.asData == null + ? const [] + : ref + .read(channelsProvider.notifier) + .cachedMembersForChannel(args.channelId); + final members = channelMembersForAutocomplete( + membersAsync: membersAsync, + sessionStatus: sessionStatus, + cachedMembers: cachedMembers, + ); final relayAgents = ref.watch(agentDirectoryProvider).asData?.value ?? const []; final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; - final channels = - ref.watch(channelsProvider).asData?.value ?? const []; + final channels = channelsAsync.asData?.value ?? const []; final userCache = ref.watch(userCacheProvider); final currentPubkey = ref.watch(currentPubkeyProvider); final searchResults = diff --git a/mobile/lib/features/invites/invite_create_page.dart b/mobile/lib/features/invites/invite_create_page.dart new file mode 100644 index 0000000000..b6ce235f65 --- /dev/null +++ b/mobile/lib/features/invites/invite_create_page.dart @@ -0,0 +1,166 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/clipboard_utils.dart'; +import '../../shared/community/community_membership_provider.dart'; +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/app_list.dart'; +import '../../shared/widgets/app_list_card.dart'; +import '../../shared/widgets/buzz_action_tile.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/buzz_search_field.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/modal_presentation.dart'; +import 'invite_create_provider.dart'; + +part 'invite_create_page/invite_link_section.dart'; +part 'invite_create_page/person_invite_section.dart'; + +/// A page for authorized community members to create and send invitations. +class CommunityInvitePage extends ConsumerWidget { + /// Creates the community invitation page. + const CommunityInvitePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final roleAsync = ref.watch(currentCommunityRoleProvider); + return FrostedScaffold( + backgroundColor: context.colors.surface, + appBar: const FrostedAppBar(title: Text('Invite to community')), + body: roleAsync.when( + loading: () => const Center( + child: BuzzLoadingIndicator( + size: 48, + semanticLabel: 'Checking community permissions', + ), + ), + error: (_, _) => _InvitePermissionError( + onRetry: () => ref.invalidate(communityMembershipProvider), + ), + data: (role) => canManageCommunityInvites(role) + ? _CommunityInviteBody(role: role!) + : const _InvitePermissionDenied(), + ), + ); + } +} + +class _CommunityInviteBody extends StatelessWidget { + const _CommunityInviteBody({required this.role}); + + final CommunityMemberRole role; + + @override + Widget build(BuildContext context) { + return ListView( + padding: EdgeInsets.fromLTRB( + 0, + frostedAppBarHeight(context) + Grid.xs, + 0, + Grid.lg, + ), + children: [ + const _InviteLinkSection(), + const SizedBox(height: Grid.sm), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: Row( + children: [ + Expanded(child: Divider(color: context.colors.outlineVariant)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + child: Text( + 'Or use npub', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + Expanded(child: Divider(color: context.colors.outlineVariant)), + ], + ), + ), + const SizedBox(height: Grid.sm), + _PersonInviteSection(role: role), + ], + ); + } +} + +class _InvitePermissionError extends StatelessWidget { + const _InvitePermissionError({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return _InviteMessage( + icon: LucideIcons.wifiOff, + title: 'Could not check permissions', + body: 'Reconnect to this community and try again.', + action: TextButton(onPressed: onRetry, child: const Text('Retry')), + ); + } +} + +class _InvitePermissionDenied extends StatelessWidget { + const _InvitePermissionDenied(); + + @override + Widget build(BuildContext context) { + return const _InviteMessage( + icon: LucideIcons.shieldAlert, + title: 'Invite access required', + body: 'Only community owners and admins can invite people.', + ); + } +} + +class _InviteMessage extends StatelessWidget { + const _InviteMessage({ + required this.icon, + required this.title, + required this.body, + this.action, + }); + + final IconData icon; + final String title; + final String body; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(Grid.gutter), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 36, color: context.colors.onSurfaceVariant), + const SizedBox(height: Grid.xs), + Text(title, style: context.textTheme.titleMedium), + const SizedBox(height: Grid.xxs), + Text( + body, + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (action != null) ...[const SizedBox(height: Grid.xs), action!], + ], + ), + ), + ); + } +} + +String _inviteErrorMessage(Object error) => + error.toString().replaceFirst('Exception: ', ''); diff --git a/mobile/lib/features/invites/invite_create_page/invite_link_section.dart b/mobile/lib/features/invites/invite_create_page/invite_link_section.dart new file mode 100644 index 0000000000..e8fcc757fb --- /dev/null +++ b/mobile/lib/features/invites/invite_create_page/invite_link_section.dart @@ -0,0 +1,249 @@ +part of '../invite_create_page.dart'; + +class _InviteLinkSection extends HookConsumerWidget { + const _InviteLinkSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ttlSeconds = useState(defaultCommunityInviteTtlSeconds); + final maxUses = useState(null); + final generationId = useRef(0); + final invite = useState(null); + final isGenerating = useState(true); + final generationError = useState(null); + + Future generate() async { + final requestId = ++generationId.value; + isGenerating.value = true; + invite.value = null; + generationError.value = null; + try { + final created = await ref + .read(communityInviteActionsProvider) + .mintInvite(ttlSeconds: ttlSeconds.value, maxUses: maxUses.value); + if (context.mounted && requestId == generationId.value) { + invite.value = created; + } + } catch (error) { + if (context.mounted && requestId == generationId.value) { + generationError.value = _inviteErrorMessage(error); + } + } finally { + if (context.mounted && requestId == generationId.value) { + isGenerating.value = false; + } + } + } + + useEffect(() { + unawaited(generate()); + return null; + }, [ttlSeconds.value, maxUses.value]); + + final ttlLabel = communityInviteTtlOptions + .firstWhere((option) => option.value == ttlSeconds.value) + .label; + final maxUsesLabel = communityInviteMaxUseOptions + .firstWhere((option) => option.value == maxUses.value) + .label; + Future share(BuildContext buttonContext) async { + final url = invite.value?.url; + if (url == null) return; + final renderBox = buttonContext.findRenderObject() as RenderBox?; + final origin = renderBox == null + ? null + : renderBox.localToGlobal(Offset.zero) & renderBox.size; + try { + await ref.read(shareCommunityInviteProvider)(url, origin); + } catch (_) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not share invite link')), + ); + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: Row( + children: [ + Expanded( + child: Builder( + builder: (buttonContext) => BuzzActionTile( + key: const Key('community-invite-share-link'), + icon: LucideIcons.share2, + label: 'Share', + isEnabled: invite.value != null, + onTap: () => share(buttonContext), + ), + ), + ), + const SizedBox(width: Grid.twelve), + Expanded( + child: BuzzActionTile( + key: const Key('community-invite-copy-link'), + icon: LucideIcons.copy, + label: 'Copy', + isEnabled: invite.value != null, + onTap: () => copyToClipboard( + context, + invite.value!.url, + message: 'Invite link copied', + ), + ), + ), + ], + ), + ), + if (generationError.value case final error?) ...[ + const SizedBox(height: Grid.xs), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: Row( + children: [ + Expanded( + child: Text( + error, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + TextButton(onPressed: generate, child: const Text('Retry')), + ], + ), + ), + ], + const SizedBox(height: Grid.xs), + AppListCard( + key: const Key('community-invite-link-settings'), + dividerIndent: Grid.xs, + children: [ + AppListRow( + key: const Key('community-invite-expiry-setting'), + title: 'Expires after', + value: ttlLabel, + trailing: const _InviteRowChevron(), + onTap: isGenerating.value + ? null + : () => _showInviteOptionSheet( + context: context, + title: 'Expires after', + value: ttlSeconds.value, + options: communityInviteTtlOptions, + onSelected: (value) => ttlSeconds.value = value, + ), + ), + AppListRow( + key: const Key('community-invite-max-uses-setting'), + title: 'Limit number of uses', + value: maxUsesLabel, + trailing: const _InviteRowChevron(), + onTap: isGenerating.value + ? null + : () => _showInviteOptionSheet( + context: context, + title: 'Limit number of uses', + value: maxUses.value, + options: communityInviteMaxUseOptions, + onSelected: (value) => maxUses.value = value, + ), + ), + ], + ), + ], + ); + } +} + +void _showInviteOptionSheet({ + required BuildContext context, + required String title, + required T value, + required List> options, + required ValueChanged onSelected, +}) { + showBuzzModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => _InviteOptionSheet( + title: title, + value: value, + options: options, + onSelected: onSelected, + ), + ); +} + +class _InviteOptionSheet extends StatelessWidget { + const _InviteOptionSheet({ + required this.title, + required this.value, + required this.options, + required this.onSelected, + }); + + final String title; + final T value; + final List> options; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xxs, + ), + child: Text(title, style: context.textTheme.titleMedium), + ), + for (final option in options) + AppListRow( + key: Key('community-invite-option-${_optionKey(option.label)}'), + title: option.label, + trailing: option.value == value + ? Icon( + LucideIcons.check, + size: 18, + color: context.colors.primary, + ) + : null, + onTap: () { + onSelected(option.value); + Navigator.of(context).pop(); + }, + ), + const SizedBox(height: Grid.xxs), + ], + ), + ); + } +} + +class _InviteRowChevron extends StatelessWidget { + const _InviteRowChevron(); + + @override + Widget build(BuildContext context) { + return Icon( + LucideIcons.chevronRight, + size: 18, + color: context.colors.onSurfaceVariant, + ); + } +} + +String _optionKey(String label) => label + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '-') + .replaceAll(RegExp(r'^-|-$'), ''); diff --git a/mobile/lib/features/invites/invite_create_page/person_invite_section.dart b/mobile/lib/features/invites/invite_create_page/person_invite_section.dart new file mode 100644 index 0000000000..ea118e44e0 --- /dev/null +++ b/mobile/lib/features/invites/invite_create_page/person_invite_section.dart @@ -0,0 +1,283 @@ +part of '../invite_create_page.dart'; + +class _PersonInviteSection extends HookConsumerWidget { + const _PersonInviteSection({required this.role}); + + final CommunityMemberRole role; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchController = useTextEditingController(); + final searchFocusNode = useFocusNode(); + final isSearchEditing = useState(false); + final candidatePubkey = useState(null); + final selectedRole = useState(CommunityMemberRole.member); + final isSubmitting = useState(false); + final submitError = useState(null); + + final membership = ref.watch(communityMembershipProvider).asData?.value; + final existingMemberPubkeys = { + ...?membership?.pubkeys.map((pubkey) => pubkey.toLowerCase()), + }; + final currentPubkey = ref.watch(myPubkeyProvider)?.toLowerCase(); + + useEffect(() { + void handleFocusChange() { + isSearchEditing.value = searchFocusNode.hasFocus; + } + + searchFocusNode.addListener(handleFocusChange); + return () => searchFocusNode.removeListener(handleFocusChange); + }, [searchFocusNode]); + + void selectCandidate(String pubkey) { + final normalized = pubkey.toLowerCase(); + if (normalized == currentPubkey) { + candidatePubkey.value = null; + submitError.value = 'You cannot invite yourself.'; + return; + } + if (existingMemberPubkeys.contains(normalized)) { + candidatePubkey.value = null; + submitError.value = 'This person is already in the community.'; + return; + } + candidatePubkey.value = normalized; + submitError.value = null; + } + + void handleInput(String value) { + submitError.value = null; + final pubkey = parseCommunityInvitePubkey(value); + if (pubkey == null) { + candidatePubkey.value = null; + } else { + selectCandidate(pubkey); + } + } + + Future submit(CommunityInviteDirectoryUser invitee) async { + if (isSubmitting.value) return; + isSubmitting.value = true; + submitError.value = null; + try { + await ref + .read(communityInviteActionsProvider) + .inviteMembers( + pubkeys: [invitee.pubkey], + role: role == CommunityMemberRole.owner + ? selectedRole.value + : CommunityMemberRole.member, + ); + if (!context.mounted) return; + candidatePubkey.value = null; + searchController.clear(); + ref.invalidate(communityMembershipProvider); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Invited to the community')), + ); + } catch (error) { + if (context.mounted) { + submitError.value = _inviteErrorMessage(error); + } + } finally { + if (context.mounted) { + isSubmitting.value = false; + } + } + } + + final pubkey = candidatePubkey.value; + final profileAsync = pubkey == null + ? null + : ref.watch(communityInviteProfileProvider(pubkey)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: SizedBox( + key: const Key('community-invite-recipient-field'), + width: double.infinity, + height: buzzSearchIdleFieldHeight, + child: BuzzSearchField( + fieldKey: const Key('community-invite-search'), + controller: searchController, + focusNode: searchFocusNode, + hintText: 'Search npub', + iconColor: navigationPrimaryForeground(context), + inputColor: navigationPrimaryForeground(context), + placeholderColor: navigationSecondaryForeground(context), + surfaceColor: navigationSearchSurface(context), + isEditing: isSearchEditing.value, + reduceMotion: MediaQuery.disableAnimationsOf(context), + motionDuration: const Duration(milliseconds: 160), + autocorrect: false, + enableSuggestions: false, + enabled: !isSubmitting.value, + textInputAction: TextInputAction.done, + onTap: searchFocusNode.requestFocus, + onChanged: handleInput, + onSubmitted: (value) { + final pubkey = parseCommunityInvitePubkey(value); + if (pubkey == null && value.trim().isNotEmpty) { + submitError.value = 'Paste a valid npub.'; + } else if (pubkey != null) { + selectCandidate(pubkey); + } + }, + ), + ), + ), + if (pubkey != null && profileAsync != null) ...[ + const SizedBox(height: Grid.xs), + _InviteeResolutionCard( + pubkey: pubkey, + profileAsync: profileAsync, + role: role, + selectedRole: selectedRole.value, + isSubmitting: isSubmitting.value, + onRetry: () => + ref.invalidate(communityInviteProfileProvider(pubkey)), + onRoleSelected: (value) => selectedRole.value = value, + onInvite: submit, + ), + ], + if (submitError.value case final error?) ...[ + const SizedBox(height: Grid.xxs), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: Text( + error, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ], + ], + ); + } +} + +class _InviteeResolutionCard extends StatelessWidget { + const _InviteeResolutionCard({ + required this.pubkey, + required this.profileAsync, + required this.role, + required this.selectedRole, + required this.isSubmitting, + required this.onRetry, + required this.onRoleSelected, + required this.onInvite, + }); + + final String pubkey; + final AsyncValue profileAsync; + final CommunityMemberRole role; + final CommunityMemberRole selectedRole; + final bool isSubmitting; + final VoidCallback onRetry; + final ValueChanged onRoleSelected; + final ValueChanged onInvite; + + @override + Widget build(BuildContext context) { + return profileAsync.when( + loading: () => AppListCard( + children: [ + AppListRowRaw( + key: Key('community-invite-resolving-$pubkey'), + leading: const SizedBox.square( + dimension: 40, + child: Center( + child: BuzzLoadingIndicator( + size: 20, + semanticLabel: 'Resolving profile', + ), + ), + ), + title: const Text('Resolving profile'), + subtitle: Text(shortCommunityInviteNpub(pubkey)), + ), + ], + ), + error: (_, _) => AppListCard( + children: [ + AppListRow( + key: Key('community-invite-resolution-error-$pubkey'), + title: 'Could not resolve profile', + subtitle: shortCommunityInviteNpub(pubkey), + trailing: TextButton( + onPressed: onRetry, + child: const Text('Retry'), + ), + ), + ], + ), + data: (invitee) { + if (invitee == null) { + return AppListCard( + children: [ + AppListRow( + key: Key('community-invite-unresolved-$pubkey'), + title: 'Invalid npub', + subtitle: shortCommunityInviteNpub(pubkey), + ), + ], + ); + } + return AppListCard( + key: const Key('community-invite-person-card'), + dividerIndent: Grid.xs, + children: [ + AppListRow( + key: Key('community-invite-resolved-$pubkey'), + title: shortCommunityInviteNpub(invitee.pubkey), + trailing: FilledButton( + key: const Key('community-invite-submit'), + onPressed: isSubmitting ? null : () => onInvite(invitee), + child: isSubmitting + ? const BuzzLoadingIndicator( + size: 16, + semanticLabel: 'Inviting person', + ) + : const Text('Invite'), + ), + ), + if (role == CommunityMemberRole.owner) + AppListRow( + key: const Key('community-invite-role'), + title: 'Role', + value: switch (selectedRole) { + CommunityMemberRole.member => 'Member', + CommunityMemberRole.admin => 'Admin', + CommunityMemberRole.owner => 'Owner', + }, + trailing: const _InviteRowChevron(), + onTap: isSubmitting + ? null + : () => _showInviteOptionSheet( + context: context, + title: 'Role', + value: selectedRole, + options: const [ + CommunityInviteOption( + label: 'Member', + value: CommunityMemberRole.member, + ), + CommunityInviteOption( + label: 'Admin', + value: CommunityMemberRole.admin, + ), + ], + onSelected: onRoleSelected, + ), + ), + ], + ); + }, + ); + } +} diff --git a/mobile/lib/features/invites/invite_create_provider.dart b/mobile/lib/features/invites/invite_create_provider.dart new file mode 100644 index 0000000000..5a6eaf2b74 --- /dev/null +++ b/mobile/lib/features/invites/invite_create_provider.dart @@ -0,0 +1,424 @@ +import 'dart:convert'; +import 'dart:ui' show Rect; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:share_plus/share_plus.dart'; + +import '../../shared/community/community_membership_provider.dart'; +import '../../shared/relay/relay.dart'; + +/// The default lifetime of a newly minted community invite link. +const defaultCommunityInviteTtlSeconds = 3 * 24 * 60 * 60; + +/// A labeled value shown in a community invite settings sheet. +@immutable +class CommunityInviteOption { + /// Creates an option with its user-facing [label] and submitted [value]. + const CommunityInviteOption({required this.label, required this.value}); + + /// The text presented for this option. + final String label; + + /// The value applied when this option is selected. + final T value; +} + +/// Supported community invite-link lifetimes. +const communityInviteTtlOptions = [ + CommunityInviteOption(label: '1 day', value: 24 * 60 * 60), + CommunityInviteOption(label: '3 days', value: 3 * 24 * 60 * 60), + CommunityInviteOption(label: '7 days', value: 7 * 24 * 60 * 60), + CommunityInviteOption(label: '30 days', value: 30 * 24 * 60 * 60), +]; + +/// Supported usage limits for a community invite link. +const communityInviteMaxUseOptions = >[ + CommunityInviteOption(label: 'No limit', value: null), + CommunityInviteOption(label: '1 use', value: 1), + CommunityInviteOption(label: '3 uses', value: 3), + CommunityInviteOption(label: '5 uses', value: 5), + CommunityInviteOption(label: '10 uses', value: 10), + CommunityInviteOption(label: '25 uses', value: 25), +]; + +/// A community invite link minted by the active relay. +@immutable +class MintedCommunityInvite { + /// Creates a parsed invite-link response. + const MintedCommunityInvite({ + required this.code, + required this.expiresAt, + required this.url, + required this.maxUses, + required this.usesRemaining, + }); + + /// The relay-issued invite code. + final String code; + + /// The invite's expiration time in Unix seconds. + final int expiresAt; + + /// The complete URL that recipients can open. + final String url; + + /// The total permitted uses, or null when unlimited. + final int? maxUses; + + /// The number of uses still available, or null when unlimited. + final int? usesRemaining; + + /// Parses a relay invite response. + factory MintedCommunityInvite.fromJson(Map json) { + return MintedCommunityInvite( + code: json['code'] as String, + expiresAt: json['expires_at'] as int, + url: json['url'] as String, + maxUses: json['max_uses'] as int?, + usesRemaining: json['uses_remaining'] as int?, + ); + } +} + +/// A relay directory identity that can be invited to a community. +@immutable +class CommunityInviteDirectoryUser { + /// Creates a directory identity for [pubkey] and optional profile metadata. + const CommunityInviteDirectoryUser({ + required this.pubkey, + this.displayName, + this.avatarUrl, + this.nip05Handle, + }); + + /// The identity's lowercase hexadecimal Nostr public key. + final String pubkey; + + /// The profile's preferred display name, when published. + final String? displayName; + + /// The profile image URL, when published. + final String? avatarUrl; + + /// The profile's NIP-05 identifier, when published. + final String? nip05Handle; + + /// The best available human-readable identity label. + String get label { + final display = displayName?.trim(); + if (display != null && display.isNotEmpty) return display; + final nip05 = nip05Handle?.trim(); + if (nip05 != null && nip05.isNotEmpty) return nip05; + return shortCommunityInvitePubkey(pubkey); + } + + /// A supporting identity label distinct from [label]. + String get secondaryLabel { + final nip05 = nip05Handle?.trim(); + if (nip05 != null && nip05.isNotEmpty && nip05 != label) return nip05; + return pubkey.length > 16 ? '${pubkey.substring(0, 16)}…' : pubkey; + } + + /// The uppercase first character of [label], or `?` when unavailable. + String get initial => label.isEmpty ? '?' : label[0].toUpperCase(); +} + +/// Abbreviates a hexadecimal public key for compact display. +String shortCommunityInvitePubkey(String pubkey) => + pubkey.length > 8 ? '${pubkey.substring(0, 8)}…' : pubkey; + +/// Encodes and abbreviates a hexadecimal public key as an npub. +String shortCommunityInviteNpub(String pubkey) { + try { + final npub = nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.npub, + data: pubkey, + ); + return npub.length > 20 + ? '${npub.substring(0, 12)}…${npub.substring(npub.length - 6)}' + : npub; + } catch (_) { + return shortCommunityInvitePubkey(pubkey); + } +} + +/// Parses a hexadecimal public key or npub, returning lowercase hex. +String? parseCommunityInvitePubkey(String value) { + final normalized = value.trim(); + final hexPattern = RegExp(r'^[0-9a-fA-F]{64}$'); + if (hexPattern.hasMatch(normalized)) return normalized.toLowerCase(); + try { + final decoded = nostr.Nip19.decode(payload: normalized); + if (decoded.prefix != nostr.Nip19Prefix.npub || + !hexPattern.hasMatch(decoded.data)) { + return null; + } + return decoded.data.toLowerCase(); + } catch (_) { + return null; + } +} + +/// Builds the Nostr tags for a kind:9030 community member invitation. +@visibleForTesting +List> buildCommunityMemberInviteTags({ + required String pubkey, + required CommunityMemberRole role, +}) => [ + ['p', pubkey.trim().toLowerCase()], + ['role', role.name], +]; + +/// Creates invite links and submits direct community member invitations. +abstract class CommunityInviteActions { + /// Mints a community invite link with the requested limits. + Future mintInvite({ + required int ttlSeconds, + required int? maxUses, + }); + + /// Invites each public key to the active community with [role]. + Future inviteMembers({ + required Iterable pubkeys, + required CommunityMemberRole role, + }); +} + +/// Relay-backed implementation of [CommunityInviteActions]. +class RelayCommunityInviteActions implements CommunityInviteActions { + /// Creates invite actions bound to the active relay and signing session. + RelayCommunityInviteActions({ + required http.Client httpClient, + required String baseUrl, + required String? nsec, + required SignedEventRelay signedEventRelay, + required bool Function() isCommunityActive, + }) : _httpClient = httpClient, + _baseUrl = baseUrl, + _nsec = nsec, + _signedEventRelay = signedEventRelay, + _isCommunityActive = isCommunityActive; + + final http.Client _httpClient; + final String _baseUrl; + final String? _nsec; + final SignedEventRelay _signedEventRelay; + final bool Function() _isCommunityActive; + + void _ensureCommunityActive() { + if (!_isCommunityActive()) { + throw StateError('Invite cancelled because the active community changed'); + } + } + + @override + Future mintInvite({ + required int ttlSeconds, + required int? maxUses, + }) async { + _ensureCommunityActive(); + final url = Uri.parse(_baseUrl).resolve('/api/invites').toString(); + final body = {'ttl_secs': ttlSeconds}; + if (maxUses != null) body['max_uses'] = maxUses; + final bodyBytes = utf8.encode(jsonEncode(body)); + final response = await _httpClient + .post( + Uri.parse(url), + headers: { + 'Authorization': buildNip98AuthHeader( + method: 'POST', + url: url, + bodyBytes: bodyBytes, + nsec: _nsec, + ), + 'Content-Type': 'application/json', + }, + body: bodyBytes, + ) + .timeout(const Duration(seconds: 15)); + _ensureCommunityActive(); + + final dynamic decoded; + try { + decoded = jsonDecode(response.body); + } on FormatException { + throw Exception('Relay returned an invalid invite response'); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + final rawMessage = decoded is Map + ? decoded['error'] + : null; + final message = rawMessage is String ? rawMessage : null; + throw Exception(message ?? 'HTTP ${response.statusCode}'); + } + if (decoded is! Map) { + throw Exception('Relay returned an invalid invite response'); + } + return MintedCommunityInvite.fromJson(decoded); + } + + @override + Future inviteMembers({ + required Iterable pubkeys, + required CommunityMemberRole role, + }) async { + final normalizedPubkeys = {}; + for (final pubkey in pubkeys) { + final parsed = parseCommunityInvitePubkey(pubkey); + if (parsed != null) normalizedPubkeys.add(parsed); + } + for (final pubkey in normalizedPubkeys) { + _ensureCommunityActive(); + await _signedEventRelay.submit( + kind: EventKind.relayAdminAddMember, + content: '', + tags: buildCommunityMemberInviteTags(pubkey: pubkey, role: role), + ); + } + _ensureCommunityActive(); + } +} + +/// Supplies the HTTP client used to mint community invite links. +final communityInviteHttpClientProvider = Provider((ref) { + final client = http.Client(); + ref.onDispose(client.close); + return client; +}); + +/// Supplies invite operations bound to the current community session. +final communityInviteActionsProvider = Provider((ref) { + final config = ref.watch(relayConfigProvider); + final session = ref.read(relaySessionProvider.notifier); + return RelayCommunityInviteActions( + httpClient: ref.watch(communityInviteHttpClientProvider), + baseUrl: config.baseUrl, + nsec: config.nsec, + signedEventRelay: SignedEventRelay(session: session, nsec: config.nsec), + isCommunityActive: () { + final current = ref.read(relayConfigProvider); + return current.baseUrl == config.baseUrl && current.nsec == config.nsec; + }, + ); +}); + +List _directoryUsersFromEvents( + List events, +) { + final latestByPubkey = {}; + for (final event in events) { + if (event.kind != 0) continue; + final pubkey = event.pubkey.toLowerCase(); + final current = latestByPubkey[pubkey]; + if (current == null || event.createdAt > current.createdAt) { + latestByPubkey[pubkey] = event; + } + } + final users = [ + for (final event in latestByPubkey.values) + if (ProfileData.fromEvent(event) case final profile) + CommunityInviteDirectoryUser( + pubkey: profile.pubkey.toLowerCase(), + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + nip05Handle: profile.nip05, + ), + ]; + users.sort((a, b) { + final byLabel = a.label.toLowerCase().compareTo(b.label.toLowerCase()); + return byLabel != 0 ? byLabel : a.pubkey.compareTo(b.pubkey); + }); + return users; +} + +/// Resolves a pasted pubkey to its published profile on the active relay. +/// +/// A valid npub already identifies an exact public key, so missing kind:0 +/// metadata falls back to a pubkey-only person instead of blocking the invite. +/// Relay failures still surface as errors and malformed inputs return null. +final communityInviteProfileProvider = FutureProvider.autoDispose + .family((ref, pubkey) async { + final normalized = parseCommunityInvitePubkey(pubkey); + if (normalized == null) return null; + ref.watch(relayConfigProvider); + final events = await ref.watch(relaySessionProvider.notifier).queryRelay([ + NostrFilters.profile(normalized), + ]); + for (final user in _directoryUsersFromEvents(events)) { + if (user.pubkey == normalized) return user; + } + return CommunityInviteDirectoryUser(pubkey: normalized); + }); + +/// Lists inviteable relay identities, excluding the signed-in user. +final communityInviteDirectoryProvider = + FutureProvider.autoDispose>((ref) async { + ref.watch(relayConfigProvider); + final currentPubkey = ref.watch(myPubkeyProvider)?.toLowerCase(); + final events = await ref.watch(relaySessionProvider.notifier).queryRelay([ + const NostrFilter(kinds: [0], limit: 50, extensions: {'page': 1}), + ]); + final directoryUsers = _directoryUsersFromEvents( + events, + ).where((user) => user.pubkey != currentPubkey).toList(); + if (directoryUsers.isNotEmpty) return directoryUsers; + + // Match the existing mobile person picker: older relays may not support + // a paged kind:0 directory, so fall back to the membership snapshot and + // hydrate whatever profiles are available. + final membership = await ref.watch(communityMembershipProvider.future); + final memberPubkeys = membership.pubkeys + .where((pubkey) => pubkey != currentPubkey) + .toList(); + if (memberPubkeys.isEmpty) return const []; + final profileEvents = await ref + .watch(relaySessionProvider.notifier) + .queryRelay([NostrFilters.profilesBatch(memberPubkeys)]); + final profilesByPubkey = { + for (final user in _directoryUsersFromEvents(profileEvents)) + user.pubkey: user, + }; + final fallbackUsers = [ + for (final pubkey in memberPubkeys) + profilesByPubkey[pubkey] ?? + CommunityInviteDirectoryUser(pubkey: pubkey), + ]; + fallbackUsers.sort((a, b) { + final byLabel = a.label.toLowerCase().compareTo(b.label.toLowerCase()); + return byLabel != 0 ? byLabel : a.pubkey.compareTo(b.pubkey); + }); + return fallbackUsers; + }); + +/// Searches the active relay for inviteable identities matching a query. +final communityInviteDirectorySearchProvider = FutureProvider.autoDispose + .family, String>((ref, query) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) { + return ref.watch(communityInviteDirectoryProvider.future); + } + ref.watch(relayConfigProvider); + final currentPubkey = ref.watch(myPubkeyProvider)?.toLowerCase(); + final events = await ref.watch(relaySessionProvider.notifier).queryRelay([ + NostrFilters.searchUsers(trimmed, limit: 50), + ]); + return _directoryUsersFromEvents( + events, + ).where((user) => user.pubkey != currentPubkey).toList(); + }); + +/// Shares [inviteUrl] from an optional platform anchor rectangle. +typedef ShareCommunityInvite = + Future Function(String inviteUrl, Rect? sharePositionOrigin); + +/// Supplies the native share-sheet action for community invite links. +final shareCommunityInviteProvider = Provider((ref) { + return (inviteUrl, sharePositionOrigin) async { + await SharePlus.instance.share( + ShareParams(text: inviteUrl, sharePositionOrigin: sharePositionOrigin), + ); + }; +}); diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 9700c69ba1..d89c64a947 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -10,7 +10,7 @@ import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/utils/string_utils.dart'; import '../../shared/widgets/avatar_image.dart'; -import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/buzz_action_tile.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; @@ -216,7 +216,7 @@ class UserProfileSheet extends HookConsumerWidget { children: [ if (pk != currentPubkey) ...[ Expanded( - child: _ProfileActionTile( + child: BuzzActionTile( icon: isOpeningDirectMessage.value ? null : LucideIcons.messageSquare, @@ -224,13 +224,14 @@ class UserProfileSheet extends HookConsumerWidget { ? 'Opening…' : 'Message', isLoading: isOpeningDirectMessage.value, + loadingSemanticLabel: 'Opening direct message', onTap: openDirectMessage, ), ), const SizedBox(width: Grid.twelve), ], Expanded( - child: _ProfileActionTile( + child: BuzzActionTile( icon: copied.value ? LucideIcons.check : LucideIcons.key, @@ -371,59 +372,6 @@ class _ProfilePresenceChip extends StatelessWidget { } } -class _ProfileActionTile extends StatelessWidget { - const _ProfileActionTile({ - required this.icon, - required this.label, - required this.onTap, - this.isLoading = false, - }); - - final IconData? icon; - final String label; - final VoidCallback onTap; - final bool isLoading; - - @override - Widget build(BuildContext context) => GestureDetector( - onTap: isLoading - ? null - : () { - unawaited(HapticFeedback.lightImpact()); - onTap(); - }, - behavior: HitTestBehavior.opaque, - child: Container( - width: double.infinity, - height: 68 + (Grid.xxs * 2), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.dialog), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (isLoading) - BuzzLoadingIndicator( - size: 22, - color: context.colors.onSurface, - semanticLabel: 'Opening direct message', - ) - else - Icon(icon, size: 22, color: context.colors.onSurface), - const SizedBox(height: Grid.xxs), - Text( - label, - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurface, - ), - ), - ], - ), - ), - ); -} - class _ProfileAvatar extends StatelessWidget { final String? avatarUrl; final String initial; diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index b97d10f55f..0615a19634 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -9,6 +9,7 @@ import '../../shared/mentions/mention_tags.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/buzz_search_field.dart'; import '../../shared/widgets/filter_chip_bar.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; @@ -27,8 +28,6 @@ import '../profile/user_profile.dart'; import 'recent_searches_provider.dart'; import 'search_provider.dart'; -part 'search_page/motion_field.dart'; - enum _SearchFilter { all, messages, channels, people } const _searchFieldMinHeight = 36.0; @@ -318,14 +317,15 @@ class SearchPage extends HookConsumerWidget { // native input connection before the keyboard is shown. child: SizedBox( key: const Key('search-field-container'), - child: _SearchMotionField( + child: BuzzSearchField( controller: textController, focusNode: focusNode, + hintText: 'Search messages, channels, and people', iconColor: searchPrimaryColor, inputColor: searchPrimaryColor, placeholderColor: searchPlaceholderColor, surfaceColor: searchSurfaceColor, - isSearchEditing: isSearchEditing.value, + isEditing: isSearchEditing.value, reduceMotion: reduceMotion, motionDuration: _searchFieldMoveDuration, onTap: activateSearch, diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 066dd1fd3d..40b4d77987 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -10,6 +10,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../../shared/auth/auth.dart'; import '../../shared/clipboard_utils.dart'; +import '../../shared/community/community_membership_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list.dart'; @@ -21,16 +22,25 @@ import 'accent_picker_page.dart'; import 'theme_picker_page.dart'; part 'settings_page/appearance_section.dart'; +part 'settings_page/community_section.dart'; part 'settings_page/connection_section.dart'; class SettingsPage extends HookConsumerWidget { + /// Creates the settings page. const SettingsPage({ super.key, required this.profileHeader, + required this.invitePageBuilder, required this.identityRecoveryPageBuilder, }); + /// Header widget displayed at the top of settings. final Widget profileHeader; + + /// Builds the community-invite page pushed from the invite settings row. + final WidgetBuilder invitePageBuilder; + + /// Builds the identity-recovery page pushed from the recovery settings row. final WidgetBuilder identityRecoveryPageBuilder; @override @@ -71,6 +81,7 @@ class SettingsPage extends HookConsumerWidget { padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs), children: [ profileHeader, + _CommunitySection(invitePageBuilder: invitePageBuilder), const _AppearanceSection(), _ConnectionSection( identityRecoveryPageBuilder: identityRecoveryPageBuilder, diff --git a/mobile/lib/features/settings/settings_page/community_section.dart b/mobile/lib/features/settings/settings_page/community_section.dart new file mode 100644 index 0000000000..b6a8c1b927 --- /dev/null +++ b/mobile/lib/features/settings/settings_page/community_section.dart @@ -0,0 +1,29 @@ +part of '../settings_page.dart'; + +class _CommunitySection extends ConsumerWidget { + const _CommunitySection({required this.invitePageBuilder}); + + final WidgetBuilder invitePageBuilder; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final roleAsync = ref.watch(currentCommunityRoleProvider); + if (!roleAsync.hasError && !canManageCommunityInvites(roleAsync.value)) { + return const SizedBox.shrink(); + } + + return AppListCard( + label: 'Community', + children: [ + AppListRow( + icon: LucideIcons.userPlus, + title: 'Invite to community', + trailing: const _RowChevron(), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: invitePageBuilder)), + ), + ], + ); + } +} diff --git a/mobile/lib/shared/community/community_membership_provider.dart b/mobile/lib/shared/community/community_membership_provider.dart new file mode 100644 index 0000000000..4e1eb6c8a0 --- /dev/null +++ b/mobile/lib/shared/community/community_membership_provider.dart @@ -0,0 +1,127 @@ +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../relay/relay.dart'; + +/// Role levels assigned by a community membership snapshot. +enum CommunityMemberRole { + /// Full community ownership permissions. + owner, + + /// Community administration permissions. + admin, + + /// Standard community membership permissions. + member, +} + +/// A member and their role in the active community. +@immutable +class CommunityMember { + /// Creates a community member. + const CommunityMember({required this.pubkey, required this.role}); + + /// The member's normalized hexadecimal public key. + final String pubkey; + + /// The member's role in the community. + final CommunityMemberRole role; +} + +/// The latest membership list published by the active community. +@immutable +class CommunityMembershipSnapshot { + /// Creates a community membership snapshot. + const CommunityMembershipSnapshot({ + required this.snapshotFound, + required this.members, + }); + + /// Whether the relay returned a membership snapshot event. + final bool snapshotFound; + + /// The valid members parsed from the latest snapshot. + final List members; + + /// Returns the role assigned to [pubkey], or `null` when it is not a member. + CommunityMemberRole? roleFor(String? pubkey) { + final normalized = pubkey?.trim().toLowerCase(); + if (normalized == null || normalized.isEmpty) return null; + for (final member in members) { + if (member.pubkey == normalized) return member.role; + } + return null; + } + + /// The normalized public keys in this membership snapshot. + Set get pubkeys => {for (final member in members) member.pubkey}; +} + +CommunityMemberRole _communityMemberRole(String? value) => switch (value) { + 'owner' => CommunityMemberRole.owner, + 'admin' => CommunityMemberRole.admin, + _ => CommunityMemberRole.member, +}; + +/// Parses the current kind:13534 membership snapshot. +/// +/// Buzz emits `["member", pubkey, role]`. Older NIP-29-compatible relays may +/// use `["p", pubkey, relay, role]`, so mobile accepts both forms just like +/// desktop does. +@visibleForTesting +CommunityMembershipSnapshot communityMembershipFromEvents( + List events, +) { + if (events.isEmpty) { + return const CommunityMembershipSnapshot(snapshotFound: false, members: []); + } + + final event = events.reduce( + (latest, candidate) => + candidate.createdAt > latest.createdAt ? candidate : latest, + ); + final members = []; + final seen = {}; + final pubkeyPattern = RegExp(r'^[0-9a-f]{64}$'); + + for (final tag in event.tags) { + if (tag.length < 2 || (tag[0] != 'member' && tag[0] != 'p')) continue; + final pubkey = tag[1].trim().toLowerCase(); + if (!pubkeyPattern.hasMatch(pubkey) || !seen.add(pubkey)) continue; + final role = tag[0] == 'member' + ? (tag.length >= 3 ? tag[2] : null) + : (tag.length >= 4 ? tag[3] : null); + members.add( + CommunityMember(pubkey: pubkey, role: _communityMemberRole(role)), + ); + } + + return CommunityMembershipSnapshot(snapshotFound: true, members: members); +} + +/// Relay membership for the active community. +/// +/// The HTTP query resolves from the first response rather than waiting for a +/// WebSocket EOSE frame, and watching [relayConfigProvider] makes the result +/// community-scoped. +final communityMembershipProvider = + FutureProvider.autoDispose((ref) async { + ref.watch(relayConfigProvider); + final session = ref.watch(relaySessionProvider.notifier); + final events = await session.queryRelay([NostrFilters.relayMembers()]); + return communityMembershipFromEvents(events); + }); + +/// The active user's role in the current community. +final currentCommunityRoleProvider = Provider>( + (ref) { + final pubkey = ref.watch(myPubkeyProvider); + return ref + .watch(communityMembershipProvider) + .whenData((snapshot) => snapshot.roleFor(pubkey)); + }, +); + +/// Whether [role] is allowed to create community invitations. +bool canManageCommunityInvites(CommunityMemberRole? role) => + role == CommunityMemberRole.owner || role == CommunityMemberRole.admin; diff --git a/mobile/lib/shared/relay/nostr_filters.dart b/mobile/lib/shared/relay/nostr_filters.dart index 5cea037f9d..d9d7a0b6f7 100644 --- a/mobile/lib/shared/relay/nostr_filters.dart +++ b/mobile/lib/shared/relay/nostr_filters.dart @@ -203,7 +203,7 @@ abstract final class NostrFilters { /// Relay membership list (kind:13534). static NostrFilter relayMembers() => - const NostrFilter(kinds: [13534], limit: 1); + const NostrFilter(kinds: [EventKind.relayMembership], limit: 1); /// Agent profiles (kind:10100). static NostrFilter agentProfiles() => diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 820fee4ed6..43b34c0e17 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -10,6 +10,12 @@ abstract final class EventKind { static const contactList = 3; static const deletion = 5; static const reaction = 7; + + /// Kind:9030 event requesting that the relay add a community member. + static const relayAdminAddMember = 9030; + + /// Kind:13534 event containing the current relay-community membership. + static const relayMembership = 13534; static const streamMessage = 9; static const nip29DeleteEvent = 9005; static const presenceUpdate = 20001; diff --git a/mobile/lib/shared/widgets/app_list_card.dart b/mobile/lib/shared/widgets/app_list_card.dart index 20a569e2e3..00815e14b9 100644 --- a/mobile/lib/shared/widgets/app_list_card.dart +++ b/mobile/lib/shared/widgets/app_list_card.dart @@ -7,11 +7,21 @@ import 'app_list_inset.dart'; /// it. Rows inside are hairline-separated and inset to the card rather than the /// page, via [AppListInset]. class AppListCard extends StatelessWidget { - const AppListCard({super.key, this.label, required this.children}); + const AppListCard({ + super.key, + this.label, + this.dividerIndent, + required this.children, + }); /// Rendered above the card in sentence case, as written — no uppercasing. final String? label; + /// Separator inset from the card edge. Defaults to the standard row label + /// column, clearing a leading icon. Icon-free cards can pass [_inset] so + /// separators align with their row content on both sides. + final double? dividerIndent; + final List children; static const _inset = Grid.xs; @@ -29,7 +39,7 @@ class AppListCard extends StatelessWidget { Divider( height: 1, thickness: 1, - indent: _dividerIndent, + indent: dividerIndent ?? _dividerIndent, endIndent: _inset, // The scheme's own border tokens are derived from the page surface, // which lands them within a few levels of the card fill — invisible. diff --git a/mobile/lib/shared/widgets/buzz_action_tile.dart b/mobile/lib/shared/widgets/buzz_action_tile.dart new file mode 100644 index 0000000000..88469293ad --- /dev/null +++ b/mobile/lib/shared/widgets/buzz_action_tile.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../theme/theme.dart'; +import 'buzz_loading_indicator.dart'; + +/// Equal-width icon action used by profile and profile-adjacent surfaces. +class BuzzActionTile extends StatelessWidget { + /// Creates an action tile with an optional loading state. + const BuzzActionTile({ + super.key, + required this.icon, + required this.label, + required this.onTap, + this.isEnabled = true, + this.isLoading = false, + this.loadingSemanticLabel, + }); + + /// Icon shown when the tile is not loading. + final IconData? icon; + + /// Label shown below the icon. + final String label; + + /// Called when the tile is tapped. + final VoidCallback onTap; + + /// Whether the tile accepts taps. + final bool isEnabled; + + /// Whether to show a loading indicator instead of [icon]. + final bool isLoading; + + /// Accessibility label for the loading indicator. + final String? loadingSemanticLabel; + + @override + Widget build(BuildContext context) { + final canTap = isEnabled && !isLoading; + return GestureDetector( + onTap: canTap + ? () { + unawaited(HapticFeedback.lightImpact()); + onTap(); + } + : null, + behavior: HitTestBehavior.opaque, + child: Opacity( + opacity: isEnabled ? 1 : 0.5, + child: Container( + width: double.infinity, + height: 68 + (Grid.xxs * 2), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.dialog), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isLoading) + BuzzLoadingIndicator( + size: 22, + color: context.colors.onSurface, + semanticLabel: loadingSemanticLabel ?? label, + ) + else + Icon(icon, size: 22, color: context.colors.onSurface), + const SizedBox(height: Grid.xxs), + Text( + label, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurface, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/search/search_page/motion_field.dart b/mobile/lib/shared/widgets/buzz_search_field.dart similarity index 53% rename from mobile/lib/features/search/search_page/motion_field.dart rename to mobile/lib/shared/widgets/buzz_search_field.dart index 96a23849a1..d6e5530a17 100644 --- a/mobile/lib/features/search/search_page/motion_field.dart +++ b/mobile/lib/shared/widgets/buzz_search_field.dart @@ -1,43 +1,100 @@ -part of '../search_page.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; -const _searchIdleIconSize = 26.0; -const _searchCompactIconSize = 18.0; -const _searchFieldHint = 'Search messages, channels, and people'; -const _searchIdleIconInset = Grid.xxs; -const _searchIdleTextInset = +import '../theme/theme.dart'; + +/// Height of an idle [BuzzSearchField]. +const double buzzSearchIdleFieldHeight = 45; + +/// Font size used for idle [BuzzSearchField] text. +const double buzzSearchIdleTextSize = 15; +const double _searchIdleIconSize = 26; +const double _searchCompactIconSize = 18; +const double _searchIdleIconInset = Grid.xxs; +const double _searchIdleTextInset = _searchIdleIconInset + _searchIdleIconSize + Grid.xxs; -const _searchCompactTextInset = +const double _searchCompactTextInset = _searchIdleIconInset + _searchCompactIconSize + Grid.xxs; -class _SearchMotionField extends StatelessWidget { - final TextEditingController controller; - final FocusNode focusNode; - final Color iconColor; - final Color inputColor; - final Color placeholderColor; - final Color surfaceColor; - final bool isSearchEditing; - final bool reduceMotion; - final Duration motionDuration; - final VoidCallback onTap; - final ValueChanged onChanged; - final ValueChanged onSubmitted; - - const _SearchMotionField({ +/// Buzz's global-search text field treatment, shared by search-like inputs. +class BuzzSearchField extends StatelessWidget { + /// Creates a search field with Buzz's shared styling. + const BuzzSearchField({ required this.controller, required this.focusNode, + required this.hintText, required this.iconColor, required this.inputColor, required this.placeholderColor, required this.surfaceColor, - required this.isSearchEditing, + required this.isEditing, required this.reduceMotion, required this.motionDuration, required this.onTap, required this.onChanged, required this.onSubmitted, + this.fieldKey = const Key('search-field'), + this.autocorrect = true, + this.enableSuggestions = true, + this.enabled = true, + this.textInputAction = TextInputAction.search, + super.key, }); + /// Controller that owns the field's editable text. + final TextEditingController controller; + + /// Node that controls the field's focus. + final FocusNode focusNode; + + /// Placeholder shown while the field is idle and empty. + final String hintText; + + /// Color applied to the search icon. + final Color iconColor; + + /// Color applied to entered text. + final Color inputColor; + + /// Color applied to [hintText]. + final Color placeholderColor; + + /// Background color of the field. + final Color surfaceColor; + + /// Whether the field is in its compact editing state. + final bool isEditing; + + /// Whether to suppress field animations for reduced-motion users. + final bool reduceMotion; + + /// Duration used by the field's editing-state animation. + final Duration motionDuration; + + /// Called when the field is tapped. + final VoidCallback onTap; + + /// Called whenever the field's text changes. + final ValueChanged onChanged; + + /// Called when the user submits the field. + final ValueChanged onSubmitted; + + /// Key assigned to the underlying text field. + final Key fieldKey; + + /// Whether the text field should autocorrect user input. + final bool autocorrect; + + /// Whether the platform should offer text suggestions. + final bool enableSuggestions; + + /// Whether the text field accepts user input. + final bool enabled; + + /// Action displayed on the keyboard's submit key. + final TextInputAction textInputAction; + @override Widget build(BuildContext context) => DecoratedBox( decoration: BoxDecoration( @@ -52,33 +109,36 @@ class _SearchMotionField extends StatelessWidget { child: SizedBox( width: double.infinity, child: TextField( - key: const Key('search-field'), + key: fieldKey, controller: controller, focusNode: focusNode, + autocorrect: autocorrect, + enableSuggestions: enableSuggestions, + enabled: enabled, decoration: InputDecoration( - hintText: isSearchEditing ? null : _searchFieldHint, + hintText: isEditing ? null : hintText, hintStyle: searchInputTextStyle.copyWith( color: placeholderColor, - fontSize: _searchIdleTextSize, - height: 20 / _searchIdleTextSize, + fontSize: buzzSearchIdleTextSize, + height: 20 / buzzSearchIdleTextSize, ), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, isDense: true, contentPadding: EdgeInsets.only( - left: isSearchEditing + left: isEditing ? _searchCompactTextInset : _searchIdleTextInset, right: Grid.xxs, - top: isSearchEditing ? _searchFieldVerticalPadding : 0, - bottom: isSearchEditing ? _searchFieldVerticalPadding : 0, + top: isEditing ? Grid.xxs : 0, + bottom: isEditing ? Grid.xxs : 0, ), ), style: searchInputTextStyle.copyWith(color: inputColor), textAlignVertical: TextAlignVertical.center, textAlign: TextAlign.start, - textInputAction: TextInputAction.search, + textInputAction: textInputAction, onTap: onTap, onChanged: onChanged, onSubmitted: onSubmitted, @@ -94,7 +154,7 @@ class _SearchMotionField extends StatelessWidget { child: AnimatedScale( duration: reduceMotion ? Duration.zero : motionDuration, curve: Curves.easeInOutCubic, - scale: isSearchEditing + scale: isEditing ? _searchCompactIconSize / _searchIdleIconSize : 1, child: Icon( diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index 89659f37e4..619a011f8f 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -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> 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 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 { diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index c128b9be5a..116a977852 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -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', diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 1d6d4518c6..beb83784cb 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -173,8 +173,10 @@ Widget _buildComposeBar({ Future>? membersFuture, List relayAgents = const [], List channels = const [], + List cachedMembers = const [], String? currentPubkey, bool? supportsShowingSystemContextMenu, + bool? disableAnimations, TextScaler? textScaler, List customEmoji = const [], 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 _channels; + final List _cachedMembers; - _FakeChannelsNotifier(this._channels); + _FakeChannelsNotifier( + this._channels, { + List cachedMembers = const [], + }) : _cachedMembers = cachedMembers; + + @override + List cachedMembersForChannel(String channelId) => + channelId == 'channel-1' ? _cachedMembers : const []; @override Future> build() async => _channels; @@ -406,6 +424,10 @@ class _FakeChannelsNotifier extends ChannelsNotifier { Future 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 >[], + }) async { + sendCount += 1; + sentContent = content; + }, + ), + ); + + await _expandComposer(tester); + final textField = tester.widget(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 >[], + }) 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(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 >[], + }) 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 >[], + }) async {}, + ), + ); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), 'abc def ghi'); + await tester.pump(); + + final editableState = tester.state( + 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(find.byType(TextField)) + .controller!; + expect( + tester + .widget(find.byType(TextField)) + .magnifierConfiguration, + same(TextMagnifierConfiguration.disabled), + ); + expect( + controller.selection, + const TextSelection(baseOffset: 4, extentOffset: 7), + ); + + final contextMenuBuilder = tester + .widget(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(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 = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -788,6 +1012,141 @@ void main() { ); }); + testWidgets('shows cached member mentions before the refresh completes', ( + tester, + ) async { + final pendingMembers = Completer>(); + 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 >[], + }) 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 >[], + }) 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(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 >[], + }) async {}, + ), + ); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), 'abc def ghi'); + await tester.pump(); + + final textField = tester.widget(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); diff --git a/mobile/test/features/invites/invite_create_page_test.dart b/mobile/test/features/invites/invite_create_page_test.dart new file mode 100644 index 0000000000..9aecfd10e5 --- /dev/null +++ b/mobile/test/features/invites/invite_create_page_test.dart @@ -0,0 +1,448 @@ +import 'dart:async'; + +import 'package:buzz/features/invites/invite_create_page.dart'; +import 'package:buzz/features/invites/invite_create_provider.dart'; +import 'package:buzz/shared/community/community_membership_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + const owner = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const alice = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + testWidgets('owner can invite a pasted npub and share generated link', ( + tester, + ) async { + tester.view.physicalSize = const Size(430, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final actions = _FakeInviteActions(); + String? sharedUrl; + final hapticCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call); + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(CommunityMemberRole.owner), + ), + communityMembershipProvider.overrideWith( + (ref) async => const CommunityMembershipSnapshot( + snapshotFound: true, + members: [ + CommunityMember(pubkey: owner, role: CommunityMemberRole.owner), + ], + ), + ), + myPubkeyProvider.overrideWithValue(owner), + communityInviteDirectoryProvider.overrideWith( + (ref) async => const [ + CommunityInviteDirectoryUser( + pubkey: alice, + displayName: 'Alice', + nip05Handle: 'alice@example.com', + ), + ], + ), + communityInviteDirectorySearchProvider.overrideWith( + (ref, query) async => const [], + ), + communityInviteProfileProvider.overrideWith( + (ref, pubkey) async => CommunityInviteDirectoryUser( + pubkey: pubkey, + displayName: 'Resolved person', + nip05Handle: 'person@example.com', + ), + ), + communityInviteActionsProvider.overrideWithValue(actions), + shareCommunityInviteProvider.overrideWithValue((url, origin) async { + sharedUrl = url; + }), + ], + child: MaterialApp( + theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS), + home: const CommunityInvitePage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(actions.mintRequests, [(defaultCommunityInviteTtlSeconds, null)]); + expect( + find.text('Add someone directly or share a link they can use to join.'), + findsNothing, + ); + expect(find.text('Invite people'), findsNothing); + expect(find.text('Search by name or paste an npub.'), findsNothing); + expect(find.text('Alice'), findsNothing); + expect( + find.byKey(const Key('community-invite-person-$alice')), + findsNothing, + ); + expect(find.text('https://relay.example.com/invite/1'), findsNothing); + expect(find.text('Invite link ready'), findsNothing); + expect(find.text('Invite link'), findsNothing); + expect(find.text('Creating invite link…'), findsNothing); + expect(find.text('Or use npub'), findsOneWidget); + expect(find.text('Search npub'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Share'), findsOneWidget); + expect(find.text('Messages'), findsNothing); + expect(find.text('Mail'), findsNothing); + expect(find.text('WhatsApp'), findsNothing); + expect(find.text('Gmail'), findsNothing); + expect(find.text('Telegram'), findsNothing); + expect(find.text('X'), findsNothing); + expect( + tester.getSize(find.byKey(const Key('community-invite-search'))).width, + greaterThan(350), + ); + final shareSize = tester.getSize( + find.byKey(const Key('community-invite-share-link')), + ); + final copySize = tester.getSize( + find.byKey(const Key('community-invite-copy-link')), + ); + expect(shareSize, copySize); + expect(shareSize.height, 68 + (Grid.xxs * 2)); + expect( + tester + .getTopLeft(find.byKey(const Key('community-invite-share-link'))) + .dx, + lessThan( + tester + .getTopLeft(find.byKey(const Key('community-invite-copy-link'))) + .dx, + ), + ); + expect(tester.widget(find.byIcon(LucideIcons.share2)).size, 22); + expect(tester.widget(find.byIcon(LucideIcons.copy)).size, 22); + final settingsDivider = find.descendant( + of: find.byKey(const Key('community-invite-link-settings')), + matching: find.byType(Divider), + ); + final divider = tester.widget(settingsDivider); + expect(divider.indent, Grid.xs); + expect(divider.endIndent, Grid.xs); + expect( + tester.getRect(settingsDivider).left + divider.indent!, + closeTo(tester.getRect(find.text('Expires after')).left, 0.5), + ); + + const pastedPubkey = + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + final npub = nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.npub, + data: pastedPubkey, + ); + await tester.enterText( + find.byKey(const Key('community-invite-search')), + npub, + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('community-invite-resolved-$pastedPubkey')), + findsOneWidget, + ); + expect(find.text(shortCommunityInviteNpub(pastedPubkey)), findsOneWidget); + expect(find.text('Resolved person'), findsNothing); + expect(find.text('person@example.com'), findsNothing); + expect( + find.byKey(const Key('community-invite-person-$pastedPubkey')), + findsNothing, + ); + expect(find.text('Role'), findsOneWidget); + expect(find.byIcon(LucideIcons.shield), findsNothing); + final personDivider = find.descendant( + of: find.byKey(const Key('community-invite-person-card')), + matching: find.byType(Divider), + ); + expect(personDivider, findsOneWidget); + final personDividerWidget = tester.widget(personDivider); + expect(personDividerWidget.indent, Grid.xs); + expect(personDividerWidget.endIndent, Grid.xs); + await tester.tap(find.byKey(const Key('community-invite-submit'))); + await tester.pumpAndSettle(); + + expect(actions.memberInvites, hasLength(1)); + expect(actions.memberInvites.single.$1, [pastedPubkey]); + expect(actions.memberInvites.single.$2, CommunityMemberRole.member); + + await tester.ensureVisible( + find.byKey(const Key('community-invite-share-link')), + ); + await tester.tap(find.byKey(const Key('community-invite-share-link'))); + await tester.pumpAndSettle(); + expect(sharedUrl, 'https://relay.example.com/invite/1'); + + await tester.ensureVisible( + find.byKey(const Key('community-invite-copy-link')), + ); + await tester.tap(find.byKey(const Key('community-invite-copy-link'))); + await tester.pumpAndSettle(); + expect( + hapticCalls.map((call) => call.arguments), + everyElement('HapticFeedbackType.lightImpact'), + ); + expect(hapticCalls, hasLength(2)); + }); + + testWidgets('pasted npub remains inviteable without profile metadata', ( + tester, + ) async { + tester.view.physicalSize = const Size(430, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final actions = _FakeInviteActions(); + final profileResolution = Completer(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(CommunityMemberRole.admin), + ), + communityMembershipProvider.overrideWith( + (ref) async => const CommunityMembershipSnapshot( + snapshotFound: true, + members: [], + ), + ), + myPubkeyProvider.overrideWithValue(owner), + communityInviteProfileProvider.overrideWith( + (ref, pubkey) => profileResolution.future, + ), + communityInviteActionsProvider.overrideWithValue(actions), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: const CommunityInvitePage(), + ), + ), + ); + await tester.pumpAndSettle(); + + const pastedPubkey = + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + final npub = nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.npub, + data: pastedPubkey, + ); + await tester.enterText( + find.byKey(const Key('community-invite-search')), + npub, + ); + await tester.pump(); + + expect( + find.byKey(const Key('community-invite-resolving-$pastedPubkey')), + findsOneWidget, + ); + expect(find.byKey(const Key('community-invite-submit')), findsNothing); + expect(actions.memberInvites, isEmpty); + + profileResolution.complete( + const CommunityInviteDirectoryUser(pubkey: pastedPubkey), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('community-invite-resolved-$pastedPubkey')), + findsOneWidget, + ); + expect(find.text('Profile not found'), findsNothing); + expect(find.text(shortCommunityInviteNpub(pastedPubkey)), findsOneWidget); + expect(find.byKey(const Key('community-invite-submit')), findsOneWidget); + + await tester.tap(find.byKey(const Key('community-invite-submit'))); + await tester.pumpAndSettle(); + + expect(actions.memberInvites, hasLength(1)); + expect(actions.memberInvites.single.$1, [pastedPubkey]); + expect(actions.memberInvites.single.$2, CommunityMemberRole.member); + }); + + testWidgets('invite completion does not update state after page disposal', ( + tester, + ) async { + tester.view.physicalSize = const Size(430, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final actions = _FakeInviteActions() + ..memberInviteCompleter = Completer(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(CommunityMemberRole.admin), + ), + communityMembershipProvider.overrideWith( + (ref) async => const CommunityMembershipSnapshot( + snapshotFound: true, + members: [], + ), + ), + myPubkeyProvider.overrideWithValue(owner), + communityInviteProfileProvider.overrideWith( + (ref, pubkey) async => CommunityInviteDirectoryUser(pubkey: pubkey), + ), + communityInviteActionsProvider.overrideWithValue(actions), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: const CommunityInvitePage(), + ), + ), + ); + await tester.pumpAndSettle(); + + const pastedPubkey = + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + final npub = nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.npub, + data: pastedPubkey, + ); + await tester.enterText( + find.byKey(const Key('community-invite-search')), + npub, + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-invite-submit'))); + await tester.pump(); + + await tester.pumpWidget(const SizedBox.shrink()); + actions.memberInviteCompleter!.complete(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('link settings remint with desktop expiry and use options', ( + tester, + ) async { + tester.view.physicalSize = const Size(430, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final actions = _FakeInviteActions(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(CommunityMemberRole.admin), + ), + communityMembershipProvider.overrideWith( + (ref) async => const CommunityMembershipSnapshot( + snapshotFound: true, + members: [], + ), + ), + communityInviteDirectoryProvider.overrideWith( + (ref) async => const [], + ), + communityInviteActionsProvider.overrideWithValue(actions), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: const CommunityInvitePage(), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const Key('community-invite-max-uses-setting')), + ); + await tester.pumpAndSettle(); + expect(find.text('Limit number of uses'), findsNWidgets(2)); + expect(find.byIcon(LucideIcons.check), findsOneWidget); + await tester.tap(find.byKey(const Key('community-invite-option-5-uses'))); + await tester.pumpAndSettle(); + + expect(actions.mintRequests, [ + (defaultCommunityInviteTtlSeconds, null), + (defaultCommunityInviteTtlSeconds, 5), + ]); + + await tester.tap(find.byKey(const Key('community-invite-expiry-setting'))); + await tester.pumpAndSettle(); + expect(find.text('Expires after'), findsNWidgets(2)); + expect(find.byIcon(LucideIcons.check), findsOneWidget); + await tester.tap(find.byKey(const Key('community-invite-option-7-days'))); + await tester.pumpAndSettle(); + + expect(actions.mintRequests.last, (7 * 24 * 60 * 60, 5)); + }); + + testWidgets('plain members cannot open invite tools', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(CommunityMemberRole.member), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: const CommunityInvitePage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Invite access required'), findsOneWidget); + expect(find.byKey(const Key('community-invite-search')), findsNothing); + }); +} + +class _FakeInviteActions implements CommunityInviteActions { + final List<(int, int?)> mintRequests = []; + final List<(List, CommunityMemberRole)> memberInvites = []; + Completer? memberInviteCompleter; + + @override + Future inviteMembers({ + required Iterable pubkeys, + required CommunityMemberRole role, + }) async { + memberInvites.add((pubkeys.toList(), role)); + await memberInviteCompleter?.future; + } + + @override + Future mintInvite({ + required int ttlSeconds, + required int? maxUses, + }) async { + mintRequests.add((ttlSeconds, maxUses)); + return MintedCommunityInvite( + code: '${mintRequests.length}', + expiresAt: 12345, + url: 'https://relay.example.com/invite/${mintRequests.length}', + maxUses: maxUses, + usesRemaining: maxUses, + ); + } +} diff --git a/mobile/test/features/invites/invite_create_provider_test.dart b/mobile/test/features/invites/invite_create_provider_test.dart new file mode 100644 index 0000000000..521b18f740 --- /dev/null +++ b/mobile/test/features/invites/invite_create_provider_test.dart @@ -0,0 +1,164 @@ +import 'dart:convert'; + +import 'package:buzz/features/invites/invite_create_provider.dart'; +import 'package:buzz/shared/community/community_membership_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + test('accepts hex and npub inputs but rejects other NIP-19 values', () { + final keys = nostr.Keys.generate(); + + expect(parseCommunityInvitePubkey(keys.public), keys.public); + expect(parseCommunityInvitePubkey(keys.public.toUpperCase()), keys.public); + expect(parseCommunityInvitePubkey(keys.npub), keys.public); + expect(parseCommunityInvitePubkey(keys.nsec), isNull); + expect(parseCommunityInvitePubkey('not-a-pubkey'), isNull); + }); + + test('builds the desktop-compatible kind:9030 role tags', () { + const pubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + expect( + buildCommunityMemberInviteTags( + pubkey: pubkey.toUpperCase(), + role: CommunityMemberRole.admin, + ), + [ + ['p', pubkey], + ['role', 'admin'], + ], + ); + }); + + test( + 'valid npub falls back to its pubkey when profile metadata is absent', + () async { + final keys = nostr.Keys.generate(); + final session = _ProfileRelaySession(const []); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(_TestRelayConfigNotifier.new), + relaySessionProvider.overrideWith(() => session), + ], + ); + addTearDown(container.dispose); + + final invitee = await container.read( + communityInviteProfileProvider(keys.npub).future, + ); + + expect(invitee?.pubkey, keys.public); + expect(invitee?.displayName, isNull); + expect(session.queryCount, 1); + }, + ); + + test('mints an unlimited invite with exact NIP-98 request shape', () async { + final keys = nostr.Keys.generate(); + late http.Request captured; + final client = http_testing.MockClient((request) async { + captured = request; + return http.Response( + jsonEncode({ + 'code': 'invite-code', + 'expires_at': 12345, + 'url': 'https://relay.example.com/invite/invite-code', + 'max_uses': null, + 'uses_remaining': null, + }), + 200, + ); + }); + final service = RelayCommunityInviteActions( + httpClient: client, + baseUrl: 'https://relay.example.com', + nsec: keys.nsec, + signedEventRelay: SignedEventRelay( + session: _UnusedRelaySession(), + nsec: keys.nsec, + ), + isCommunityActive: () => true, + ); + + final invite = await service.mintInvite( + ttlSeconds: defaultCommunityInviteTtlSeconds, + maxUses: null, + ); + + expect(captured.method, 'POST'); + expect(captured.url, Uri.parse('https://relay.example.com/api/invites')); + expect(captured.headers['content-type'], 'application/json'); + expect(captured.headers['authorization'], startsWith('Nostr ')); + expect(jsonDecode(captured.body), { + 'ttl_secs': defaultCommunityInviteTtlSeconds, + }); + expect(invite.code, 'invite-code'); + expect(invite.maxUses, isNull); + expect(invite.usesRemaining, isNull); + }); + + test('includes a selected maximum-use limit', () async { + final keys = nostr.Keys.generate(); + late http.Request captured; + final client = http_testing.MockClient((request) async { + captured = request; + return http.Response( + jsonEncode({ + 'code': 'limited', + 'expires_at': 12345, + 'url': 'https://relay.example.com/invite/limited', + 'max_uses': 5, + 'uses_remaining': 5, + }), + 200, + ); + }); + final service = RelayCommunityInviteActions( + httpClient: client, + baseUrl: 'https://relay.example.com', + nsec: keys.nsec, + signedEventRelay: SignedEventRelay( + session: _UnusedRelaySession(), + nsec: keys.nsec, + ), + isCommunityActive: () => true, + ); + + await service.mintInvite(ttlSeconds: 86400, maxUses: 5); + + expect(jsonDecode(captured.body), {'ttl_secs': 86400, 'max_uses': 5}); + }); +} + +class _UnusedRelaySession extends RelaySessionNotifier {} + +class _TestRelayConfigNotifier extends RelayConfigNotifier { + @override + RelayConfig build() => + const RelayConfig(baseUrl: 'https://relay.example.com'); +} + +class _ProfileRelaySession extends RelaySessionNotifier { + _ProfileRelaySession(this.events); + + final List events; + int queryCount = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryCount++; + return events; + } +} diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart new file mode 100644 index 0000000000..64245ea721 --- /dev/null +++ b/mobile/test/features/settings/settings_page_test.dart @@ -0,0 +1,109 @@ +import 'package:buzz/features/settings/settings_page.dart'; +import 'package:buzz/shared/community/community_membership_provider.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + testWidgets('shows community invite navigation to owners and admins', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(CommunityMemberRole.admin), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const Text('Invite destination'), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Invite to community'), findsOneWidget); + expect( + find.text('Add people directly or share an invite link'), + findsNothing, + ); + await tester.tap(find.text('Invite to community')); + await tester.pumpAndSettle(); + expect(find.text('Invite destination'), findsOneWidget); + }); + + testWidgets('keeps invite navigation available when role lookup fails', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + currentCommunityRoleProvider.overrideWithValue( + AsyncError( + Exception('membership query failed'), + StackTrace.empty, + ), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const Text('Invite destination'), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Invite to community'), findsOneWidget); + await tester.tap(find.text('Invite to community')); + await tester.pumpAndSettle(); + expect(find.text('Invite destination'), findsOneWidget); + }); + + testWidgets('hides community invite navigation from plain members', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(CommunityMemberRole.member), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const Text('Invite destination'), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Invite to community'), findsNothing); + }); +} diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart index 6b166c8efa..649f6862a3 100644 --- a/mobile/test/features/settings/theme_picker_page_test.dart +++ b/mobile/test/features/settings/theme_picker_page_test.dart @@ -174,6 +174,7 @@ void main() { tester, SettingsPage( profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), prefs: {'buzz_color_scheme': 'buzz', 'buzz_accent_color': 4}, @@ -189,6 +190,7 @@ void main() { tester, SettingsPage( profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), prefs: { diff --git a/mobile/test/shared/community/community_membership_provider_test.dart b/mobile/test/shared/community/community_membership_provider_test.dart new file mode 100644 index 0000000000..45731c3469 --- /dev/null +++ b/mobile/test/shared/community/community_membership_provider_test.dart @@ -0,0 +1,72 @@ +import 'package:buzz/shared/community/community_membership_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const owner = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const admin = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const member = + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + + test('parses Buzz and legacy membership tags with roles', () { + final snapshot = communityMembershipFromEvents([ + _event( + createdAt: 2, + tags: [ + ['member', owner, 'owner'], + ['member', admin.toUpperCase(), 'admin'], + ['p', member, 'wss://relay.example.com', 'member'], + ['member', 'not-a-pubkey', 'owner'], + ['member', owner, 'member'], + ], + ), + ]); + + expect(snapshot.snapshotFound, isTrue); + expect(snapshot.members, hasLength(3)); + expect(snapshot.roleFor(owner), CommunityMemberRole.owner); + expect(snapshot.roleFor(admin), CommunityMemberRole.admin); + expect(snapshot.roleFor(member), CommunityMemberRole.member); + expect(snapshot.pubkeys, {owner, admin, member}); + }); + + test('uses the latest membership snapshot', () { + final snapshot = communityMembershipFromEvents([ + _event( + createdAt: 1, + tags: [ + ['member', owner, 'owner'], + ], + ), + _event( + createdAt: 2, + tags: [ + ['member', owner, 'admin'], + ], + ), + ]); + + expect(snapshot.roleFor(owner), CommunityMemberRole.admin); + }); + + test('fails closed when no snapshot is available', () { + final snapshot = communityMembershipFromEvents(const []); + + expect(snapshot.snapshotFound, isFalse); + expect(snapshot.members, isEmpty); + expect(canManageCommunityInvites(snapshot.roleFor(owner)), isFalse); + }); +} + +NostrEvent _event({required int createdAt, required List> tags}) => + NostrEvent( + id: '$createdAt', + pubkey: 'relay', + createdAt: createdAt, + kind: EventKind.relayMembership, + tags: tags, + content: '', + sig: '', + );