allow pasting attachments from keyboard

Closes #55
This commit is contained in:
Henry Hiles 2026-10-15 13:40:24 -04:00
commit 36f61a62d8
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
12 changed files with 154 additions and 15 deletions

View file

@ -51,6 +51,16 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- Needed by pasteboard, for pasting files from the clipboard -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path
name="external_files"
path="." />
</paths>

View file

@ -18,6 +18,10 @@ import "package:nexus/controllers/power_level.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart"; import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
import "package:nexus/widgets/composer/paste_files_action.dart";
import "package:pasteboard/pasteboard.dart";
import "package:path/path.dart";
import "package:path_provider/path_provider.dart";
import "package:nexus/widgets/composer/relation_preview.dart"; import "package:nexus/widgets/composer/relation_preview.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
@ -100,6 +104,46 @@ class const Composer(
} }
} }
/// Attaches [bytes] as a file called [filename], e.g. for pasted images.
Future<void> attachBytes(Uint8List bytes, String filename) async {
// A new directory each time, so files with the same name don't clash
final directory = await (await getTemporaryDirectory()).createTemp(
"attachment",
);
final file = await File(join(directory.path, filename))
.writeAsBytes(bytes);
await ref
.read(AttachmentController.provider(roomId).notifier)
.add(.new(file.path));
}
/// Attaches the first file on the clipboard, or the image on it if there
/// are no files. Returns whether there was either, so that text can be
/// pasted otherwise.
Future<bool> pasteFiles() async {
// Only one attachment is supported at a time
if (attachment != null) return false;
// Failing to read the clipboard counts as it being empty, so that text
// can still be pasted
final files = await Pasteboard.files().onError((_, _) => const []);
if (files.firstOrNull case final path?) {
ref
.read(AttachmentController.provider(roomId).notifier)
.add(XFile(path))
.onError(showError);
return true;
}
if (await Pasteboard.image.onError((_, _) => null) case final image?) {
attachBytes(image, "image.png").onError(showError);
return true;
}
return false;
}
void send() { void send() {
if (controller.value.text.isEmpty && attachment == null || if (controller.value.text.isEmpty && attachment == null ||
attachment != null && attachment.$2 == null) { attachment != null && attachment.$2 == null) {
@ -257,21 +301,65 @@ class const Composer(
return KeyEventResult.ignored; return KeyEventResult.ignored;
}, },
child: TextField( // Overrides the keyboard shortcut for pasting
maxLines: 12, child: Actions(
minLines: 1, actions: {
textCapitalization: .sentences, PasteTextIntent: PasteFilesAction(
autofocus: pasteFiles,
(Platform.isLinux || ),
Platform.isMacOS || },
Platform.isWindows), child: TextField(
decoration: .new( maxLines: 12,
hintText: "Your message here...", minLines: 1,
border: .none, textCapitalization: .sentences,
autofocus:
(Platform.isLinux ||
Platform.isMacOS ||
Platform.isWindows),
decoration: .new(
hintText: "Your message here...",
border: .none,
),
controller: controller,
key: key,
focusNode: node,
// The toolbar's paste button doesn't use the shortcut's action, so replace it too
contextMenuBuilder: (_, editableText) =>
AdaptiveTextSelectionToolbar.buttonItems(
anchors:
editableText.contextMenuAnchors,
buttonItems: editableText
.contextMenuButtonItems
.map(
(item) => item.type == .paste
? item.copyWith(
onPressed: () async {
editableText
.hideToolbar();
if (!await pasteFiles()) {
item.onPressed
?.call();
}
},
)
: item,
)
.toList(),
),
// Images pasted from Android keyboards' clipboards are inserted as content
contentInsertionConfiguration: .new(
onContentInserted: (content) {
if (attachment != null) return;
if (content.data case final data?) {
attachBytes(
data,
"image.${content.mimeType.split("/").last}",
).onError(showError);
}
},
),
), ),
controller: controller,
key: key,
focusNode: node,
), ),
), ),
), ),

View file

@ -0,0 +1,12 @@
import "package:material_ui/material_ui.dart";
/// Overrides a text field's paste, to paste files and images with
/// [pasteFiles], falling back to pasting text if it returns false.
class PasteFilesAction(final Future<bool> Function() pasteFiles)
extends Action<PasteTextIntent> {
@override
Future<void> invoke(PasteTextIntent intent) async {
// callingAction is the text field's own paste action, which this overrides
if (!await pasteFiles()) callingAction?.invoke(intent);
}
}

View file

@ -11,6 +11,7 @@
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h> #include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h> #include <media_kit_video/media_kit_video_plugin.h>
#include <pasteboard/pasteboard_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h> #include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
#include <webcrypto/webcrypto_plugin.h> #include <webcrypto/webcrypto_plugin.h>
@ -32,6 +33,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) media_kit_video_registrar = g_autoptr(FlPluginRegistrar) media_kit_video_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin");
media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); media_kit_video_plugin_register_with_registrar(media_kit_video_registrar);
g_autoptr(FlPluginRegistrar) pasteboard_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
pasteboard_plugin_register_with_registrar(pasteboard_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);

View file

@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux file_selector_linux
media_kit_libs_linux media_kit_libs_linux
media_kit_video media_kit_video
pasteboard
screen_retriever_linux screen_retriever_linux
url_launcher_linux url_launcher_linux
webcrypto webcrypto

View file

@ -8,7 +8,10 @@ let
"36.0.0" "36.0.0"
]; ];
cmakeVersions = [ "3.22.1" ]; cmakeVersions = [ "3.22.1" ];
platformVersions = [ "36" ]; platformVersions = [
"34"
"36"
];
abiVersions = [ abiVersions = [
"armeabi-v7a" "armeabi-v7a"
"arm64-v8a" "arm64-v8a"

View file

@ -12,6 +12,7 @@ import flutter_local_notifications
import media_kit_libs_macos_video import media_kit_libs_macos_video
import media_kit_video import media_kit_video
import package_info_plus import package_info_plus
import pasteboard
import screen_retriever_macos import screen_retriever_macos
import shared_preferences_foundation import shared_preferences_foundation
import url_launcher_macos import url_launcher_macos
@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))

View file

@ -1047,6 +1047,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.0" version: "4.1.0"
pasteboard:
dependency: "direct main"
description:
name: pasteboard
sha256: fedbe8da188d2f713aa8b01260737342e6e1087534a3ab26e1a719f8d3e8f32f
url: "https://pub.dev"
source: hosted
version: "0.5.0"
path: path:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -97,6 +97,7 @@ dependencies:
git: git:
url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart
ref: quad/nexus ref: quad/nexus
pasteboard: 0.5.0
dev_dependencies: dev_dependencies:
build_runner: 2.16.2 build_runner: 2.16.2

View file

@ -11,6 +11,7 @@
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h> #include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h> #include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <pasteboard/pasteboard_plugin.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h> #include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
#include <webcrypto/webcrypto_plugin.h> #include <webcrypto/webcrypto_plugin.h>
@ -27,6 +28,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar( MediaKitVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
PasteboardPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PasteboardPlugin"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(

View file

@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows file_selector_windows
media_kit_libs_windows_video media_kit_libs_windows_video
media_kit_video media_kit_video
pasteboard
screen_retriever_windows screen_retriever_windows
url_launcher_windows url_launcher_windows
webcrypto webcrypto