diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock
index ea62f66562..ba8c828d25 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
@@ -14,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):
@@ -28,11 +34,14 @@ 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`)
- 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`)
@@ -43,8 +52,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:
@@ -53,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:
@@ -65,11 +80,14 @@ 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
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
+ open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
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/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 0285cda560..8711ff7b3a 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';
@@ -16,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';
@@ -25,9 +29,11 @@ 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';
+part 'compose_bar/camera_preview.dart';
part 'compose_bar/send_button.dart';
const _pastedImageMimeTypes = [
@@ -37,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,
@@ -69,8 +75,12 @@ 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);
+ final showCamera = useState(false);
final isSending = useState(false);
final showFormatting = useState(false);
final attachments = useState>([]);
@@ -80,11 +90,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;
@@ -288,12 +328,28 @@ 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 = [];
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 +546,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,61 +580,150 @@ 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;
+ }
- // Compose chrome — bottom-sheet style container.
- Container(
+ final motionDuration = reducedMotion
+ ? Duration.zero
+ : const Duration(milliseconds: 180);
+ final suggestionOverlayController = useMemoized(
+ OverlayPortalController.new,
+ );
+
+ 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 {
+ showCamera.value = false;
+ await pickAndUpload(
+ () => ref.read(mediaUploadServiceProvider).uploadImage(image),
+ );
+ },
+ ),
+ )
+ : 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) ...[
_AttachmentStrip(
attachments: attachments.value,
@@ -616,110 +746,185 @@ 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.smilePlus,
+ onTap: () {
+ showAttachments.value = false;
+ showCamera.value = false;
+ showEmojiPicker(
+ context: context,
+ onSelect: insertEmoji,
+ );
+ },
+ ),
+ _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..1d342cd15c 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,
@@ -53,25 +193,55 @@ class _AttachmentStrip extends StatelessWidget {
height: thumbHeight,
child: ListView.separated(
scrollDirection: Axis.horizontal,
- itemCount: attachments.length + uploadingCount,
+ itemCount: attachments.length + (uploadingCount > 0 ? 1 : 0),
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),
+ 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/');
final previewUrl = attachment.thumb ?? attachment.url;
return Container(
key: ValueKey('compose-attachment:${attachment.url}'),
@@ -96,7 +266,8 @@ class _AttachmentStrip extends StatelessWidget {
),
),
)
- : MediaImage(
+ : isImage
+ ? MediaImage(
url: previewUrl,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
@@ -106,6 +277,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..df16827ba5
--- /dev/null
+++ b/mobile/lib/features/channels/compose_bar/camera_preview.dart
@@ -0,0 +1,312 @@
+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 processCapturedImage(image, onCapture);
+ } else {
+ await processCapturedImage(image, (_) async {});
+ }
+ } 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(
+ isPressed: 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 isPressed;
+ final VoidCallback 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: 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,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+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..c9089a99cb 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```'),
+ ),
+ ],
);
}
}
@@ -62,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),
),
),
);
@@ -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/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/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/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/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart
index b868fc2845..93c53e9743 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,12 @@ 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 PickAttachmentFile = Future Function();
typedef SanitizeImageBytes =
Future Function(Uint8List bytes, String mimeType);
typedef TranscodeImageToJpeg = Future Function(Uint8List bytes);
@@ -83,6 +86,7 @@ class BlobDescriptor {
final String? thumb;
final double? duration;
final String? image;
+ final String? filename;
const BlobDescriptor({
required this.url,
@@ -95,6 +99,7 @@ class BlobDescriptor {
this.thumb,
this.duration,
this.image,
+ this.filename,
});
factory BlobDescriptor.fromJson(Map json) => BlobDescriptor(
@@ -108,6 +113,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 +141,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/') ? '' : '';
+ String toMarkdownImage() {
+ if (type.startsWith('video/')) return '';
+ if (type.startsWith('image/')) return '';
+ final label = (filename ?? 'file').replaceAllMapped(
+ RegExp(r'[\\\[\]]'),
+ (match) => '\\${match[0]}',
+ );
+ return '[$label]($url)';
+ }
}
class MediaUploadService {
@@ -132,6 +160,7 @@ class MediaUploadService {
final String? _nsec;
final PickGalleryImage _pickGalleryImage;
final PickGalleryVideo _pickGalleryVideo;
+ final PickAttachmentFile? _pickAttachmentFile;
final SanitizeImageBytes _sanitizeImageBytes;
final TranscodeImageToJpeg _transcodeImageToJpeg;
final TranscodeVideoToMp4 _transcodeVideoToMp4;
@@ -145,6 +174,7 @@ class MediaUploadService {
required String? nsec,
required PickGalleryImage pickGalleryImage,
required PickGalleryVideo pickGalleryVideo,
+ PickAttachmentFile? pickAttachmentFile,
SanitizeImageBytes? sanitizeImageBytes,
TranscodeImageToJpeg? transcodeImageToJpeg,
TranscodeVideoToMp4? transcodeVideoToMp4,
@@ -155,6 +185,7 @@ class MediaUploadService {
_nsec = nsec,
_pickGalleryImage = pickGalleryImage,
_pickGalleryVideo = pickGalleryVideo,
+ _pickAttachmentFile = pickAttachmentFile,
_sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes,
_transcodeImageToJpeg =
transcodeImageToJpeg ?? _transcodePickedImageToJpeg,
@@ -234,6 +265,32 @@ 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 == 0) {
+ throw Exception('File is empty.');
+ }
+ 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 +310,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 +480,29 @@ class MediaUploadService {
}
}
+String _safeAttachmentFilename(String filename) {
+ final segments = filename.split(RegExp(r'[/\\]'));
+ 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) {
final digest = SHA256Digest().process(bytes);
return digest.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
@@ -687,6 +769,7 @@ final mediaUploadServiceProvider = Provider((ref) {
requestFullMetadata: false,
),
pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery),
+ pickAttachmentFile: file_selector.openFile,
);
ref.onDispose(service.dispose);
return service;
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index dc062b8628..55185fc9c6 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:
@@ -824,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:
@@ -865,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:
@@ -1165,6 +1245,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..b90f50c25a 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -26,12 +26,16 @@ 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
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/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);
+ });
+}
diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart
index c1236c2a67..01fae2b799 100644
--- a/mobile/test/features/channels/channel_detail_page_test.dart
+++ b/mobile/test/features/channels/channel_detail_page_test.dart
@@ -1272,12 +1272,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..a4953401ae 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(
@@ -170,7 +174,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),
+ ),
),
),
),
@@ -255,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 {
@@ -299,14 +342,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();
@@ -320,6 +363,150 @@ 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('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 {
@@ -370,6 +557,7 @@ void main() {
),
);
+ await _expandComposer(tester);
final textField = tester.widget(find.byType(TextField));
final insertionConfiguration = textField.contentInsertionConfiguration;
expect(insertionConfiguration, isNotNull);
@@ -400,7 +588,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');
@@ -450,6 +638,7 @@ void main() {
),
);
+ await _expandComposer(tester);
final textField = tester.widget(find.byType(TextField));
final editableTextState = tester.state(
find.byType(EditableText),
@@ -513,6 +702,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 +771,7 @@ void main() {
),
);
+ await _expandComposer(tester);
final textField = tester.widget(find.byType(TextField));
final editableTextState = tester.state(
find.byType(EditableText),
@@ -631,6 +822,7 @@ void main() {
),
);
+ await _expandComposer(tester);
final textField = tester.widget(find.byType(TextField));
textField.contentInsertionConfiguration!.onContentInserted(
const KeyboardInsertedContent(
@@ -669,6 +861,7 @@ void main() {
),
);
+ await _expandComposer(tester);
final textField = tester.widget(find.byType(TextField));
final editableTextState = tester.state(
find.byType(EditableText),
@@ -712,6 +905,7 @@ void main() {
),
);
+ await _expandComposer(tester);
final textField = tester.widget(find.byType(TextField));
final editableTextState = tester.state(
find.byType(EditableText),
@@ -767,9 +961,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 +1017,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 +1058,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 +1107,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 +1171,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 +1273,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 +1334,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 +1407,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 +1419,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,16 +1644,48 @@ 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();
}
+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',
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',
diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart
index dc901eba38..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,
@@ -1147,6 +1156,69 @@ 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);
+ });
+
+ 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', () {