Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions mobile/ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 45 additions & 3 deletions mobile/ios/RunnerTests/RunnerTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}

}
98 changes: 98 additions & 0 deletions mobile/lib/features/channels/compose_bar.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -27,6 +29,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.
Expand Down Expand Up @@ -66,6 +75,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);
Expand All @@ -74,6 +84,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);
Expand Down Expand Up @@ -347,6 +386,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;
Expand Down Expand Up @@ -478,6 +571,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,
Expand Down
33 changes: 32 additions & 1 deletion mobile/lib/shared/relay/media_upload.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -134,6 +138,7 @@ class MediaUploadService {
SanitizeImageBytes? sanitizeImageBytes,
TranscodeImageToJpeg? transcodeImageToJpeg,
TranscodeVideoToMp4? transcodeVideoToMp4,
ReadClipboardImage? readClipboardImage,
DateTime Function()? now,
http.Client? httpClient,
}) : _baseUrl = baseUrl,
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading