From bca4801ed843613b1a6afb2d6ffb5a2f0a3ba574 Mon Sep 17 00:00:00 2001 From: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Date: Mon, 13 Jul 2026 16:00:59 -0700 Subject: [PATCH 1/4] feat(mobile): support pasted image attachments Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> --- mobile/lib/features/channels/compose_bar.dart | 26 ++++ mobile/lib/shared/relay/media_upload.dart | 6 +- .../features/channels/compose_bar_test.dart | 125 ++++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 2eaae9a41e2..20869f9c6a3 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -25,6 +26,13 @@ part 'compose_bar/formatting_toolbar.dart'; part 'compose_bar/attachments.dart'; part 'compose_bar/send_button.dart'; +const _pastedImageMimeTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + '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. @@ -310,6 +318,20 @@ class ComposeBar extends HookConsumerWidget { } } + void uploadPastedImage(KeyboardInsertedContent content) { + final bytes = content.data; + if (bytes == null || bytes.isEmpty) { + uploadError.value = 'Unable to read pasted image'; + return; + } + + pickAndUpload( + () => ref + .read(mediaUploadServiceProvider) + .uploadImage(XFile.fromData(bytes)), + ); + } + // Insert an emoji at the cursor. void insertEmoji(String emoji) { final text = controller.text; @@ -441,6 +463,10 @@ class ComposeBar extends HookConsumerWidget { controller: controller, focusNode: focusNode, textInputAction: TextInputAction.send, + contentInsertionConfiguration: ContentInsertionConfiguration( + allowedMimeTypes: _pastedImageMimeTypes, + onContentInserted: uploadPastedImage, + ), onSubmitted: (_) => send(), minLines: 1, maxLines: 5, diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 58926a8aa15..3a7e4d6c7dd 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -157,7 +157,11 @@ class MediaUploadService { Future pickAndUploadImage() async { final pickedImage = await _pickGalleryImage(); if (pickedImage == null) return null; - final preparedImage = await _prepareUploadImage(pickedImage); + return uploadImage(pickedImage); + } + + Future uploadImage(XFile image) async { + final preparedImage = await _prepareUploadImage(image); return uploadBytes(preparedImage.bytes, mimeType: preparedImage.mimeType); } diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 00e1236ccaa..a3b0b23e3fe 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -224,6 +224,131 @@ void main() { expect(find.byTooltip('Remove attachment'), findsNothing); }); + testWidgets('pasted image follows the attachment preview and send path', ( + tester, + ) async { + final keychain = nostr.Keys.generate(); + var galleryPickerCalled = false; + Uint8List? uploadedBytes; + String? uploadedMimeType; + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: keychain.nsec, + httpClient: http_testing.MockClient((request) async { + uploadedBytes = request.bodyBytes; + uploadedMimeType = request.headers['Content-Type']; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/pasted.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + 'thumb': 'https://relay.example/media/pasted.thumb.jpg', + }), + 200, + ); + }), + pickGalleryVideo: () async => null, + pickGalleryImage: () async { + galleryPickerCalled = true; + return null; + }, + ); + + String? sentContent; + List> sentMediaTags = const []; + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sentContent = content; + sentMediaTags = mediaTags; + }, + ), + ); + + final textField = tester.widget(find.byType(TextField)); + final insertionConfiguration = textField.contentInsertionConfiguration; + expect(insertionConfiguration, isNotNull); + expect( + insertionConfiguration!.allowedMimeTypes, + containsAll(['image/jpeg', 'image/png', 'image/webp']), + ); + + insertionConfiguration.onContentInserted( + KeyboardInsertedContent( + mimeType: 'image/png', + uri: 'content://clipboard/pasted.png', + data: _pngBytes, + ), + ); + await tester.pumpAndSettle(); + + expect(galleryPickerCalled, isFalse); + expect(uploadedBytes, _pngBytes); + expect(uploadedMimeType, 'image/png'); + expect( + find.byKey( + const ValueKey( + 'compose-attachment:https://relay.example/media/pasted.png', + ), + ), + findsOneWidget, + ); + expect(find.byTooltip('Remove attachment'), findsOneWidget); + + await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.pumpAndSettle(); + + expect(sentContent, '\n![image](https://relay.example/media/pasted.png)'); + expect(sentMediaTags, hasLength(1)); + expect( + sentMediaTags.single, + contains('url https://relay.example/media/pasted.png'), + ); + }); + + testWidgets('shows an error when pasted image bytes are unavailable', ( + tester, + ) async { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + final textField = tester.widget(find.byType(TextField)); + textField.contentInsertionConfiguration!.onContentInserted( + const KeyboardInsertedContent( + mimeType: 'image/png', + uri: 'content://clipboard/unavailable.png', + ), + ); + await tester.pump(); + + expect(find.text('Unable to read pasted image'), findsOneWidget); + expect(find.byTooltip('Remove attachment'), findsNothing); + }); + testWidgets('keeps the remove button pinned to the attachment corner', ( tester, ) async { From f2238e34ee49bb6ee4cbe1b59356cc1b24131b82 Mon Sep 17 00:00:00 2001 From: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Date: Mon, 13 Jul 2026 16:18:16 -0700 Subject: [PATCH 2/4] feat(mobile): bridge iOS clipboard image paste Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> --- mobile/ios/Runner/AppDelegate.swift | 22 +++ mobile/ios/RunnerTests/RunnerTests.swift | 37 ++++- mobile/lib/features/channels/compose_bar.dart | 29 ++++ mobile/lib/shared/relay/media_upload.dart | 19 +++ .../features/channels/compose_bar_test.dart | 153 ++++++++++++++++++ 5 files changed, 257 insertions(+), 3 deletions(-) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 9fe13485c40..219799c9f07 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -118,11 +118,33 @@ import UserNotifications return } transcodeVideoToMp4(sourcePath: sourcePath, result: result) + case "readClipboardImage": + guard let imageData = Self.clipboardImageData(from: UIPasteboard.general) else { + result(nil) + return + } + result(FlutterStandardTypedData(bytes: imageData)) default: result(FlutterMethodNotImplemented) } } + static func clipboardImageData(from pasteboard: UIPasteboard) -> Data? { + if let pngData = pasteboard.data(forPasteboardType: "public.png") { + return pngData + } + if let jpegData = pasteboard.data(forPasteboardType: "public.jpeg") { + return jpegData + } + if let gifData = pasteboard.data(forPasteboardType: "com.compuserve.gif") { + return gifData + } + guard let image = pasteboard.image else { + return nil + } + return image.pngData() + } + private func transcodeVideoToMp4( sourcePath: String, result: @escaping FlutterResult diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 86a7c3b1b61..557943aefad 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -1,12 +1,43 @@ import Flutter import UIKit import XCTest +@testable import Buzz class RunnerTests: XCTestCase { - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + func testClipboardImageDataPrefersOriginalPngBytes() throws { + let pasteboard = try XCTUnwrap( + UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true) + ) + defer { UIPasteboard.remove(withName: pasteboard.name) } + let pngData = Data([0x89, 0x50, 0x4E, 0x47]) + let jpegData = Data([0xFF, 0xD8, 0xFF]) + pasteboard.setItems([ + ["public.png": pngData, "public.jpeg": jpegData] + ]) + + XCTAssertEqual(AppDelegate.clipboardImageData(from: pasteboard), pngData) + } + + func testClipboardImageDataPreservesOriginalGifBytesForValidation() throws { + let pasteboard = try XCTUnwrap( + UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true) + ) + defer { UIPasteboard.remove(withName: pasteboard.name) } + let gifData = Data("GIF89a".utf8) + pasteboard.setData(gifData, forPasteboardType: "com.compuserve.gif") + + XCTAssertEqual(AppDelegate.clipboardImageData(from: pasteboard), gifData) + } + + func testClipboardImageDataReturnsNilWithoutAnImage() throws { + let pasteboard = try XCTUnwrap( + UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true) + ) + defer { UIPasteboard.remove(withName: pasteboard.name) } + pasteboard.string = "text only" + + XCTAssertNil(AppDelegate.clipboardImageData(from: pasteboard)) } } diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 20869f9c6a3..38f5edf6ca6 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -318,6 +319,33 @@ class ComposeBar extends HookConsumerWidget { } } + Widget buildContextMenu( + BuildContext context, + EditableTextState editableTextState, + ) { + final buttonItems = [...editableTextState.contextMenuButtonItems]; + if (defaultTargetPlatform == TargetPlatform.iOS) { + buttonItems.insert( + 0, + ContextMenuButtonItem( + label: 'Paste Image', + onPressed: () { + ContextMenuController.removeAny(); + pickAndUpload( + ref + .read(mediaUploadServiceProvider) + .readAndUploadClipboardImage, + ); + }, + ), + ); + } + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: editableTextState.contextMenuAnchors, + buttonItems: buttonItems, + ); + } + void uploadPastedImage(KeyboardInsertedContent content) { final bytes = content.data; if (bytes == null || bytes.isEmpty) { @@ -463,6 +491,7 @@ class ComposeBar extends HookConsumerWidget { controller: controller, focusNode: focusNode, textInputAction: TextInputAction.send, + contextMenuBuilder: buildContextMenu, contentInsertionConfiguration: ContentInsertionConfiguration( allowedMimeTypes: _pastedImageMimeTypes, onContentInserted: uploadPastedImage, diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 3a7e4d6c7dd..e6917e7fbda 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -16,6 +16,7 @@ const _mediaUploadPlatformChannelName = 'buzz/media_upload'; const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload'; const _transcodeVideoToMp4Method = 'transcodeVideoToMp4'; const _transcodeImageToJpegMethod = 'transcodeImageToJpeg'; +const _readClipboardImageMethod = 'readClipboardImage'; const _uploadAuthKind = 24242; const _uploadAuthLifetimeSeconds = 300; const _heicBrands = { @@ -49,6 +50,7 @@ typedef SanitizeImageBytes = Future Function(Uint8List bytes, String mimeType); typedef TranscodeImageToJpeg = Future Function(Uint8List bytes); typedef TranscodeVideoToMp4 = Future Function(String filePath); +typedef ReadClipboardImage = Future Function(); @immutable class _PreparedUploadImage { @@ -122,6 +124,7 @@ class MediaUploadService { final SanitizeImageBytes _sanitizeImageBytes; final TranscodeImageToJpeg _transcodeImageToJpeg; final TranscodeVideoToMp4 _transcodeVideoToMp4; + final ReadClipboardImage _readClipboardImage; final DateTime Function() _now; final http.Client _http; final bool _ownsHttpClient; @@ -134,6 +137,7 @@ class MediaUploadService { SanitizeImageBytes? sanitizeImageBytes, TranscodeImageToJpeg? transcodeImageToJpeg, TranscodeVideoToMp4? transcodeVideoToMp4, + ReadClipboardImage? readClipboardImage, DateTime Function()? now, http.Client? httpClient, }) : _baseUrl = baseUrl, @@ -144,6 +148,7 @@ class MediaUploadService { _transcodeImageToJpeg = transcodeImageToJpeg ?? _transcodePickedImageToJpeg, _transcodeVideoToMp4 = transcodeVideoToMp4 ?? _transcodePickedVideoToMp4, + _readClipboardImage = readClipboardImage ?? _readPlatformClipboardImage, _now = now ?? DateTime.now, _http = httpClient ?? http.Client(), _ownsHttpClient = httpClient == null; @@ -165,6 +170,14 @@ class MediaUploadService { return uploadBytes(preparedImage.bytes, mimeType: preparedImage.mimeType); } + Future readAndUploadClipboardImage() async { + final bytes = await _readClipboardImage(); + if (bytes == null || bytes.isEmpty) { + throw Exception('Unable to read pasted image'); + } + return uploadImage(XFile.fromData(bytes)); + } + Future pickAndUploadVideo() async { final pickedVideo = await _pickGalleryVideo(); if (pickedVideo == null) return null; @@ -582,6 +595,12 @@ Future _readFileHeader(String path, int count) async { } } +Future _readPlatformClipboardImage() async { + return _mediaUploadPlatformChannel.invokeMethod( + _readClipboardImageMethod, + ); +} + Future _transcodePickedVideoToMp4(String filePath) async { final result = await _mediaUploadPlatformChannel.invokeMethod( _transcodeVideoToMp4Method, diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index a3b0b23e3fe..a563d7dd9ed 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -315,6 +316,73 @@ void main() { ); }); + testWidgets('iOS Paste Image reads the clipboard into the shared path', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient( + (request) async => http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/ios-paste.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ), + ), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + readClipboardImage: () async => _pngBytes, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + final textField = tester.widget(find.byType(TextField)); + final editableTextState = tester.state( + find.byType(EditableText), + ); + final menu = + textField.contextMenuBuilder!( + tester.element(find.byType(TextField)), + editableTextState, + ) + as AdaptiveTextSelectionToolbar; + final pasteImage = menu.buttonItems!.singleWhere( + (item) => item.label == 'Paste Image', + ); + pasteImage.onPressed!(); + await tester.pumpAndSettle(); + + expect( + find.byKey( + const ValueKey( + 'compose-attachment:https://relay.example/media/ios-paste.png', + ), + ), + findsOneWidget, + ); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets('shows an error when pasted image bytes are unavailable', ( tester, ) async { @@ -349,6 +417,91 @@ void main() { expect(find.byTooltip('Remove attachment'), findsNothing); }); + testWidgets('iOS Paste Image reports an unavailable clipboard image', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + readClipboardImage: () async => null, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + final textField = tester.widget(find.byType(TextField)); + final editableTextState = tester.state( + find.byType(EditableText), + ); + final menu = + textField.contextMenuBuilder!( + tester.element(find.byType(TextField)), + editableTextState, + ) + as AdaptiveTextSelectionToolbar; + menu.buttonItems! + .singleWhere((item) => item.label == 'Paste Image') + .onPressed!(); + await tester.pumpAndSettle(); + + expect(find.text('Unable to read pasted image'), findsOneWidget); + expect(find.byTooltip('Remove attachment'), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('does not add Paste Image to non-iOS context menus', ( + tester, + ) async { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + final textField = tester.widget(find.byType(TextField)); + final editableTextState = tester.state( + find.byType(EditableText), + ); + final menu = + textField.contextMenuBuilder!( + tester.element(find.byType(TextField)), + editableTextState, + ) + as AdaptiveTextSelectionToolbar; + + expect( + menu.buttonItems!.where((item) => item.label == 'Paste Image'), + isEmpty, + ); + }); + testWidgets('keeps the remove button pinned to the attachment corner', ( tester, ) async { From 9f669bb1624784d82fe1336204e39f2bad049061 Mon Sep 17 00:00:00 2001 From: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Date: Mon, 13 Jul 2026 16:39:57 -0700 Subject: [PATCH 3/4] fix(mobile): preserve native iOS edit menu Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> --- mobile/lib/features/channels/compose_bar.dart | 33 +++++--- .../features/channels/compose_bar_test.dart | 84 ++++++++++++++++++- 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 38f5edf6ca6..5e8b71a570a 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -323,21 +323,32 @@ class ComposeBar extends HookConsumerWidget { BuildContext context, EditableTextState editableTextState, ) { + void pasteImage() { + ContextMenuController.removeAny(); + pickAndUpload( + ref.read(mediaUploadServiceProvider).readAndUploadClipboardImage, + ); + } + + if (defaultTargetPlatform == TargetPlatform.iOS && + SystemContextMenu.isSupportedByField(editableTextState)) { + return SystemContextMenu.editableText( + editableTextState: editableTextState, + items: [ + IOSSystemContextMenuItemCustom( + title: 'Paste Image', + onPressed: pasteImage, + ), + ...SystemContextMenu.getDefaultItems(editableTextState), + ], + ); + } + final buttonItems = [...editableTextState.contextMenuButtonItems]; if (defaultTargetPlatform == TargetPlatform.iOS) { buttonItems.insert( 0, - ContextMenuButtonItem( - label: 'Paste Image', - onPressed: () { - ContextMenuController.removeAny(); - pickAndUpload( - ref - .read(mediaUploadServiceProvider) - .readAndUploadClipboardImage, - ); - }, - ), + ContextMenuButtonItem(label: 'Paste Image', onPressed: pasteImage), ); } return AdaptiveTextSelectionToolbar.buttonItems( diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index a563d7dd9ed..89d4087ec22 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -111,6 +111,7 @@ void _setMockMediaUploadPlatformHandler( Widget _buildComposeBar({ required MediaUploadService uploadService, required ComposeBarOnSend onSend, + bool? supportsShowingSystemContextMenu, }) { return ProviderScope( overrides: [ @@ -129,6 +130,15 @@ Widget _buildComposeBar({ ], child: MaterialApp( theme: AppTheme.light(), + builder: supportsShowingSystemContextMenu == null + ? null + : (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + supportsShowingSystemContextMenu: + supportsShowingSystemContextMenu, + ), + child: child!, + ), home: Scaffold( body: SafeArea( child: ComposeBar(channelId: 'channel-1', onSend: onSend), @@ -316,7 +326,79 @@ void main() { ); }); - testWidgets('iOS Paste Image reads the clipboard into the shared path', ( + testWidgets('iOS native context menu preserves defaults and pastes image', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient( + (request) async => http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/ios-native-paste.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ), + ), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + readClipboardImage: () async => _pngBytes, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + supportsShowingSystemContextMenu: true, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + final textField = tester.widget(find.byType(TextField)); + final editableTextState = tester.state( + find.byType(EditableText), + ); + final defaultItems = SystemContextMenu.getDefaultItems( + editableTextState, + ); + final menu = + textField.contextMenuBuilder!( + tester.element(find.byType(TextField)), + editableTextState, + ) + as SystemContextMenu; + final pasteImage = menu.items.first as IOSSystemContextMenuItemCustom; + + expect(pasteImage.title, 'Paste Image'); + expect(menu.items.skip(1), orderedEquals(defaultItems)); + pasteImage.onPressed(); + await tester.pumpAndSettle(); + + expect( + find.byKey( + const ValueKey( + 'compose-attachment:https://relay.example/media/ios-native-paste.png', + ), + ), + findsOneWidget, + ); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('iOS adaptive Paste Image reads the clipboard into shared path', ( tester, ) async { final previousPlatform = debugDefaultTargetPlatformOverride; From 1d899c83097a5afac2ecf11033cf55a594e0755b Mon Sep 17 00:00:00 2001 From: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Date: Mon, 13 Jul 2026 16:57:08 -0700 Subject: [PATCH 4/4] fix(mobile): harden iOS image paste availability Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> --- mobile/ios/Runner/AppDelegate.swift | 8 +- mobile/ios/RunnerTests/RunnerTests.swift | 11 ++ mobile/lib/features/channels/compose_bar.dart | 42 +++++- mobile/lib/shared/relay/media_upload.dart | 8 ++ .../features/channels/compose_bar_test.dart | 62 +++++++++ .../test/shared/relay/media_upload_test.dart | 129 ++++++++++++++++++ 6 files changed, 253 insertions(+), 7 deletions(-) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 219799c9f07..c1bc7a74235 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -118,6 +118,8 @@ import UserNotifications return } transcodeVideoToMp4(sourcePath: sourcePath, result: result) + case "clipboardHasImage": + result(UIPasteboard.general.hasImages) case "readClipboardImage": guard let imageData = Self.clipboardImageData(from: UIPasteboard.general) else { result(nil) @@ -136,8 +138,10 @@ import UserNotifications if let jpegData = pasteboard.data(forPasteboardType: "public.jpeg") { return jpegData } - if let gifData = pasteboard.data(forPasteboardType: "com.compuserve.gif") { - return gifData + for imageType in ["public.heic", "public.heif", "org.webmproject.webp", "com.compuserve.gif"] { + if let imageData = pasteboard.data(forPasteboardType: imageType) { + return imageData + } } guard let image = pasteboard.image else { return nil diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 557943aefad..b898bf861af 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -19,6 +19,17 @@ class RunnerTests: XCTestCase { XCTAssertEqual(AppDelegate.clipboardImageData(from: pasteboard), pngData) } + func testClipboardImageDataPreservesOriginalWebPBytesForValidation() throws { + let pasteboard = try XCTUnwrap( + UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true) + ) + defer { UIPasteboard.remove(withName: pasteboard.name) } + let webPData = Data("RIFFxxxxWEBP".utf8) + pasteboard.setData(webPData, forPasteboardType: "org.webmproject.webp") + + XCTAssertEqual(AppDelegate.clipboardImageData(from: pasteboard), webPData) + } + func testClipboardImageDataPreservesOriginalGifBytesForValidation() throws { let pasteboard = try XCTUnwrap( UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true) diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 5e8b71a570a..322867ed4e9 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -73,6 +73,7 @@ class ComposeBar extends HookConsumerWidget { final attachments = useState>([]); final uploadError = useState(null); final uploadingCount = useState(0); + final clipboardHasImage = useState(false); final hasAttachments = attachments.value.isNotEmpty; final hasPendingUploads = uploadingCount.value > 0; final customEmoji = ref.watch(customEmojiListProvider); @@ -81,6 +82,35 @@ class ComposeBar extends HookConsumerWidget { hintText ?? (channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026'); + useEffect(() { + if (defaultTargetPlatform != TargetPlatform.iOS) return null; + + var disposed = false; + Future refreshClipboardAvailability() async { + final hasImage = await ref + .read(mediaUploadServiceProvider) + .clipboardHasImage(); + if (!disposed && context.mounted) { + clipboardHasImage.value = hasImage; + } + } + + void refreshWhenFocused() { + if (focusNode.hasFocus) refreshClipboardAvailability(); + } + + final lifecycleListener = AppLifecycleListener( + onResume: refreshClipboardAvailability, + ); + focusNode.addListener(refreshWhenFocused); + refreshClipboardAvailability(); + return () { + disposed = true; + focusNode.removeListener(refreshWhenFocused); + lifecycleListener.dispose(); + }; + }, [focusNode]); + // Mention state -------------------------------------------------------- final mentionQuery = useState(null); final mentionStartIdx = useState(-1); @@ -335,17 +365,19 @@ class ComposeBar extends HookConsumerWidget { return SystemContextMenu.editableText( editableTextState: editableTextState, items: [ - IOSSystemContextMenuItemCustom( - title: 'Paste Image', - onPressed: pasteImage, - ), + if (clipboardHasImage.value) + IOSSystemContextMenuItemCustom( + title: 'Paste Image', + onPressed: pasteImage, + ), ...SystemContextMenu.getDefaultItems(editableTextState), ], ); } final buttonItems = [...editableTextState.contextMenuButtonItems]; - if (defaultTargetPlatform == TargetPlatform.iOS) { + if (defaultTargetPlatform == TargetPlatform.iOS && + clipboardHasImage.value) { buttonItems.insert( 0, ContextMenuButtonItem(label: 'Paste Image', onPressed: pasteImage), diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index e6917e7fbda..63fdc24583f 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -17,6 +17,7 @@ const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload'; const _transcodeVideoToMp4Method = 'transcodeVideoToMp4'; const _transcodeImageToJpegMethod = 'transcodeImageToJpeg'; const _readClipboardImageMethod = 'readClipboardImage'; +const _clipboardHasImageMethod = 'clipboardHasImage'; const _uploadAuthKind = 24242; const _uploadAuthLifetimeSeconds = 300; const _heicBrands = { @@ -170,6 +171,13 @@ class MediaUploadService { return uploadBytes(preparedImage.bytes, mimeType: preparedImage.mimeType); } + Future clipboardHasImage() async { + return await _mediaUploadPlatformChannel.invokeMethod( + _clipboardHasImageMethod, + ) ?? + false; + } + Future readAndUploadClipboardImage() async { final bytes = await _readClipboardImage(); if (bytes == null || bytes.isEmpty) { diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 89d4087ec22..b79d3ec1287 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -159,6 +159,8 @@ void main() { return arguments['bytes'] as Uint8List; case 'transcodeImageToJpeg': return _pngBytes; + case 'clipboardHasImage': + return true; default: return null; } @@ -398,6 +400,66 @@ void main() { } }); + testWidgets('iOS hides Paste Image when clipboard has no image', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + _setMockMediaUploadPlatformHandler((call) async { + if (call.method == 'clipboardHasImage') return false; + return null; + }); + try { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + supportsShowingSystemContextMenu: true, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await tester.pump(); + + final textField = tester.widget(find.byType(TextField)); + final editableTextState = tester.state( + find.byType(EditableText), + ); + final menu = + textField.contextMenuBuilder!( + tester.element(find.byType(TextField)), + editableTextState, + ) + as SystemContextMenu; + + expect(menu.items.whereType(), isEmpty); + } finally { + _setMockMediaUploadPlatformHandler((call) async { + switch (call.method) { + case 'sanitizeImageForUpload': + final arguments = call.arguments as Map; + return arguments['bytes'] as Uint8List; + case 'transcodeImageToJpeg': + return _pngBytes; + case 'clipboardHasImage': + return true; + default: + return null; + } + }); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets('iOS adaptive Paste Image reads the clipboard into shared path', ( tester, ) async { diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index 01c7cb03ade..990bc3f773f 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -307,6 +307,135 @@ void main() { ); }); + test( + 'checks clipboard image availability through the platform channel', + () async { + final invokedMethods = []; + _setMockMediaUploadPlatformHandler((call) async { + invokedMethods.add(call.method); + if (call.method == 'clipboardHasImage') return true; + return null; + }); + addTearDown(() { + _setMockMediaUploadPlatformHandler((call) async { + switch (call.method) { + case 'sanitizeImageForUpload': + final arguments = call.arguments as Map; + return arguments['bytes'] as Uint8List; + case 'transcodeImageToJpeg': + return _jpegBytes; + default: + return null; + } + }); + }); + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + + expect(await service.clipboardHasImage(), isTrue); + expect(invokedMethods, ['clipboardHasImage']); + }, + ); + + test('reads clipboard image through the platform channel', () async { + final invokedMethods = []; + _setMockMediaUploadPlatformHandler((call) async { + invokedMethods.add(call.method); + if (call.method == 'readClipboardImage') return _pngBytes; + if (call.method == 'sanitizeImageForUpload') { + final arguments = call.arguments as Map; + return arguments['bytes'] as Uint8List; + } + return null; + }); + addTearDown(() { + _setMockMediaUploadPlatformHandler((call) async { + switch (call.method) { + case 'sanitizeImageForUpload': + final arguments = call.arguments as Map; + return arguments['bytes'] as Uint8List; + case 'transcodeImageToJpeg': + return _jpegBytes; + default: + return null; + } + }); + }); + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient( + (request) async => http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/clipboard.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ), + ), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + + final descriptor = await service.readAndUploadClipboardImage(); + + expect(invokedMethods.first, 'readClipboardImage'); + expect(descriptor.type, 'image/png'); + }); + + test( + 'rejects GIF clipboard bytes through the shared validation path', + () async { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + readClipboardImage: () async => _gifBytes, + ); + + expect( + service.readAndUploadClipboardImage, + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('GIF uploads are not supported on mobile yet'), + ), + ), + ); + }, + ); + + test('rejects empty clipboard image bytes', () async { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + readClipboardImage: () async => Uint8List(0), + ); + + expect( + service.readAndUploadClipboardImage, + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('Unable to read pasted image'), + ), + ), + ); + }); + test('returns null when the gallery picker is cancelled', () async { final service = MediaUploadService( baseUrl: 'https://relay.example',