From 3cfd0f694db8a2bc8c6ef67a48b39f41518a7b7f Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 10:02:29 -0700 Subject: [PATCH 1/8] Refine the mobile message composer --- mobile/ios/Podfile.lock | 12 + mobile/ios/Runner/Info.plist | 2 +- mobile/lib/features/channels/compose_bar.dart | 491 ++++++++++++------ .../channels/compose_bar/attachments.dart | 192 ++++++- .../channels/compose_bar/camera_preview.dart | 308 +++++++++++ .../compose_bar/formatting_toolbar.dart | 76 ++- .../channels/compose_bar/send_button.dart | 6 +- .../channels/compose_bar/suggestions.dart | 95 ++-- mobile/lib/shared/relay/media_upload.dart | 87 +++- mobile/pubspec.lock | 80 +++ mobile/pubspec.yaml | 2 + .../channels/channel_detail_page_test.dart | 12 +- .../features/channels/compose_bar_test.dart | 72 ++- 13 files changed, 1141 insertions(+), 294 deletions(-) create mode 100644 mobile/lib/features/channels/compose_bar/camera_preview.dart diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index ea62f66562..1b79aa0b74 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -3,8 +3,12 @@ PODS: - Flutter - app_links (6.4.1): - Flutter + - camera_avfoundation (0.0.1): + - Flutter - connectivity_plus (0.0.1): - Flutter + - file_selector_ios (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_secure_storage_darwin (10.0.0): - Flutter @@ -28,7 +32,9 @@ PODS: DEPENDENCIES: - app_badge_plus (from `.symlinks/plugins/app_badge_plus/ios`) - app_links (from `.symlinks/plugins/app_links/ios`) + - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) @@ -43,8 +49,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/app_badge_plus/ios" app_links: :path: ".symlinks/plugins/app_links/ios" + camera_avfoundation: + :path: ".symlinks/plugins/camera_avfoundation/ios" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" + file_selector_ios: + :path: ".symlinks/plugins/file_selector_ios/ios" Flutter: :path: Flutter flutter_secure_storage_darwin: @@ -65,7 +75,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_badge_plus: 09939f19a075cc742cc155d8ed85e6d8601f0104 app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a + camera_avfoundation: 968a9a5323c79a99c166ad9d7866bfd2047b5a9b connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 9e4f42cb89..4aa9fcb7d5 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -44,7 +44,7 @@ LSRequiresIPhoneOS NSCameraUsageDescription - Buzz needs camera access to scan QR codes for device pairing. + Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing. NSPhotoLibraryUsageDescription Buzz needs photo library access so you can attach images to messages. UIApplicationSceneManifest diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 0285cda560..19d968abf8 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,7 +1,10 @@ +import 'dart:async'; import 'dart:collection'; +import 'package:camera/camera.dart' as camera; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; @@ -19,7 +22,6 @@ import '../custom_emoji/custom_emoji_provider.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; import 'channels_provider.dart'; -import 'emoji_picker.dart'; import 'mentions/mention_candidates.dart'; import 'mentions/mention_candidates_provider.dart'; import 'mentions/mention_ranking.dart'; @@ -28,6 +30,7 @@ part 'compose_bar/helpers.dart'; part 'compose_bar/suggestions.dart'; part 'compose_bar/formatting_toolbar.dart'; part 'compose_bar/attachments.dart'; +part 'compose_bar/camera_preview.dart'; part 'compose_bar/send_button.dart'; const _pastedImageMimeTypes = [ @@ -71,6 +74,9 @@ class ComposeBar extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final controller = useTextEditingController(); final focusNode = useFocusNode(); + final isComposerExpanded = useState(false); + final showAttachments = useState(false); + final showCamera = useState(false); final isSending = useState(false); final showFormatting = useState(false); final attachments = useState>([]); @@ -80,11 +86,41 @@ class ComposeBar extends HookConsumerWidget { final hasAttachments = attachments.value.isNotEmpty; final hasPendingUploads = uploadingCount.value > 0; final customEmoji = ref.watch(customEmojiListProvider); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final composerExpansionController = useAnimationController( + initialValue: 0, + upperBound: 1.05, + ); + final composerExpansionValue = useAnimation(composerExpansionController); + final composerExpansionProgress = composerExpansionValue + .clamp(0.0, 1.0) + .toDouble(); final resolvedHint = hintText ?? (channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026'); + useEffect(() { + final target = isComposerExpanded.value ? 1.0 : 0.0; + if (reducedMotion) { + composerExpansionController.value = target; + } else if ((composerExpansionController.value - target).abs() > 0.001) { + composerExpansionController.animateWith( + SpringSimulation( + SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 280), + bounce: 0.16, + ), + composerExpansionController.value, + target, + 0, + snapToEnd: true, + ), + ); + } + return null; + }, [isComposerExpanded.value, reducedMotion]); + useEffect(() { if (defaultTargetPlatform != TargetPlatform.iOS) return null; @@ -294,6 +330,8 @@ class ComposeBar extends HookConsumerWidget { mentionMap.value.clear(); mentionQuery.value = null; channelQuery.value = null; + showAttachments.value = false; + showCamera.value = false; showFormatting.value = false; uploadError.value = null; focusNode.requestFocus(); @@ -490,21 +528,6 @@ class ComposeBar extends HookConsumerWidget { ); } - // Insert an emoji at the cursor. - void insertEmoji(String emoji) { - final text = controller.text; - final cursor = controller.selection.isValid - ? controller.selection.baseOffset - : text.length; - final before = text.substring(0, cursor); - final after = text.substring(cursor); - controller.text = '$before$emoji$after'; - controller.selection = TextSelection.collapsed( - offset: cursor + emoji.length, - ); - focusNode.requestFocus(); - } - // Wrap (or insert) markdown formatting around the current selection. void applyFormat(String prefix, [String? suffix]) { suffix ??= prefix; @@ -539,65 +562,153 @@ class ComposeBar extends HookConsumerWidget { // ----- Widget tree ---------------------------------------------------- - final hasSuggestions = - suggestions.isNotEmpty || channelSuggestions.isNotEmpty; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Channel suggestions (above the compose chrome). - if (channelSuggestions.isNotEmpty) - _ChannelSuggestions( - suggestions: channelSuggestions, - onSelect: insertChannel, - ), + void chooseAttachment(Future Function() pick) { + showAttachments.value = false; + showCamera.value = false; + pickAndUpload(pick); + } - // Mention suggestions (above the compose chrome). - if (suggestions.isNotEmpty) - _MentionSuggestions( - suggestions: suggestions, - userCache: userCache, - currentPubkey: currentPubkey, - isDmChannel: isDmChannel, - onSelect: insertMention, - ), + void toggleAttachments() { + if (showCamera.value) { + showCamera.value = false; + showAttachments.value = false; + return; + } + showCamera.value = false; + showAttachments.value = !showAttachments.value; + } + + void openCamera() { + focusNode.unfocus(); + showAttachments.value = false; + showCamera.value = true; + } + + final motionDuration = reducedMotion + ? Duration.zero + : const Duration(milliseconds: 180); + final suggestionOverlayController = useMemoized( + OverlayPortalController.new, + ); - // Compose chrome — bottom-sheet style container. - Container( + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) suggestionOverlayController.show(); + }); + return null; + }, [suggestionOverlayController]); + + void expandComposer() { + if (isComposerExpanded.value) return; + showAttachments.value = false; + showCamera.value = false; + isComposerExpanded.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) focusNode.requestFocus(); + }); + } + + final suggestionPanel = channelSuggestions.isNotEmpty + ? KeyedSubtree( + key: const ValueKey('channel-suggestions'), + child: _ChannelSuggestions( + suggestions: channelSuggestions, + onSelect: insertChannel, + ), + ) + : suggestions.isNotEmpty + ? KeyedSubtree( + key: const ValueKey('mention-suggestions'), + child: _MentionSuggestions( + suggestions: suggestions, + userCache: userCache, + currentPubkey: currentPubkey, + isDmChannel: isDmChannel, + onSelect: insertMention, + ), + ) + : const SizedBox.shrink(key: ValueKey('no-suggestions')); + final overlayPanel = showCamera.value + ? KeyedSubtree( + key: const ValueKey('camera-preview'), + child: _InlineCameraPreview( + onClose: () => showCamera.value = false, + onCapture: (image) async { + await pickAndUpload( + () => ref.read(mediaUploadServiceProvider).uploadImage(image), + ); + if (context.mounted) showCamera.value = false; + }, + ), + ) + : showAttachments.value + ? KeyedSubtree( + key: const ValueKey('attachment-menu'), + child: Align( + alignment: Alignment.bottomLeft, + heightFactor: 1, + child: _AttachmentMenu( + onCamera: openCamera, + onPhotos: () => chooseAttachment( + ref.read(mediaUploadServiceProvider).pickAndUploadImage, + ), + onVideo: () => chooseAttachment( + ref.read(mediaUploadServiceProvider).pickAndUploadVideo, + ), + onFiles: () => chooseAttachment( + ref.read(mediaUploadServiceProvider).pickAndUploadFile, + ), + ), + ), + ) + : suggestionPanel; + + // Suggestions and attachments live in the overlay so showing them cannot + // reflow the composer. Both stay anchored just above the capsule. + return Padding( + padding: EdgeInsets.only( + left: Grid.twelve, + right: Grid.twelve, + bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, + ), + child: OverlayPortal.overlayChildLayoutBuilder( + controller: suggestionOverlayController, + overlayChildBuilder: (context, layoutInfo) { + final composerOrigin = MatrixUtils.transformPoint( + layoutInfo.childPaintTransform, + Offset.zero, + ); + return Positioned( + left: composerOrigin.dx, + bottom: layoutInfo.overlaySize.height - composerOrigin.dy, + width: layoutInfo.childSize.width, + child: ClipRect( + child: Padding( + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: _SuggestionPanelMotion( + duration: motionDuration, + child: overlayPanel, + ), + ), + ), + ); + }, + child: Container( decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, - borderRadius: !hasSuggestions - ? const BorderRadius.vertical( - top: Radius.circular(Radii.dialog), - ) - : BorderRadius.zero, - boxShadow: !hasSuggestions - ? [ - BoxShadow( - color: context.colors.shadow.withValues(alpha: 0.08), - blurRadius: 8, - offset: const Offset(0, -2), - ), - ] - : null, - ), - padding: EdgeInsets.only( - left: Grid.gutter, - right: Grid.gutter, - top: Grid.xs, - bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.twelve, + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), ), + padding: const EdgeInsets.all(Grid.xxs), child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Formatting toolbar (toggled via Aa button). - if (showFormatting.value) - _FormattingToolbar(onFormat: applyFormat), - - if (hasAttachments || hasPendingUploads) ...[ + if (hasAttachments) ...[ _AttachmentStrip( attachments: attachments.value, - uploadingCount: uploadingCount.value, onRemove: removeAttachment, ), const SizedBox(height: Grid.xxs), @@ -616,110 +727,174 @@ class ComposeBar extends HookConsumerWidget { const SizedBox(height: Grid.xxs), ], - // Row 1 — text input (full width, grows). - TextField( - controller: controller, - focusNode: focusNode, - textInputAction: TextInputAction.send, - contextMenuBuilder: buildContextMenu, - contentInsertionConfiguration: ContentInsertionConfiguration( - allowedMimeTypes: _pastedImageMimeTypes, - onContentInserted: uploadPastedImage, - ), - onSubmitted: (_) => send(), - minLines: 1, - maxLines: 5, - style: context.textTheme.bodyMedium, - decoration: InputDecoration( - hintText: resolvedHint, - hintStyle: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + // Keep the default state out of the focus system entirely so + // restored native focus cannot expand a newly opened channel. + if (isComposerExpanded.value) + TextField( + controller: controller, + focusNode: focusNode, + textInputAction: TextInputAction.send, + contextMenuBuilder: buildContextMenu, + contentInsertionConfiguration: ContentInsertionConfiguration( + allowedMimeTypes: _pastedImageMimeTypes, + onContentInserted: uploadPastedImage, ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: Grid.half, - vertical: Grid.half, + onSubmitted: (_) => send(), + 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, ), - isDense: true, - ), - ), - - const SizedBox(height: Grid.xxs), - - // Row 2 — action buttons [paperclip, emoji, @, Aa] ... [send]. - Row( - children: [ - _ComposeAction( - icon: LucideIcons.paperclip, - onTap: () { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(LucideIcons.image), - title: const Text('Photo'), - onTap: () { - Navigator.of(sheetContext).pop(); - pickAndUpload( - ref - .read(mediaUploadServiceProvider) - .pickAndUploadImage, - ); - }, - ), - ListTile( - leading: const Icon(LucideIcons.video), - title: const Text('Video'), - onTap: () { - Navigator.of(sheetContext).pop(); - pickAndUpload( - ref - .read(mediaUploadServiceProvider) - .pickAndUploadVideo, - ); - }, + ) + else + Row( + children: [ + _AttachmentTrigger( + open: showAttachments.value || showCamera.value, + onTap: toggleAttachments, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Semantics( + button: true, + label: resolvedHint, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: expandComposer, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: Grid.half, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + resolvedHint, + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), ), - ], + ), ), ), - ); - }, - ), - _ComposeAction( - icon: LucideIcons.smilePlus, - onTap: () => showEmojiPicker( - context: context, - onSelect: insertEmoji, + ), + ), + ], + ), + + ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: composerExpansionValue, + child: IgnorePointer( + ignoring: composerExpansionValue < 0.98, + child: Opacity( + opacity: composerExpansionProgress, + child: Transform.translate( + offset: Offset( + 0, + Grid.xxs * (1 - composerExpansionProgress), + ), + child: Column( + children: [ + const SizedBox(height: Grid.xxs), + Row( + children: [ + _AttachmentTrigger( + open: + showAttachments.value || + showCamera.value || + showFormatting.value, + onTap: () { + if (showFormatting.value) { + showFormatting.value = false; + } else { + toggleAttachments(); + } + }, + ), + const SizedBox(width: Grid.half), + Expanded( + child: AnimatedSwitcher( + duration: motionDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + layoutBuilder: + (currentChild, previousChildren) => + Stack( + alignment: Alignment.centerLeft, + children: [ + ...previousChildren, + ?currentChild, + ], + ), + child: showFormatting.value + ? _FormattingToolbar( + onFormat: applyFormat, + ) + : Row( + key: const ValueKey( + 'standard-actions', + ), + children: [ + _ComposeAction( + icon: LucideIcons.atSign, + onTap: () { + showAttachments.value = false; + showCamera.value = false; + triggerMention(); + }, + ), + _ComposeAction( + icon: LucideIcons.hash, + onTap: () { + showAttachments.value = false; + showCamera.value = false; + triggerChannel(); + }, + ), + _ComposeAction( + icon: LucideIcons.aLargeSmall, + onTap: () { + showAttachments.value = false; + showCamera.value = false; + showFormatting.value = true; + }, + ), + const Spacer(), + _SendButton( + isDisabled: hasPendingUploads, + isSending: isSending.value, + onTap: send, + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), ), ), - _ComposeAction( - icon: LucideIcons.atSign, - onTap: triggerMention, - ), - _ComposeAction(icon: LucideIcons.hash, onTap: triggerChannel), - _ComposeAction( - icon: LucideIcons.aLargeSmall, - active: showFormatting.value, - onTap: () => showFormatting.value = !showFormatting.value, - ), - const Spacer(), - _SendButton( - isDisabled: hasPendingUploads, - isSending: isSending.value, - onTap: send, - ), - ], + ), ), ], ), ), - ], + ), ); } } diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index a3313ba64c..8cbd0df755 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -23,6 +23,146 @@ class _ComposeDraftPayload { } } +class _AttachmentTrigger extends StatelessWidget { + final bool open; + final VoidCallback onTap; + + const _AttachmentTrigger({required this.open, required this.onTap}); + + @override + Widget build(BuildContext context) { + final duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180); + + return SizedBox.square( + dimension: 36, + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.surface, + shape: BoxShape.circle, + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), + ), + child: IconButton( + tooltip: open ? 'Close attachments' : 'Add attachment', + onPressed: onTap, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + icon: AnimatedRotation( + duration: duration, + curve: Curves.easeInOutCubic, + turns: open ? 0.125 : 0, + child: Icon( + LucideIcons.plus, + size: 20, + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + ); + } +} + +class _AttachmentMenu extends StatelessWidget { + final VoidCallback onCamera; + final VoidCallback onPhotos; + final VoidCallback onVideo; + final VoidCallback onFiles; + + const _AttachmentMenu({ + required this.onCamera, + required this.onPhotos, + required this.onVideo, + required this.onFiles, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 176, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), + ), + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _AttachmentMenuItem( + icon: LucideIcons.camera, + label: 'Camera', + onTap: onCamera, + ), + _AttachmentMenuItem( + icon: LucideIcons.images, + label: 'Photos', + onTap: onPhotos, + ), + _AttachmentMenuItem( + icon: LucideIcons.video, + label: 'Video', + onTap: onVideo, + ), + _AttachmentMenuItem( + icon: LucideIcons.file, + label: 'Files', + onTap: onFiles, + ), + ], + ), + ); + } +} + +class _AttachmentMenuItem extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _AttachmentMenuItem({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 48, + child: Tooltip( + message: label, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), + child: Row( + children: [ + Icon(icon, size: 20, color: context.colors.onSurfaceVariant), + const SizedBox(width: Grid.xxs), + Text( + label, + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurface, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + List _withoutAttachment( List attachments, String url, @@ -35,14 +175,9 @@ List _withoutAttachment( class _AttachmentStrip extends StatelessWidget { final List attachments; - final int uploadingCount; final void Function(String url) onRemove; - const _AttachmentStrip({ - required this.attachments, - required this.uploadingCount, - required this.onRemove, - }); + const _AttachmentStrip({required this.attachments, required this.onRemove}); @override Widget build(BuildContext context) { @@ -53,25 +188,12 @@ class _AttachmentStrip extends StatelessWidget { height: thumbHeight, child: ListView.separated( scrollDirection: Axis.horizontal, - itemCount: attachments.length + uploadingCount, + itemCount: attachments.length, separatorBuilder: (_, _) => const SizedBox(width: Grid.half), itemBuilder: (context, index) { - if (index >= attachments.length) { - return Container( - width: thumbWidth, - decoration: BoxDecoration( - color: context.colors.surface, - borderRadius: BorderRadius.circular(Radii.md), - border: Border.all(color: context.colors.outlineVariant), - ), - child: const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - } - final attachment = attachments[index]; final isVideo = attachment.type.startsWith('video/'); + final isImage = attachment.type.startsWith('image/'); final previewUrl = attachment.thumb ?? attachment.url; return Container( key: ValueKey('compose-attachment:${attachment.url}'), @@ -96,7 +218,8 @@ class _AttachmentStrip extends StatelessWidget { ), ), ) - : MediaImage( + : isImage + ? MediaImage( url: previewUrl, fit: BoxFit.cover, errorBuilder: (_, _, _) => ColoredBox( @@ -106,6 +229,31 @@ class _AttachmentStrip extends StatelessWidget { color: context.colors.onSurfaceVariant, ), ), + ) + : ColoredBox( + color: context.colors.surface, + child: Padding( + padding: const EdgeInsets.all(Grid.xxs), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.file, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(height: Grid.quarter), + Text( + attachment.filename ?? 'File', + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), ), ), Positioned( diff --git a/mobile/lib/features/channels/compose_bar/camera_preview.dart b/mobile/lib/features/channels/compose_bar/camera_preview.dart new file mode 100644 index 0000000000..c51d1a1b31 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/camera_preview.dart @@ -0,0 +1,308 @@ +part of '../compose_bar.dart'; + +class _InlineCameraPreview extends HookConsumerWidget { + final Future Function(XFile image) onCapture; + final VoidCallback onClose; + + const _InlineCameraPreview({required this.onCapture, required this.onClose}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useState(null); + final controllerRef = useRef(null); + final isInitializing = useState(true); + final isCapturing = useState(false); + final error = useState(null); + + useEffect(() { + var disposed = false; + var generation = 0; + + Future disposeCurrent() async { + generation += 1; + final current = controllerRef.value; + controllerRef.value = null; + if (!disposed) controller.value = null; + await current?.dispose(); + } + + Future initialize() async { + final currentGeneration = ++generation; + if (!disposed) { + isInitializing.value = true; + error.value = null; + } + + camera.CameraController? next; + try { + final available = await camera.availableCameras(); + if (disposed || currentGeneration != generation) return; + if (available.isEmpty) { + throw camera.CameraException( + 'no-cameras', + 'No cameras are available on this device.', + ); + } + + final description = available.firstWhere( + (candidate) => + candidate.lensDirection == camera.CameraLensDirection.back, + orElse: () => available.first, + ); + next = camera.CameraController( + description, + camera.ResolutionPreset.high, + enableAudio: false, + ); + await next.initialize(); + + if (disposed || currentGeneration != generation) { + await next.dispose(); + return; + } + controllerRef.value = next; + controller.value = next; + isInitializing.value = false; + } catch (cameraError) { + await next?.dispose(); + if (!disposed && currentGeneration == generation) { + error.value = _cameraErrorMessage(cameraError); + isInitializing.value = false; + } + } + } + + final lifecycleListener = AppLifecycleListener( + onInactive: () => unawaited(disposeCurrent()), + onResume: () => unawaited(initialize()), + ); + unawaited(initialize()); + + return () { + disposed = true; + lifecycleListener.dispose(); + generation += 1; + final current = controllerRef.value; + controllerRef.value = null; + unawaited(current?.dispose() ?? Future.value()); + }; + }, const []); + + Future capture() async { + final activeController = controller.value; + if (activeController == null || + isCapturing.value || + activeController.value.isTakingPicture) { + return; + } + + isCapturing.value = true; + error.value = null; + try { + final image = await activeController.takePicture(); + if (context.mounted) await onCapture(image); + } catch (captureError) { + if (context.mounted) { + error.value = _cameraErrorMessage(captureError); + } + } finally { + if (context.mounted) isCapturing.value = false; + } + } + + final activeController = controller.value; + return Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(Radii.dialog), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), + ), + child: AspectRatio( + aspectRatio: 4 / 3, + child: Stack( + fit: StackFit.expand, + children: [ + if (activeController case final initialized?) + _CameraFeed(controller: initialized) + else + _CameraPlaceholder( + isInitializing: isInitializing.value, + message: error.value, + ), + if (activeController != null) + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(Grid.twelve), + child: _CameraCaptureButton( + isCapturing: isCapturing.value, + onTap: capture, + ), + ), + ), + Positioned( + top: Grid.xxs, + right: Grid.xxs, + child: _CameraCloseButton(onTap: onClose), + ), + ], + ), + ), + ); + } +} + +class _CameraFeed extends StatelessWidget { + final camera.CameraController controller; + + const _CameraFeed({required this.controller}); + + @override + Widget build(BuildContext context) { + final previewSize = controller.value.previewSize; + if (previewSize == null) return const ColoredBox(color: Colors.black); + + return ClipRect( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: previewSize.height, + height: previewSize.width, + child: camera.CameraPreview(controller), + ), + ), + ); + } +} + +class _CameraPlaceholder extends StatelessWidget { + final bool isInitializing; + final String? message; + + const _CameraPlaceholder({ + required this.isInitializing, + required this.message, + }); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Colors.black, + child: Center( + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: isInitializing + ? const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + LucideIcons.cameraOff, + color: Colors.white, + size: 28, + ), + const SizedBox(height: Grid.xxs), + Text( + message ?? 'Camera isn’t available here.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: Colors.white, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _CameraCaptureButton extends StatelessWidget { + final bool isCapturing; + final VoidCallback onTap; + + const _CameraCaptureButton({required this.isCapturing, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'Take photo', + child: GestureDetector( + onTap: isCapturing ? null : onTap, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.24), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + ), + padding: const EdgeInsets.all(Grid.half), + child: DecoratedBox( + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: isCapturing + ? const Padding( + padding: EdgeInsets.all(Grid.twelve), + child: CircularProgressIndicator( + color: Colors.black, + strokeWidth: 2, + ), + ) + : null, + ), + ), + ), + ); + } +} + +class _CameraCloseButton extends StatelessWidget { + final VoidCallback onTap; + + const _CameraCloseButton({required this.onTap}); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 36, + child: IconButton( + onPressed: onTap, + tooltip: 'Close camera', + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: Colors.black.withValues(alpha: 0.56), + foregroundColor: Colors.white, + ), + icon: const Icon(LucideIcons.x, size: 18), + ), + ); + } +} + +String _cameraErrorMessage(Object error) { + if (error is camera.CameraException) { + return switch (error.code) { + 'CameraAccessDenied' || + 'CameraAccessDeniedWithoutPrompt' || + 'CameraAccessRestricted' => 'Camera access is turned off for Buzz.', + 'no-cameras' => 'Camera isn’t available on this device.', + _ => 'Camera couldn’t start. Try again.', + }; + } + return 'Camera couldn’t start. Try again.'; +} diff --git a/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart b/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart index f08d947f1e..66f665a38e 100644 --- a/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart +++ b/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart @@ -7,37 +7,36 @@ class _FormattingToolbar extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: Grid.half), - child: Row( - children: [ - _FormatButton( - icon: LucideIcons.bold, - tooltip: 'Bold', - onTap: () => onFormat('**'), - ), - _FormatButton( - icon: LucideIcons.italic, - tooltip: 'Italic', - onTap: () => onFormat('_'), - ), - _FormatButton( - icon: LucideIcons.strikethrough, - tooltip: 'Strikethrough', - onTap: () => onFormat('~~'), - ), - _FormatButton( - icon: LucideIcons.code, - tooltip: 'Code', - onTap: () => onFormat('`'), - ), - _FormatButton( - icon: LucideIcons.squareCode, - tooltip: 'Code block', - onTap: () => onFormat('```\n', '\n```'), - ), - ], - ), + return Row( + key: const ValueKey('formatting-actions'), + mainAxisSize: MainAxisSize.min, + children: [ + _FormatButton( + icon: LucideIcons.bold, + tooltip: 'Bold', + onTap: () => onFormat('**'), + ), + _FormatButton( + icon: LucideIcons.italic, + tooltip: 'Italic', + onTap: () => onFormat('_'), + ), + _FormatButton( + icon: LucideIcons.strikethrough, + tooltip: 'Strikethrough', + onTap: () => onFormat('~~'), + ), + _FormatButton( + icon: LucideIcons.code, + tooltip: 'Code', + onTap: () => onFormat('`'), + ), + _FormatButton( + icon: LucideIcons.squareCode, + tooltip: 'Code block', + onTap: () => onFormat('```\n', '\n```'), + ), + ], ); } } @@ -71,14 +70,9 @@ class _FormatButton extends StatelessWidget { class _ComposeAction extends StatelessWidget { final IconData icon; - final bool active; final VoidCallback onTap; - const _ComposeAction({ - required this.icon, - this.active = false, - required this.onTap, - }); + const _ComposeAction({required this.icon, required this.onTap}); @override Widget build(BuildContext context) { @@ -87,13 +81,7 @@ class _ComposeAction extends StatelessWidget { height: 36, child: IconButton( onPressed: onTap, - icon: Icon( - icon, - size: 20, - color: active - ? context.colors.primary - : context.colors.onSurfaceVariant, - ), + icon: Icon(icon, size: 20, color: context.colors.onSurfaceVariant), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, ), diff --git a/mobile/lib/features/channels/compose_bar/send_button.dart b/mobile/lib/features/channels/compose_bar/send_button.dart index cc32894d63..45bd1df9c2 100644 --- a/mobile/lib/features/channels/compose_bar/send_button.dart +++ b/mobile/lib/features/channels/compose_bar/send_button.dart @@ -23,9 +23,7 @@ class _SendButton extends StatelessWidget { disabledBackgroundColor: context.colors.primary.withValues( alpha: 0.5, ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.md), - ), + shape: const CircleBorder(), ), padding: EdgeInsets.zero, icon: isSending @@ -38,7 +36,7 @@ class _SendButton extends StatelessWidget { ), ) : Icon( - LucideIcons.sendHorizontal, + LucideIcons.arrowUp, size: 18, color: context.colors.onPrimary, ), diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index b7958138a1..f1deefcdaf 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -1,5 +1,48 @@ part of '../compose_bar.dart'; +class _SuggestionPanelMotion extends StatelessWidget { + final Duration duration; + final Widget child; + + const _SuggestionPanelMotion({required this.duration, required this.child}); + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: duration, + reverseDuration: duration, + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: Alignment.bottomLeft, + clipBehavior: Clip.none, + children: [...previousChildren, ?currentChild], + ), + transitionBuilder: (child, animation) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + + return AnimatedBuilder( + animation: curvedAnimation, + child: child, + builder: (context, child) => IgnorePointer( + ignoring: animation.status == AnimationStatus.reverse, + child: Opacity( + opacity: curvedAnimation.value, + child: Transform.translate( + offset: Offset(0, Grid.xs * (1 - curvedAnimation.value)), + child: child, + ), + ), + ), + ); + }, + child: child, + ); + } +} + class _MentionSuggestions extends StatelessWidget { final List suggestions; final Map userCache; @@ -22,16 +65,11 @@ class _MentionSuggestions extends StatelessWidget { clipBehavior: Clip.hardEdge, decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(Radii.dialog), + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, ), - boxShadow: [ - BoxShadow( - color: context.colors.shadow.withValues(alpha: 0.08), - blurRadius: 8, - offset: const Offset(0, -2), - ), - ], ), child: ListView.separated( shrinkWrap: true, @@ -49,16 +87,17 @@ class _MentionSuggestions extends StatelessWidget { visualDensity: VisualDensity.compact, leading: AvatarImage( imageUrl: avatarUrl, - radius: 14, + radius: 18, backgroundColor: context.colors.primaryContainer, fallback: Text( name[0].toUpperCase(), - style: context.textTheme.labelSmall?.copyWith( + style: context.textTheme.labelMedium?.copyWith( color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, ), ), ), - title: Text(name, style: context.textTheme.bodyMedium), + title: Text(name, style: context.textTheme.titleSmall), subtitle: _MentionSuggestionInfo.build( context, candidate: candidate, @@ -176,16 +215,11 @@ class _ChannelSuggestions extends StatelessWidget { clipBehavior: Clip.hardEdge, decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(Radii.dialog), + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, ), - boxShadow: [ - BoxShadow( - color: context.colors.shadow.withValues(alpha: 0.08), - blurRadius: 8, - offset: const Offset(0, -2), - ), - ], ), child: ListView.separated( shrinkWrap: true, @@ -197,21 +231,16 @@ class _ChannelSuggestions extends StatelessWidget { return ListTile( dense: true, visualDensity: VisualDensity.compact, - leading: Icon( - channel.isForum ? LucideIcons.messageSquare : LucideIcons.hash, - size: 18, - color: context.colors.onSurfaceVariant, - ), - title: Text( - '#${channel.name}', - style: context.textTheme.bodyMedium, - ), - trailing: Text( - channel.channelType, - style: context.textTheme.labelSmall?.copyWith( + horizontalTitleGap: 0, + leading: SizedBox.square( + dimension: 36, + child: Icon( + LucideIcons.hash, + size: 20, color: context.colors.onSurfaceVariant, ), ), + title: Text(channel.name, style: context.textTheme.bodyLarge), onTap: () => onSelect(channel), ); }, diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index b868fc2845..1d3ff381a7 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:file_selector/file_selector.dart' as file_selector; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -46,10 +47,13 @@ const _allowedImageMimeTypes = { }; const _allowedVideoMimeTypes = {'video/mp4'}; const _maxVideoSizeBytes = 100 * 1024 * 1024; // 100MB +const _maxFileSizeBytes = 100 * 1024 * 1024; // 100MB const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload."; typedef PickGalleryImage = Future Function(); typedef PickGalleryVideo = Future Function(); +typedef PickCameraImage = Future Function(); +typedef PickAttachmentFile = Future Function(); typedef SanitizeImageBytes = Future Function(Uint8List bytes, String mimeType); typedef TranscodeImageToJpeg = Future Function(Uint8List bytes); @@ -83,6 +87,7 @@ class BlobDescriptor { final String? thumb; final double? duration; final String? image; + final String? filename; const BlobDescriptor({ required this.url, @@ -95,6 +100,7 @@ class BlobDescriptor { this.thumb, this.duration, this.image, + this.filename, }); factory BlobDescriptor.fromJson(Map json) => BlobDescriptor( @@ -108,6 +114,21 @@ class BlobDescriptor { thumb: json['thumb'] as String?, duration: (json['duration'] as num?)?.toDouble(), image: json['image'] as String?, + filename: json['filename'] as String?, + ); + + BlobDescriptor withFilename(String value) => BlobDescriptor( + url: url, + sha256: sha256, + size: size, + type: type, + uploaded: uploaded, + dim: dim, + blurhash: blurhash, + thumb: thumb, + duration: duration, + image: image, + filename: value, ); List toImetaTag() => [ @@ -121,10 +142,18 @@ class BlobDescriptor { if (thumb != null) 'thumb $thumb', if (duration != null) 'duration $duration', if (image != null) 'image $image', + if (filename != null) 'filename $filename', ]; - String toMarkdownImage() => - type.startsWith('video/') ? '![video]($url)' : '![image]($url)'; + String toMarkdownImage() { + if (type.startsWith('video/')) return '![video]($url)'; + if (type.startsWith('image/')) return '![image]($url)'; + final label = (filename ?? 'file').replaceAllMapped( + RegExp(r'[\\\[\]]'), + (match) => '\\${match[0]}', + ); + return '[$label]($url)'; + } } class MediaUploadService { @@ -132,6 +161,8 @@ class MediaUploadService { final String? _nsec; final PickGalleryImage _pickGalleryImage; final PickGalleryVideo _pickGalleryVideo; + final PickCameraImage? _pickCameraImage; + final PickAttachmentFile? _pickAttachmentFile; final SanitizeImageBytes _sanitizeImageBytes; final TranscodeImageToJpeg _transcodeImageToJpeg; final TranscodeVideoToMp4 _transcodeVideoToMp4; @@ -145,6 +176,8 @@ class MediaUploadService { required String? nsec, required PickGalleryImage pickGalleryImage, required PickGalleryVideo pickGalleryVideo, + PickCameraImage? pickCameraImage, + PickAttachmentFile? pickAttachmentFile, SanitizeImageBytes? sanitizeImageBytes, TranscodeImageToJpeg? transcodeImageToJpeg, TranscodeVideoToMp4? transcodeVideoToMp4, @@ -155,6 +188,8 @@ class MediaUploadService { _nsec = nsec, _pickGalleryImage = pickGalleryImage, _pickGalleryVideo = pickGalleryVideo, + _pickCameraImage = pickCameraImage, + _pickAttachmentFile = pickAttachmentFile, _sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes, _transcodeImageToJpeg = transcodeImageToJpeg ?? _transcodePickedImageToJpeg, @@ -184,6 +219,16 @@ class MediaUploadService { ); } + Future captureAndUploadImage() async { + final pickCameraImage = _pickCameraImage; + if (pickCameraImage == null) { + throw Exception("Camera attachments aren't available on this device."); + } + final pickedImage = await pickCameraImage(); + if (pickedImage == null) return null; + return uploadImage(pickedImage); + } + Future clipboardHasImage() async { return await _mediaUploadPlatformChannel.invokeMethod( _clipboardHasImageMethod, @@ -234,6 +279,29 @@ class MediaUploadService { } } + Future pickAndUploadFile() async { + final pickAttachmentFile = _pickAttachmentFile; + if (pickAttachmentFile == null) { + throw Exception("File attachments aren't available on this device."); + } + final pickedFile = await pickAttachmentFile(); + if (pickedFile == null) return null; + + final length = await pickedFile.length(); + if (length > _maxFileSizeBytes) { + throw Exception( + 'File is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.', + ); + } + final bytes = await pickedFile.readAsBytes(); + final descriptor = await _uploadPreparedBytes( + bytes, + mimeType: 'application/octet-stream', + allowGenericFile: true, + ); + return descriptor.withFilename(_safeAttachmentFilename(pickedFile.name)); + } + Future uploadBytes( Uint8List bytes, { required String mimeType, @@ -253,8 +321,10 @@ class MediaUploadService { Future _uploadPreparedBytes( Uint8List bytes, { required String mimeType, + bool allowGenericFile = false, }) async { - if (!_allowedImageMimeTypes.contains(mimeType) && + if (!allowGenericFile && + !_allowedImageMimeTypes.contains(mimeType) && !_allowedVideoMimeTypes.contains(mimeType)) { throw Exception('unsupported file type: $mimeType'); } @@ -421,6 +491,12 @@ class MediaUploadService { } } +String _safeAttachmentFilename(String filename) { + final segments = filename.split(RegExp(r'[/\\]')); + final basename = segments.isEmpty ? '' : segments.last.trim(); + return basename.isEmpty ? 'file' : basename; +} + String _sha256Hex(Uint8List bytes) { final digest = SHA256Digest().process(bytes); return digest.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); @@ -687,6 +763,11 @@ final mediaUploadServiceProvider = Provider((ref) { requestFullMetadata: false, ), pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery), + pickCameraImage: () => picker.pickImage( + source: ImageSource.camera, + requestFullMetadata: false, + ), + pickAttachmentFile: file_selector.openFile, ); ref.onDispose(service.dispose); return service; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index dc062b8628..565808caee 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -129,6 +129,46 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.5" + camera: + dependency: "direct main" + description: + name: camera + sha256: "558230d6ce6ccea856b32d390db7e7b557adf4d9320aa614481bd3f2f608953f" + url: "https://pub.dev" + source: hosted + version: "0.12.0+2" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: b5064cf25a2787d122d0bf12e77c7b1033a2b983d0730e3091f770ee376efde5 + url: "https://pub.dev" + source: hosted + version: "0.7.2" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "866e9cd8370f8055d005c0413937a52dc1d7a472687f0ee3ce02392955aababa" + url: "https://pub.dev" + source: hosted + version: "0.10.2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498" + url: "https://pub.dev" + source: hosted + version: "2.13.1" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "1245a480a113437f8d46d19c0fb90cea9db921436d9cf2ba5fb11854a1312693" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" characters: dependency: transitive description: @@ -329,6 +369,30 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed" + url: "https://pub.dev" + source: hosted + version: "0.5.2+6" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" file_selector_linux: dependency: transitive description: @@ -353,6 +417,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" file_selector_windows: dependency: transitive description: @@ -1165,6 +1237,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index d6f25eb004..3e3601de32 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -26,6 +26,8 @@ dependencies: highlight: ^0.7.0 intl: ^0.20.2 uuid: ^4.5.1 + file_selector: ^1.1.0 + camera: ^0.12.0+2 image_picker: ^1.1.2 video_player: ^2.10.1 package_info_plus: ^10.0.0 diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 629776ef13..3d35865788 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1006,12 +1006,20 @@ void main() { }); group('Compose bar', () { - testWidgets('shows text field and send button', (tester) async { + testWidgets('expands from the channel hint into the composer controls', ( + tester, + ) async { await tester.pumpWidget(_buildTestable(messages: [])); await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsNothing); + + await tester.tap(find.text('Message #general')); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsOneWidget); - expect(find.byIcon(LucideIcons.sendHorizontal), findsOneWidget); + expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsOneWidget); }); testWidgets('shows hint text', (tester) async { diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index dd629dcf88..baa7573c27 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -170,7 +170,10 @@ Widget _buildComposeBar({ ), home: Scaffold( body: SafeArea( - child: ComposeBar(channelId: 'channel-1', onSend: onSend), + child: Align( + alignment: Alignment.bottomCenter, + child: ComposeBar(channelId: 'channel-1', onSend: onSend), + ), ), ), ), @@ -299,14 +302,14 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Photo')); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect(find.byTooltip('Remove attachment'), findsOneWidget); - await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await _expandComposer(tester); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pump(); await tester.pumpAndSettle(); @@ -370,6 +373,7 @@ void main() { ), ); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final insertionConfiguration = textField.contentInsertionConfiguration; expect(insertionConfiguration, isNotNull); @@ -400,7 +404,7 @@ void main() { ); expect(find.byTooltip('Remove attachment'), findsOneWidget); - await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); expect(sentContent, '\n![image](https://relay.example/media/pasted.png)'); @@ -450,6 +454,7 @@ void main() { ), ); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), @@ -513,6 +518,7 @@ void main() { ); await tester.pump(); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), @@ -581,6 +587,7 @@ void main() { ), ); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), @@ -631,6 +638,7 @@ void main() { ), ); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); textField.contentInsertionConfiguration!.onContentInserted( const KeyboardInsertedContent( @@ -669,6 +677,7 @@ void main() { ), ); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), @@ -712,6 +721,7 @@ void main() { ), ); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), @@ -767,9 +777,8 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Photo')); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); final attachmentFinder = find.byKey( @@ -824,9 +833,8 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Photo')); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect(find.textContaining('upload failed'), findsOneWidget); @@ -866,9 +874,8 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Photo')); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect( @@ -916,9 +923,8 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Photo')); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect( @@ -981,12 +987,13 @@ void main() { ); session.debugAttachSocketForTest(socket); + await _expandComposer(tester); await tester.enterText(find.byType(TextField), '@hel'); await tester.pumpAndSettle(); await tester.tap(find.text('Helper Bot')); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); - await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); expect(sentContent, 'hello @Helper Bot'); @@ -1082,12 +1089,13 @@ void main() { ); session.debugAttachSocketForTest(socket); + await _expandComposer(tester); await tester.enterText(find.byType(TextField), '@hel'); await tester.pumpAndSettle(); await tester.tap(find.text('Helper Bot')); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); - await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pump(); expect(didSend, isFalse); @@ -1142,9 +1150,8 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Photo')); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect( @@ -1216,8 +1223,7 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); + await _openAttachmentMenu(tester); await tester.tap(find.text('Video')); // Pump enough frames for the async file read + upload to complete. // Can't use pumpAndSettle here — the upload spinner's animation @@ -1229,7 +1235,7 @@ void main() { // Video attachment should show a video icon (not a broken image). expect(find.byIcon(LucideIcons.video), findsOneWidget); - await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pump(); await tester.pumpAndSettle(); @@ -1454,13 +1460,25 @@ AgentDirectoryEntry _testAgent(String pubkey) { ); } +Future _expandComposer(WidgetTester tester) async { + if (find.byType(TextField).evaluate().isNotEmpty) return; + await tester.tap(find.text('Message\u2026')); + await tester.pumpAndSettle(); +} + +Future _openAttachmentMenu(WidgetTester tester) async { + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pumpAndSettle(); +} + Future _selectAndSendAgentMention(WidgetTester tester) async { + await _expandComposer(tester); await tester.enterText(find.byType(TextField), '@hel'); await tester.pumpAndSettle(); await tester.tap(find.text('Helper Bot')); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); - await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); } From f9924e9775d96fa38fc04f38b9f09cb9d359420d Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 10:27:53 -0700 Subject: [PATCH 2/8] Address mobile composer review feedback --- mobile/lib/features/channels/compose_bar.dart | 3 +- .../channels/compose_bar/attachments.dart | 52 ++++++++++++++++++- mobile/lib/shared/relay/media_upload.dart | 21 ++------ .../features/channels/compose_bar_test.dart | 42 +++++++++++++++ .../test/shared/relay/media_upload_test.dart | 28 ++++++++++ 5 files changed, 125 insertions(+), 21 deletions(-) diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 19d968abf8..f13df16d4d 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -706,9 +706,10 @@ class ComposeBar extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (hasAttachments) ...[ + if (hasAttachments || hasPendingUploads) ...[ _AttachmentStrip( attachments: attachments.value, + uploadingCount: uploadingCount.value, onRemove: removeAttachment, ), const SizedBox(height: Grid.xxs), diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index 8cbd0df755..1d342cd15c 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -175,9 +175,14 @@ List _withoutAttachment( class _AttachmentStrip extends StatelessWidget { final List attachments; + final int uploadingCount; final void Function(String url) onRemove; - const _AttachmentStrip({required this.attachments, required this.onRemove}); + const _AttachmentStrip({ + required this.attachments, + required this.uploadingCount, + required this.onRemove, + }); @override Widget build(BuildContext context) { @@ -188,9 +193,52 @@ class _AttachmentStrip extends StatelessWidget { height: thumbHeight, child: ListView.separated( scrollDirection: Axis.horizontal, - itemCount: attachments.length, + itemCount: attachments.length + (uploadingCount > 0 ? 1 : 0), separatorBuilder: (_, _) => const SizedBox(width: Grid.half), itemBuilder: (context, index) { + if (index == attachments.length) { + final label = uploadingCount == 1 + ? 'Uploading attachment…' + : 'Uploading $uploadingCount attachments…'; + return Semantics( + liveRegion: true, + label: label, + child: Container( + key: const ValueKey('compose-upload-progress'), + width: 128, + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all(color: context.colors.outlineVariant), + ), + padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: context.colors.primary, + ), + ), + const SizedBox(width: Grid.half), + Flexible( + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ); + } + final attachment = attachments[index]; final isVideo = attachment.type.startsWith('video/'); final isImage = attachment.type.startsWith('image/'); diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 1d3ff381a7..8ed822fa3b 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -52,7 +52,6 @@ const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload."; typedef PickGalleryImage = Future Function(); typedef PickGalleryVideo = Future Function(); -typedef PickCameraImage = Future Function(); typedef PickAttachmentFile = Future Function(); typedef SanitizeImageBytes = Future Function(Uint8List bytes, String mimeType); @@ -161,7 +160,6 @@ class MediaUploadService { final String? _nsec; final PickGalleryImage _pickGalleryImage; final PickGalleryVideo _pickGalleryVideo; - final PickCameraImage? _pickCameraImage; final PickAttachmentFile? _pickAttachmentFile; final SanitizeImageBytes _sanitizeImageBytes; final TranscodeImageToJpeg _transcodeImageToJpeg; @@ -176,7 +174,6 @@ class MediaUploadService { required String? nsec, required PickGalleryImage pickGalleryImage, required PickGalleryVideo pickGalleryVideo, - PickCameraImage? pickCameraImage, PickAttachmentFile? pickAttachmentFile, SanitizeImageBytes? sanitizeImageBytes, TranscodeImageToJpeg? transcodeImageToJpeg, @@ -188,7 +185,6 @@ class MediaUploadService { _nsec = nsec, _pickGalleryImage = pickGalleryImage, _pickGalleryVideo = pickGalleryVideo, - _pickCameraImage = pickCameraImage, _pickAttachmentFile = pickAttachmentFile, _sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes, _transcodeImageToJpeg = @@ -219,16 +215,6 @@ class MediaUploadService { ); } - Future captureAndUploadImage() async { - final pickCameraImage = _pickCameraImage; - if (pickCameraImage == null) { - throw Exception("Camera attachments aren't available on this device."); - } - final pickedImage = await pickCameraImage(); - if (pickedImage == null) return null; - return uploadImage(pickedImage); - } - Future clipboardHasImage() async { return await _mediaUploadPlatformChannel.invokeMethod( _clipboardHasImageMethod, @@ -288,6 +274,9 @@ class MediaUploadService { if (pickedFile == null) return null; final length = await pickedFile.length(); + if (length == 0) { + throw Exception('File is empty.'); + } if (length > _maxFileSizeBytes) { throw Exception( 'File is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.', @@ -763,10 +752,6 @@ final mediaUploadServiceProvider = Provider((ref) { requestFullMetadata: false, ), pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery), - pickCameraImage: () => picker.pickImage( - source: ImageSource.camera, - requestFullMetadata: false, - ), pickAttachmentFile: file_selector.openFile, ); ref.onDispose(service.dispose); diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index baa7573c27..bf151f5396 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -323,6 +323,48 @@ void main() { expect(find.byTooltip('Remove attachment'), findsNothing); }); + testWidgets('keeps upload progress visible after the picker closes', ( + tester, + ) async { + final pickedImage = Completer(); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryVideo: () async => null, + pickGalleryImage: () => pickedImage.future, + ); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); + await tester.pump(); + + expect( + find.byKey(const ValueKey('compose-upload-progress')), + findsOneWidget, + ); + expect(find.text('Uploading attachment…'), findsOneWidget); + + pickedImage.complete(null); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('compose-upload-progress')), + findsNothing, + ); + }); + testWidgets('pasted image follows the attachment preview and send path', ( tester, ) async { diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index dc901eba38..ad145d81eb 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -1147,6 +1147,34 @@ void main() { ), ); }); + + test('rejects empty generic file attachments before upload', () async { + var uploadRequested = false; + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient((request) async { + uploadRequested = true; + return http.Response('', HttpStatus.ok); + }), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + pickAttachmentFile: () async => + XFile.fromData(Uint8List(0), name: 'empty.txt'), + ); + + await expectLater( + service.pickAndUploadFile(), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('File is empty'), + ), + ), + ); + expect(uploadRequested, isFalse); + }); }); group('pickAndUploadVideo', () { From 479cffd03658e9eefc68ac7e9ef7e04204e01df1 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 10:47:18 -0700 Subject: [PATCH 3/8] Sanitize mobile attachment filenames --- mobile/lib/shared/relay/media_upload.dart | 21 ++++++++- .../test/shared/relay/media_upload_test.dart | 44 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 8ed822fa3b..93c53e9743 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -482,8 +482,25 @@ class MediaUploadService { String _safeAttachmentFilename(String filename) { final segments = filename.split(RegExp(r'[/\\]')); - final basename = segments.isEmpty ? '' : segments.last.trim(); - return basename.isEmpty ? 'file' : basename; + final basename = segments.isEmpty ? '' : segments.last; + final sanitized = StringBuffer(); + var byteLength = 0; + + for (final rune in basename.runes) { + if ((rune >= 0 && rune <= 0x1f) || (rune >= 0x7f && rune <= 0x9f)) { + continue; + } + + final character = String.fromCharCode(rune); + final characterByteLength = utf8.encode(character).length; + if (byteLength + characterByteLength > 255) break; + + sanitized.write(character); + byteLength += characterByteLength; + } + + final safeBasename = sanitized.toString().trim(); + return safeBasename.isEmpty ? 'file' : safeBasename; } String _sha256Hex(Uint8List bytes) { diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index ad145d81eb..dcd04359b2 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -68,6 +68,15 @@ final _heicBytes = Uint8List.fromList([ 0x63, ]); +class _NamedXFile extends XFile { + final String _name; + + _NamedXFile(super.bytes, this._name) : super.fromData(); + + @override + String get name => _name; +} + final _gifBytes = Uint8List.fromList([ ...ascii.encode('GIF89a'), 0x02, @@ -1175,6 +1184,41 @@ void main() { ); expect(uploadRequested, isFalse); }); + + test('sanitizes generic filenames to relay imeta constraints', () async { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.bin', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': request.bodyBytes.length, + 'type': 'application/octet-stream', + 'uploaded': 1, + }), + HttpStatus.ok, + ); + }), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + pickAttachmentFile: () async => _NamedXFile( + Uint8List.fromList([1]), + 'folder\\draft\u0000${List.filled(200, 'é').join()}.txt', + ), + ); + + final descriptor = await service.pickAndUploadFile(); + final filename = descriptor!.filename!; + + expect(filename, startsWith('draft')); + expect(filename, isNot(contains('\u0000'))); + expect(filename, isNot(contains('/'))); + expect(filename, isNot(contains('\\'))); + expect(utf8.encode(filename).length, lessThanOrEqualTo(255)); + }); }); group('pickAndUploadVideo', () { From 0532f36f5007f200d2c521888bc1ab331002b60d Mon Sep 17 00:00:00 2001 From: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Date: Fri, 24 Jul 2026 12:43:23 -0700 Subject: [PATCH 4/8] Restore composer emoji picker action Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> --- mobile/lib/features/channels/compose_bar.dart | 26 ++++++++++++ .../features/channels/compose_bar_test.dart | 40 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index f13df16d4d..82d057290b 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -22,6 +22,7 @@ import '../custom_emoji/custom_emoji_provider.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; import 'channels_provider.dart'; +import 'emoji_picker.dart'; import 'mentions/mention_candidates.dart'; import 'mentions/mention_candidates_provider.dart'; import 'mentions/mention_ranking.dart'; @@ -324,6 +325,20 @@ class ComposeBar extends HookConsumerWidget { // Insert `#` at the cursor to manually trigger channel mode. void triggerChannel() => _insertTriggerAtCursor(controller, focusNode, '#'); + // Insert a selected emoji at the cursor without replacing the draft. + void insertEmoji(String emoji) { + final text = controller.text; + final selection = controller.selection; + final cursor = selection.isValid + ? selection.baseOffset.clamp(0, text.length) + : text.length; + controller.value = TextEditingValue( + text: text.replaceRange(cursor, cursor, emoji), + selection: TextSelection.collapsed(offset: cursor + emoji.length), + ); + focusNode.requestFocus(); + } + void clearComposer() { controller.clear(); attachments.value = []; @@ -865,6 +880,17 @@ class ComposeBar extends HookConsumerWidget { triggerChannel(); }, ), + _ComposeAction( + icon: LucideIcons.smilePlus, + onTap: () { + showAttachments.value = false; + showCamera.value = false; + showEmojiPicker( + context: context, + onSelect: insertEmoji, + ); + }, + ), _ComposeAction( icon: LucideIcons.aLargeSmall, onTap: () { diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index bf151f5396..24644929c2 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -18,6 +18,8 @@ import 'package:buzz/features/channels/compose_bar.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; +import 'package:buzz/features/custom_emoji/custom_emoji.dart'; +import 'package:buzz/features/custom_emoji/custom_emoji_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -141,9 +143,11 @@ Widget _buildComposeBar({ List channels = const [], String? currentPubkey, bool? supportsShowingSystemContextMenu, + List customEmoji = const [], }) { return ProviderScope( overrides: [ + customEmojiListProvider.overrideWithValue(customEmoji), mediaUploadServiceProvider.overrideWithValue(uploadService), currentPubkeyProvider.overrideWith((ref) => currentPubkey), channelMembersProvider( @@ -258,6 +262,42 @@ void main() { }); group('ComposeBar', () { + testWidgets('inserts a community emoji at the cursor from the action row', ( + tester, + ) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + customEmoji: const [ + CustomEmoji(shortcode: 'meow', url: 'https://example.com/meow.png'), + ], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), 'hello world'); + final textField = tester.widget(find.byType(TextField)); + textField.controller!.selection = const TextSelection.collapsed( + offset: 6, + ); + + await tester.tap(find.byIcon(LucideIcons.smilePlus)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.sparkles)); + await tester.pump(); + await tester.tap(find.byTooltip(':meow:')); + await tester.pumpAndSettle(); + + expect(textField.controller!.text, 'hello :meow:world'); + expect(textField.controller!.selection.baseOffset, 12); + }); + testWidgets('uploads an image and sends markdown plus imeta tags', ( tester, ) async { From 6e89bce08ebd3321b36e3dfdd4669f4d4319379f Mon Sep 17 00:00:00 2001 From: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Date: Fri, 24 Jul 2026 12:47:07 -0700 Subject: [PATCH 5/8] Open local attachments with media auth Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> --- .../features/channels/message_content.dart | 72 +++++++++++++++++-- mobile/lib/shared/relay/media_auth.dart | 12 ++-- mobile/pubspec.lock | 14 +++- mobile/pubspec.yaml | 2 + .../channels/message_content_test.dart | 40 +++++++++++ 5 files changed, 129 insertions(+), 11 deletions(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index c55494d67f..1dc892acfd 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -6,6 +7,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:gpt_markdown/custom_widgets/markdown_config.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../shared/clipboard_utils.dart'; @@ -21,6 +24,47 @@ import 'message_media.dart'; const _messageMediaMaxInlineWidth = 320.0; const _messageMediaMaxImageHeight = 240.0; +typedef OpenDownloadedFile = + Future Function( + String url, + Map headers, + String filename, + ); + +final openDownloadedFileProvider = Provider((ref) { + final client = ref.watch(mediaHttpClientProvider); + return (url, headers, filename) async { + final response = await client.get(Uri.parse(url), headers: headers); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw HttpException( + 'Attachment download failed (${response.statusCode})', + uri: Uri.parse(url), + ); + } + + final directory = await getTemporaryDirectory(); + final safeName = _safeDownloadedFilename(filename); + final file = File( + '${directory.path}${Platform.pathSeparator}' + '${DateTime.now().microsecondsSinceEpoch}-$safeName', + ); + await file.writeAsBytes(response.bodyBytes, flush: true); + final result = await OpenFilex.open(file.path); + if (result.type != ResultType.done) { + throw FileSystemException(result.message, file.path); + } + }; +}); + +String _safeDownloadedFilename(String filename) { + final safe = filename + .split(RegExp(r'[/\\]')) + .last + .replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '') + .trim(); + return safe.isEmpty ? 'attachment' : safe; +} + /// Renders message content with markdown formatting, @mentions, #channel links, /// and media-aware markdown images/videos. class MessageContent extends HookConsumerWidget { @@ -151,7 +195,7 @@ class MessageContent extends HookConsumerWidget { codeBuilder: (context, name, code, closed) => _MessageCodeBlock(name: name, code: code), linkBuilder: (context, linkText, url, linkStyle) => - _buildLink(context, linkText, url, linkStyle, style), + _buildLink(context, ref, linkText, url, linkStyle, style), imageBuilder: (context, imageUrl) => _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), maxLines: maxLines, @@ -182,6 +226,7 @@ class MessageContent extends HookConsumerWidget { Widget _buildLink( BuildContext context, + WidgetRef ref, InlineSpan linkText, String url, TextStyle linkStyle, @@ -198,10 +243,29 @@ class MessageContent extends HookConsumerWidget { final baseStyle = fallbackStyle ?? linkStyle; return GestureDetector( - onTap: () { + onTap: () async { final uri = Uri.tryParse(url); - if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) { - launchUrl(uri, mode: LaunchMode.externalApplication); + if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) { + return; + } + + final auth = ref.read(mediaGetAuthServiceProvider); + if (!auth.isRelayMediaUrl(url)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + return; + } + + try { + await ref.read(openDownloadedFileProvider)( + url, + auth.headersFor(url), + text, + ); + } catch (_) { + if (!context.mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar(content: Text('Could not open attachment')), + ); } }, child: Text( diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index 3982fb48d1..b21eeca36d 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -40,13 +40,17 @@ class MediaGetAuthService { _nsec = nsec, _now = now ?? DateTime.now; + bool isRelayMediaUrl(String url) { + final uri = Uri.tryParse(url); + final relayUri = Uri.tryParse(_baseUrl); + if (uri == null || relayUri == null) return false; + return _isRelayMediaUrl(uri, relayUri); + } + Map headersFor(String url) { final nsec = _nsec; if (nsec == null || nsec.isEmpty) return const {}; - final uri = Uri.tryParse(url); - final relayUri = Uri.tryParse(_baseUrl); - if (uri == null || relayUri == null) return const {}; - if (!_isRelayMediaUrl(uri, relayUri)) return const {}; + if (!isRelayMediaUrl(url)) return const {}; final cached = _cachedHeaders; final refreshAt = _refreshAt; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 565808caee..55185fc9c6 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -896,6 +896,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + open_filex: + dependency: "direct main" + description: + name: open_filex + sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900" + url: "https://pub.dev" + source: hosted + version: "4.7.0" package_config: dependency: transitive description: @@ -937,13 +945,13 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3e3601de32..b90f50c25a 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -34,6 +34,8 @@ dependencies: app_badge_plus: ^1.2.10 app_links: ^6.4.0 scrollable_positioned_list: ^0.3.8 + open_filex: ^4.7.0 + path_provider: ^2.1.6 dev_dependencies: flutter_test: diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 02821157a8..bcb46f0c37 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -3,8 +3,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/misc.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/media_viewer_page.dart'; +import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; Widget _testable(Widget child, {List overrides = const []}) { @@ -135,6 +137,44 @@ bool _spanHasStyle( void main() { group('MessageContent', () { + testWidgets('opens local file links through an authenticated download', ( + tester, + ) async { + const url = 'https://relay.example/media/report.pdf'; + String? openedUrl; + Map? openedHeaders; + String? openedFilename; + final auth = MediaGetAuthService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + ); + + await tester.pumpWidget( + _testable( + const MessageContent(content: '[report.pdf]($url)'), + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(auth), + openDownloadedFileProvider.overrideWithValue(( + url, + headers, + filename, + ) async { + openedUrl = url; + openedHeaders = headers; + openedFilename = filename; + }), + ], + ), + ); + + await tester.tap(find.text('report.pdf')); + await tester.pump(); + + expect(openedUrl, url); + expect(openedFilename, 'report.pdf'); + expect(openedHeaders?['Authorization'], startsWith('Nostr ')); + }); + test('buildImageViewerRoute uses modal-style page route builder', () { final route = buildImageViewerRoute( imageUrl: 'https://example.com/media/image.png', From eabeac01589695fa0f56eeddd1231d4b64a3cc23 Mon Sep 17 00:00:00 2001 From: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Date: Fri, 24 Jul 2026 12:47:51 -0700 Subject: [PATCH 6/8] Delete temporary inline camera captures Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> --- .../channels/camera_capture_cleanup.dart | 21 +++++++++++ mobile/lib/features/channels/compose_bar.dart | 1 + .../channels/compose_bar/camera_preview.dart | 6 +++- .../channels/camera_capture_cleanup_test.dart | 35 +++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 mobile/lib/features/channels/camera_capture_cleanup.dart create mode 100644 mobile/test/features/channels/camera_capture_cleanup_test.dart diff --git a/mobile/lib/features/channels/camera_capture_cleanup.dart b/mobile/lib/features/channels/camera_capture_cleanup.dart new file mode 100644 index 0000000000..6937218c9e --- /dev/null +++ b/mobile/lib/features/channels/camera_capture_cleanup.dart @@ -0,0 +1,21 @@ +import 'dart:io'; + +import 'package:image_picker/image_picker.dart'; + +Future processCapturedImage( + XFile image, + Future Function(XFile image) onCapture, +) async { + try { + await onCapture(image); + } finally { + final path = image.path; + if (path.isNotEmpty) { + try { + await File(path).delete(); + } on FileSystemException { + // The camera plugin may already have removed its temporary file. + } + } + } +} diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 82d057290b..9ddf0b4a0a 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -19,6 +19,7 @@ import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../custom_emoji/custom_emoji.dart'; import '../custom_emoji/custom_emoji_provider.dart'; +import 'camera_capture_cleanup.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; import 'channels_provider.dart'; diff --git a/mobile/lib/features/channels/compose_bar/camera_preview.dart b/mobile/lib/features/channels/compose_bar/camera_preview.dart index c51d1a1b31..87bf072778 100644 --- a/mobile/lib/features/channels/compose_bar/camera_preview.dart +++ b/mobile/lib/features/channels/compose_bar/camera_preview.dart @@ -100,7 +100,11 @@ class _InlineCameraPreview extends HookConsumerWidget { error.value = null; try { final image = await activeController.takePicture(); - if (context.mounted) await onCapture(image); + if (context.mounted) { + await processCapturedImage(image, onCapture); + } else { + await processCapturedImage(image, (_) async {}); + } } catch (captureError) { if (context.mounted) { error.value = _cameraErrorMessage(captureError); diff --git a/mobile/test/features/channels/camera_capture_cleanup_test.dart b/mobile/test/features/channels/camera_capture_cleanup_test.dart new file mode 100644 index 0000000000..2b5360035d --- /dev/null +++ b/mobile/test/features/channels/camera_capture_cleanup_test.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:buzz/features/channels/camera_capture_cleanup.dart'; + +void main() { + test('deletes the inline camera file after upload succeeds', () async { + final file = File( + '${Directory.systemTemp.path}/buzz-camera-${DateTime.now().microsecondsSinceEpoch}.jpg', + ); + await file.writeAsBytes([1, 2, 3]); + + await processCapturedImage(XFile(file.path), (_) async {}); + + expect(await file.exists(), isFalse); + }); + + test('deletes the inline camera file when upload fails', () async { + final file = File( + '${Directory.systemTemp.path}/buzz-camera-${DateTime.now().microsecondsSinceEpoch}.jpg', + ); + await file.writeAsBytes([1, 2, 3]); + + await expectLater( + processCapturedImage( + XFile(file.path), + (_) async => throw Exception('upload failed'), + ), + throwsException, + ); + + expect(await file.exists(), isFalse); + }); +} From f1dda3aa49babf34bbd4e97287170f616bc254a8 Mon Sep 17 00:00:00 2001 From: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Date: Fri, 24 Jul 2026 13:16:15 -0700 Subject: [PATCH 7/8] Update iOS pod lock for attachment opener Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> --- mobile/ios/Podfile.lock | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 1b79aa0b74..ba8c828d25 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -18,6 +18,8 @@ PODS: - mobile_scanner (7.0.0): - Flutter - FlutterMacOS + - open_filex (0.0.2): + - Flutter - package_info_plus (0.4.5): - Flutter - shared_preferences_foundation (0.0.1): @@ -39,6 +41,7 @@ DEPENDENCIES: - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) + - open_filex (from `.symlinks/plugins/open_filex/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) @@ -63,6 +66,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/image_picker_ios/ios" mobile_scanner: :path: ".symlinks/plugins/mobile_scanner/darwin" + open_filex: + :path: ".symlinks/plugins/open_filex/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" shared_preferences_foundation: @@ -82,6 +87,7 @@ SPEC CHECKSUMS: flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 + open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b From f8875d95fb151778042a48425505bde635ac0f60 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 15:51:28 -0700 Subject: [PATCH 8/8] Polish mobile composer capture and formatting --- mobile/lib/features/channels/compose_bar.dart | 12 +- .../channels/compose_bar/camera_preview.dart | 50 ++-- .../compose_bar/formatting_toolbar.dart | 2 +- .../markdown_editing_controller.dart | 249 ++++++++++++++++++ .../features/channels/compose_bar_test.dart | 122 +++++++++ 5 files changed, 404 insertions(+), 31 deletions(-) create mode 100644 mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 9ddf0b4a0a..8711ff7b3a 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -29,6 +29,7 @@ import 'mentions/mention_candidates_provider.dart'; import 'mentions/mention_ranking.dart'; part 'compose_bar/helpers.dart'; +part 'compose_bar/markdown_editing_controller.dart'; part 'compose_bar/suggestions.dart'; part 'compose_bar/formatting_toolbar.dart'; part 'compose_bar/attachments.dart'; @@ -42,9 +43,9 @@ const _pastedImageMimeTypes = [ 'image/webp', ]; -/// Rich compose bar with @mention autocomplete, emoji picker, and a markdown -/// formatting toolbar. Used in both channel and thread views — the caller -/// provides an [onSend] callback that handles actual message submission. +/// Rich compose bar with @mention autocomplete and a markdown formatting +/// toolbar. Used in both channel and thread views — the caller provides an +/// [onSend] callback that handles actual message submission. typedef ComposeBarOnSend = Future Function( String content, @@ -74,7 +75,8 @@ class ComposeBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final controller = useTextEditingController(); + final controller = useMemoized(_MarkdownEditingController.new); + useEffect(() => controller.dispose, [controller]); final focusNode = useFocusNode(); final isComposerExpanded = useState(false); final showAttachments = useState(false); @@ -650,10 +652,10 @@ class ComposeBar extends HookConsumerWidget { child: _InlineCameraPreview( onClose: () => showCamera.value = false, onCapture: (image) async { + showCamera.value = false; await pickAndUpload( () => ref.read(mediaUploadServiceProvider).uploadImage(image), ); - if (context.mounted) showCamera.value = false; }, ), ) diff --git a/mobile/lib/features/channels/compose_bar/camera_preview.dart b/mobile/lib/features/channels/compose_bar/camera_preview.dart index 87bf072778..df16827ba5 100644 --- a/mobile/lib/features/channels/compose_bar/camera_preview.dart +++ b/mobile/lib/features/channels/compose_bar/camera_preview.dart @@ -147,7 +147,7 @@ class _InlineCameraPreview extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.all(Grid.twelve), child: _CameraCaptureButton( - isCapturing: isCapturing.value, + isPressed: isCapturing.value, onTap: capture, ), ), @@ -233,41 +233,41 @@ class _CameraPlaceholder extends StatelessWidget { } class _CameraCaptureButton extends StatelessWidget { - final bool isCapturing; + final bool isPressed; final VoidCallback onTap; - const _CameraCaptureButton({required this.isCapturing, required this.onTap}); + const _CameraCaptureButton({required this.isPressed, required this.onTap}); @override Widget build(BuildContext context) { + final duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 100); + return Semantics( button: true, label: 'Take photo', child: GestureDetector( - onTap: isCapturing ? null : onTap, - child: Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.24), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 3), - ), - padding: const EdgeInsets.all(Grid.half), - child: DecoratedBox( - decoration: const BoxDecoration( - color: Colors.white, + onTap: isPressed ? null : onTap, + child: AnimatedScale( + scale: isPressed ? 0.92 : 1, + duration: duration, + curve: Curves.easeOutCubic, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.24), shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + ), + padding: const EdgeInsets.all(Grid.half), + child: const DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), ), - child: isCapturing - ? const Padding( - padding: EdgeInsets.all(Grid.twelve), - child: CircularProgressIndicator( - color: Colors.black, - strokeWidth: 2, - ), - ) - : null, ), ), ), diff --git a/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart b/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart index 66f665a38e..c9089a99cb 100644 --- a/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart +++ b/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart @@ -61,7 +61,7 @@ class _FormatButton extends StatelessWidget { onTap: onTap, child: Padding( padding: const EdgeInsets.all(Grid.xxs), - child: Icon(icon, size: 18, color: context.colors.onSurfaceVariant), + child: Icon(icon, size: 18, color: context.colors.primary), ), ), ); diff --git a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart new file mode 100644 index 0000000000..29e22a8ca9 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart @@ -0,0 +1,249 @@ +part of '../compose_bar.dart'; + +enum _MarkdownStyle { codeBlock, inlineCode, bold, italic, strikethrough } + +class _MarkdownRule { + final RegExp pattern; + final _MarkdownStyle style; + final bool parsesNestedFormatting; + + _MarkdownRule( + String pattern, + this.style, { + this.parsesNestedFormatting = true, + }) : pattern = RegExp(pattern); +} + +class _MarkdownEditingController extends TextEditingController { + static final _rules = [ + _MarkdownRule( + r'```(?:\r?\n)?([\s\S]*?)(?:\r?\n)?```', + _MarkdownStyle.codeBlock, + parsesNestedFormatting: false, + ), + _MarkdownRule( + r'`([^`\n]*?)`', + _MarkdownStyle.inlineCode, + parsesNestedFormatting: false, + ), + _MarkdownRule(r'\*\*([^\n]*?)\*\*', _MarkdownStyle.bold), + _MarkdownRule(r'~~([^\n]*?)~~', _MarkdownStyle.strikethrough), + _MarkdownRule(r'_([^_\n]*?)_', _MarkdownStyle.italic), + ]; + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + final baseStyle = style ?? const TextStyle(); + final composingRange = + withComposing && value.composing.isValid && !value.composing.isCollapsed + ? value.composing + : TextRange.empty; + return TextSpan( + style: baseStyle, + children: _buildMarkdownSpans( + context, + text, + baseStyle, + sourceOffset: 0, + composingRange: composingRange, + ), + ); + } + + List _buildMarkdownSpans( + BuildContext context, + String source, + TextStyle inheritedStyle, { + required int sourceOffset, + required TextRange composingRange, + }) { + final spans = []; + var offset = 0; + + while (offset < source.length) { + final tail = source.substring(offset); + _MarkdownRule? nextRule; + RegExpMatch? nextMatch; + + for (final rule in _rules) { + final match = rule.pattern.firstMatch(tail); + if (match == null) continue; + if (nextMatch == null || match.start < nextMatch.start) { + nextRule = rule; + nextMatch = match; + } + } + + if (nextRule == null || nextMatch == null) { + spans.addAll( + _buildTextSpans( + source.substring(offset), + inheritedStyle, + sourceOffset + offset, + composingRange, + ), + ); + break; + } + + if (nextMatch.start > 0) { + spans.addAll( + _buildTextSpans( + tail.substring(0, nextMatch.start), + inheritedStyle, + sourceOffset + offset, + composingRange, + ), + ); + } + + final fullMatch = nextMatch.group(0)!; + final content = nextMatch.group(1)!; + final (contentStart, contentEnd) = _contentBounds( + fullMatch, + nextRule.style, + ); + final contentStyle = _styleFor(context, inheritedStyle, nextRule.style); + final matchOffset = sourceOffset + offset + nextMatch.start; + + spans.add( + TextSpan( + text: fullMatch.substring(0, contentStart), + style: _hiddenSyntaxStyle(inheritedStyle), + ), + ); + if (nextRule.parsesNestedFormatting) { + spans.addAll( + _buildMarkdownSpans( + context, + content, + contentStyle, + sourceOffset: matchOffset + contentStart, + composingRange: composingRange, + ), + ); + } else { + spans.addAll( + _buildTextSpans( + content, + contentStyle, + matchOffset + contentStart, + composingRange, + ), + ); + } + spans.add( + TextSpan( + text: fullMatch.substring(contentEnd), + style: _hiddenSyntaxStyle(inheritedStyle), + ), + ); + + offset += nextMatch.end; + } + + return spans; + } + + List _buildTextSpans( + String source, + TextStyle style, + int sourceOffset, + TextRange composingRange, + ) { + if (source.isEmpty) return const []; + final localStart = (composingRange.start - sourceOffset) + .clamp(0, source.length) + .toInt(); + final localEnd = (composingRange.end - sourceOffset) + .clamp(0, source.length) + .toInt(); + if (!composingRange.isValid || + composingRange.isCollapsed || + localStart >= localEnd) { + return [TextSpan(text: source, style: style)]; + } + + final composingDecorations = [ + if (style.decoration != null && style.decoration != TextDecoration.none) + style.decoration!, + TextDecoration.underline, + ]; + final composingStyle = style.copyWith( + decoration: TextDecoration.combine(composingDecorations), + ); + return [ + if (localStart > 0) + TextSpan(text: source.substring(0, localStart), style: style), + TextSpan( + text: source.substring(localStart, localEnd), + style: composingStyle, + ), + if (localEnd < source.length) + TextSpan(text: source.substring(localEnd), style: style), + ]; + } + + (int, int) _contentBounds(String fullMatch, _MarkdownStyle markdownStyle) { + final delimiterLength = switch (markdownStyle) { + _MarkdownStyle.bold || _MarkdownStyle.strikethrough => 2, + _MarkdownStyle.italic || _MarkdownStyle.inlineCode => 1, + _MarkdownStyle.codeBlock => 3, + }; + if (markdownStyle != _MarkdownStyle.codeBlock) { + return (delimiterLength, fullMatch.length - delimiterLength); + } + + final openingLength = fullMatch.startsWith('```\r\n') + ? 5 + : fullMatch.startsWith('```\n') + ? 4 + : delimiterLength; + final closingLength = fullMatch.endsWith('\r\n```') + ? 5 + : fullMatch.endsWith('\n```') + ? 4 + : delimiterLength; + return (openingLength, fullMatch.length - closingLength); + } + + TextStyle _styleFor( + BuildContext context, + TextStyle inheritedStyle, + _MarkdownStyle markdownStyle, + ) { + return switch (markdownStyle) { + _MarkdownStyle.bold => inheritedStyle.merge( + const TextStyle(fontWeight: FontWeight.w700), + ), + _MarkdownStyle.italic => inheritedStyle.merge( + const TextStyle(fontStyle: FontStyle.italic), + ), + _MarkdownStyle.strikethrough => inheritedStyle.merge( + const TextStyle(decoration: TextDecoration.lineThrough), + ), + _MarkdownStyle.inlineCode || + _MarkdownStyle.codeBlock => inheritedStyle.merge( + TextStyle( + fontFamily: 'GeistMono', + color: context.colors.onSurface, + backgroundColor: context.colors.surface, + ), + ), + }; + } + + TextStyle _hiddenSyntaxStyle(TextStyle inheritedStyle) { + return inheritedStyle.copyWith( + color: Colors.transparent, + fontSize: 0.01, + height: 0.01, + letterSpacing: 0, + decoration: TextDecoration.none, + ); + } +} diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 24644929c2..a4953401ae 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -405,6 +405,108 @@ void main() { ); }); + testWidgets('renders markdown formatting without visible delimiters', ( + 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), + '**bold** _italic_ ~~strike~~ `code`', + ); + + final textField = tester.widget(find.byType(TextField)); + final textSpan = textField.controller!.buildTextSpan( + context: tester.element(find.byType(TextField)), + style: tester.element(find.byType(TextField)).textTheme.bodyLarge, + withComposing: false, + ); + final spans = _flattenStyledTextSpans(textSpan); + + expect(textSpan.toPlainText(), '**bold** _italic_ ~~strike~~ `code`'); + expect( + spans.singleWhere((span) => span.text == 'bold').style.fontWeight, + FontWeight.w700, + ); + expect( + spans.singleWhere((span) => span.text == 'italic').style.fontStyle, + FontStyle.italic, + ); + expect( + spans.singleWhere((span) => span.text == 'strike').style.decoration, + TextDecoration.lineThrough, + ); + expect( + spans.singleWhere((span) => span.text == 'code').style.fontFamily, + 'GeistMono', + ); + expect( + spans + .where((span) => {'**', '_', '~~', '`'}.contains(span.text)) + .every((span) => (span.style.fontSize ?? 1) < 1), + isTrue, + ); + + textField.controller!.value = textField.controller!.value.copyWith( + composing: const TextRange(start: 2, end: 6), + ); + final composingSpan = textField.controller!.buildTextSpan( + context: tester.element(find.byType(TextField)), + style: tester.element(find.byType(TextField)).textTheme.bodyLarge, + withComposing: true, + ); + final composingSpans = _flattenStyledTextSpans(composingSpan); + expect( + composingSpans + .where((span) => span.text == '**') + .every((span) => (span.style.fontSize ?? 1) < 1), + isTrue, + ); + expect( + composingSpans + .singleWhere((span) => span.text == 'bold') + .style + .decoration, + TextDecoration.underline, + ); + }); + + testWidgets('uses the primary color for formatting actions', ( + tester, + ) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + await tester.tap(find.byIcon(LucideIcons.aLargeSmall)); + await tester.pumpAndSettle(); + + final boldFinder = find.byIcon(LucideIcons.bold); + final boldIcon = tester.widget(boldFinder); + final colors = tester.element(boldFinder).colors; + expect(boldIcon.color, colors.primary); + }); + testWidgets('pasted image follows the attachment preview and send path', ( tester, ) async { @@ -1564,6 +1666,26 @@ Future _selectAndSendAgentMention(WidgetTester tester) async { await tester.pumpAndSettle(); } +List<({String text, TextStyle style})> _flattenStyledTextSpans( + InlineSpan root, +) { + final result = <({String text, TextStyle style})>[]; + + void visit(InlineSpan span, TextStyle inheritedStyle) { + if (span is! TextSpan) return; + final effectiveStyle = inheritedStyle.merge(span.style); + if (span.text case final text?) { + result.add((text: text, style: effectiveStyle)); + } + for (final child in span.children ?? const []) { + visit(child, effectiveStyle); + } + } + + visit(root, const TextStyle()); + return result; +} + Channel _makeCurrentChannel({String channelType = 'stream'}) { return Channel( id: 'channel-1',