feat(mobile): paste image into message (#1836)

Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tom Brow
2026-07-14 17:33:16 -04:00
committed by GitHub
co-authored by npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu
parent 109c2c5264
commit fdbfac6b26
6 changed files with 752 additions and 4 deletions
+26
View File
@@ -118,11 +118,37 @@ 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)
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
}
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
}
return image.pngData()
}
private func transcodeVideoToMp4(
sourcePath: String,
result: @escaping FlutterResult
+45 -3
View File
@@ -1,12 +1,54 @@
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 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)
)
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))
}
}
@@ -1,8 +1,10 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
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;
@@ -28,6 +30,13 @@ part 'compose_bar/formatting_toolbar.dart';
part 'compose_bar/attachments.dart';
part 'compose_bar/send_button.dart';
const _pastedImageMimeTypes = <String>[
'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.
@@ -67,6 +76,7 @@ class ComposeBar extends HookConsumerWidget {
final attachments = useState<List<BlobDescriptor>>([]);
final uploadError = useState<String?>(null);
final uploadingCount = useState(0);
final clipboardHasImage = useState(false);
final hasAttachments = attachments.value.isNotEmpty;
final hasPendingUploads = uploadingCount.value > 0;
final customEmoji = ref.watch(customEmojiListProvider);
@@ -75,6 +85,35 @@ class ComposeBar extends HookConsumerWidget {
hintText ??
(channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026');
useEffect(() {
if (defaultTargetPlatform != TargetPlatform.iOS) return null;
var disposed = false;
Future<void> 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<String?>(null);
final mentionStartIdx = useState(-1);
@@ -348,6 +387,60 @@ class ComposeBar extends HookConsumerWidget {
}
}
Widget buildContextMenu(
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: [
if (clipboardHasImage.value)
IOSSystemContextMenuItemCustom(
title: 'Paste Image',
onPressed: pasteImage,
),
...SystemContextMenu.getDefaultItems(editableTextState),
],
);
}
final buttonItems = [...editableTextState.contextMenuButtonItems];
if (defaultTargetPlatform == TargetPlatform.iOS &&
clipboardHasImage.value) {
buttonItems.insert(
0,
ContextMenuButtonItem(label: 'Paste Image', onPressed: pasteImage),
);
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: buttonItems,
);
}
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;
@@ -479,6 +572,11 @@ class ComposeBar extends HookConsumerWidget {
controller: controller,
focusNode: focusNode,
textInputAction: TextInputAction.send,
contextMenuBuilder: buildContextMenu,
contentInsertionConfiguration: ContentInsertionConfiguration(
allowedMimeTypes: _pastedImageMimeTypes,
onContentInserted: uploadPastedImage,
),
onSubmitted: (_) => send(),
minLines: 1,
maxLines: 5,
+32 -1
View File
@@ -16,6 +16,8 @@ const _mediaUploadPlatformChannelName = 'buzz/media_upload';
const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload';
const _transcodeVideoToMp4Method = 'transcodeVideoToMp4';
const _transcodeImageToJpegMethod = 'transcodeImageToJpeg';
const _readClipboardImageMethod = 'readClipboardImage';
const _clipboardHasImageMethod = 'clipboardHasImage';
const _uploadAuthKind = 24242;
const _uploadAuthLifetimeSeconds = 300;
const _heicBrands = {
@@ -49,6 +51,7 @@ typedef SanitizeImageBytes =
Future<Uint8List> Function(Uint8List bytes, String mimeType);
typedef TranscodeImageToJpeg = Future<Uint8List> Function(Uint8List bytes);
typedef TranscodeVideoToMp4 = Future<String> Function(String filePath);
typedef ReadClipboardImage = Future<Uint8List?> Function();
@immutable
class _PreparedUploadImage {
@@ -122,6 +125,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 +138,7 @@ class MediaUploadService {
SanitizeImageBytes? sanitizeImageBytes,
TranscodeImageToJpeg? transcodeImageToJpeg,
TranscodeVideoToMp4? transcodeVideoToMp4,
ReadClipboardImage? readClipboardImage,
DateTime Function()? now,
http.Client? httpClient,
}) : _baseUrl = baseUrl,
@@ -144,6 +149,7 @@ class MediaUploadService {
_transcodeImageToJpeg =
transcodeImageToJpeg ?? _transcodePickedImageToJpeg,
_transcodeVideoToMp4 = transcodeVideoToMp4 ?? _transcodePickedVideoToMp4,
_readClipboardImage = readClipboardImage ?? _readPlatformClipboardImage,
_now = now ?? DateTime.now,
_http = httpClient ?? http.Client(),
_ownsHttpClient = httpClient == null;
@@ -157,10 +163,29 @@ class MediaUploadService {
Future<BlobDescriptor?> pickAndUploadImage() async {
final pickedImage = await _pickGalleryImage();
if (pickedImage == null) return null;
final preparedImage = await _prepareUploadImage(pickedImage);
return uploadImage(pickedImage);
}
Future<BlobDescriptor> uploadImage(XFile image) async {
final preparedImage = await _prepareUploadImage(image);
return uploadBytes(preparedImage.bytes, mimeType: preparedImage.mimeType);
}
Future<bool> clipboardHasImage() async {
return await _mediaUploadPlatformChannel.invokeMethod<bool>(
_clipboardHasImageMethod,
) ??
false;
}
Future<BlobDescriptor> readAndUploadClipboardImage() async {
final bytes = await _readClipboardImage();
if (bytes == null || bytes.isEmpty) {
throw Exception('Unable to read pasted image');
}
return uploadImage(XFile.fromData(bytes));
}
Future<BlobDescriptor?> pickAndUploadVideo() async {
final pickedVideo = await _pickGalleryVideo();
if (pickedVideo == null) return null;
@@ -578,6 +603,12 @@ Future<Uint8List> _readFileHeader(String path, int count) async {
}
}
Future<Uint8List?> _readPlatformClipboardImage() async {
return _mediaUploadPlatformChannel.invokeMethod<Uint8List>(
_readClipboardImageMethod,
);
}
Future<String> _transcodePickedVideoToMp4(String filePath) async {
final result = await _mediaUploadPlatformChannel.invokeMethod<String>(
_transcodeVideoToMp4Method,
@@ -2,6 +2,7 @@ import 'dart:async';
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';
@@ -117,6 +118,7 @@ Widget _buildComposeBar({
List<AgentDirectoryEntry> relayAgents = const <AgentDirectoryEntry>[],
List<Channel> channels = const <Channel>[],
String? currentPubkey,
bool? supportsShowingSystemContextMenu,
}) {
return ProviderScope(
overrides: [
@@ -135,6 +137,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),
@@ -209,6 +220,8 @@ void main() {
return arguments['bytes'] as Uint8List;
case 'transcodeImageToJpeg':
return _pngBytes;
case 'clipboardHasImage':
return true;
default:
return null;
}
@@ -285,6 +298,415 @@ 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<List<String>> sentMediaTags = const [];
await tester.pumpWidget(
_buildComposeBar(
uploadService: uploadService,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {
sentContent = content;
sentMediaTags = mediaTags;
},
),
);
final textField = tester.widget<TextField>(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('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 <List<String>>[],
}) async {},
),
);
final textField = tester.widget<TextField>(find.byType(TextField));
final editableTextState = tester.state<EditableTextState>(
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 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 <List<String>>[],
}) async {},
),
);
await tester.pump();
final textField = tester.widget<TextField>(find.byType(TextField));
final editableTextState = tester.state<EditableTextState>(
find.byType(EditableText),
);
final menu =
textField.contextMenuBuilder!(
tester.element(find.byType(TextField)),
editableTextState,
)
as SystemContextMenu;
expect(menu.items.whereType<IOSSystemContextMenuItemCustom>(), isEmpty);
} finally {
_setMockMediaUploadPlatformHandler((call) async {
switch (call.method) {
case 'sanitizeImageForUpload':
final arguments = call.arguments as Map<Object?, Object?>;
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 {
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 <List<String>>[],
}) async {},
),
);
final textField = tester.widget<TextField>(find.byType(TextField));
final editableTextState = tester.state<EditableTextState>(
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 {
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 <List<String>>[],
}) async {},
),
);
final textField = tester.widget<TextField>(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('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 <List<String>>[],
}) async {},
),
);
final textField = tester.widget<TextField>(find.byType(TextField));
final editableTextState = tester.state<EditableTextState>(
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 <List<String>>[],
}) async {},
),
);
final textField = tester.widget<TextField>(find.byType(TextField));
final editableTextState = tester.state<EditableTextState>(
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 {
@@ -307,6 +307,135 @@ void main() {
);
});
test(
'checks clipboard image availability through the platform channel',
() async {
final invokedMethods = <String>[];
_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<Object?, Object?>;
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 = <String>[];
_setMockMediaUploadPlatformHandler((call) async {
invokedMethods.add(call.method);
if (call.method == 'readClipboardImage') return _pngBytes;
if (call.method == 'sanitizeImageForUpload') {
final arguments = call.arguments as Map<Object?, Object?>;
return arguments['bytes'] as Uint8List;
}
return null;
});
addTearDown(() {
_setMockMediaUploadPlatformHandler((call) async {
switch (call.method) {
case 'sanitizeImageForUpload':
final arguments = call.arguments as Map<Object?, Object?>;
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<Exception>().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<Exception>().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',