mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu
parent
109c2c5264
commit
fdbfac6b26
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user