diff --git a/README.md b/README.md index 4233429..d706c9c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] Replies - [x] Choose ping on/off - [x] Per message profiles - - [ ] Attachments + - [x] Attachments - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) - [x] Mentions - [x] Users diff --git a/lib/controllers/attachment.dart b/lib/controllers/attachment.dart new file mode 100644 index 0000000..13d096b --- /dev/null +++ b/lib/controllers/attachment.dart @@ -0,0 +1,41 @@ +import "package:file_selector/file_selector.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/message.dart"; +import "package:path/path.dart"; + +class AttachmentController extends Notifier<(String, MessageContent?)?> { + final String roomId; + AttachmentController(this.roomId); + + @override + Null build() => null; + + Future add(XFile file) async { + final filename = basename(file.path); + state = (filename, null); + + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), + ); + + final content = await ref + .watch(ClientController.provider.notifier) + .uploadMedia( + .new( + path: file.path, + encrypted: + room?.state[EventType.encryption.name]?.isNotEmpty == true, + ), + ); + + state = (filename, content); + } + + static final provider = NotifierProvider.family + .autoDispose( + AttachmentController.new, + ); +} diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index ef0f372..10e153d 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -14,6 +14,7 @@ import "package:nexus/controllers/sync_status.dart"; import "package:nexus/controllers/top_level_spaces.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/main.dart"; +import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/models/paginate.dart"; @@ -32,6 +33,7 @@ import "package:nexus/models/requests/send_event.dart"; import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/requests/set_membership.dart"; import "package:nexus/models/requests/set_state.dart"; +import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/sync_data.dart"; import "package:nexus/src/third_party/gomuks.g.dart"; @@ -266,6 +268,9 @@ class ClientController extends AsyncNotifier { Future setMembership(SetMembershipRequest request) => _sendCommand("set_membership", request.toJson()); + Future uploadMedia(UploadMediaRequest request) async => + .fromJson(await _sendCommand("upload_media", request.toJson())); + Future logout() => _sendCommand("logout"); Future markRead(Room room) async { diff --git a/lib/controllers/image_picker.dart b/lib/controllers/image_picker.dart new file mode 100644 index 0000000..797c997 --- /dev/null +++ b/lib/controllers/image_picker.dart @@ -0,0 +1,11 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:image_picker/image_picker.dart"; + +class ImagePickerController extends Notifier { + @override + ImagePicker build() => .new(); + + static final provider = NotifierProvider( + ImagePickerController.new, + ); +} diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index b79010c..ac979e0 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -4,6 +4,7 @@ import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:fluttertagger/fluttertagger.dart"; +import "package:nexus/controllers/attachment.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/models/content/content.dart"; @@ -120,6 +121,10 @@ class RoomChatController extends AsyncNotifier?> { required RelationType relationType, Event? relation, }) async { + final attachment = ref.watch(AttachmentController.provider(roomId)); + + if (attachment != null && attachment.$2 == null) return; + var taggedMessage = text; for (final tag in tags) { @@ -136,6 +141,7 @@ class RoomChatController extends AsyncNotifier?> { final event = await client.sendMessage( SendMessageRequest( roomId: roomId, + baseContent: attachment?.$2, mentions: Mentions( userIds: [ if (shouldMention == true && @@ -152,6 +158,8 @@ class RoomChatController extends AsyncNotifier?> { ), ); + ref.invalidate(AttachmentController.provider(roomId)); + ref .watch(RoomsController.provider.notifier) .update( diff --git a/lib/models/requests/send_message.dart b/lib/models/requests/send_message.dart index 628007f..951198e 100644 --- a/lib/models/requests/send_message.dart +++ b/lib/models/requests/send_message.dart @@ -1,5 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; import "package:nexus/models/relation_type.dart"; part "send_message.freezed.dart"; part "send_message.g.dart"; @@ -9,6 +10,7 @@ abstract class SendMessageRequest with _$SendMessageRequest { const factory SendMessageRequest({ required String roomId, required String text, + Content? baseContent, @Default(Mentions()) @JsonKey(name: "mentions") Mentions mentions, @JsonKey(name: "relates_to") Relation? relation, }) = _SendMessageRequest; diff --git a/lib/models/requests/upload_media.dart b/lib/models/requests/upload_media.dart new file mode 100644 index 0000000..6704974 --- /dev/null +++ b/lib/models/requests/upload_media.dart @@ -0,0 +1,24 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "upload_media.freezed.dart"; +part "upload_media.g.dart"; + +@freezed +abstract class UploadMediaRequest with _$UploadMediaRequest { + const factory UploadMediaRequest({ + required String path, + required bool encrypted, + String? filename, + @Default(false) @JsonKey(name: "voice_message") bool isVoiceMessage, + @Default(false) bool forceFile, + + // Below params only work if encodeTo is set + String? encodeTo, + int? resizeWidth, + int? resizeHeight, + int? resizePercent, + @Default(80) int quality, + }) = _UploadMediaRequest; + + factory UploadMediaRequest.fromJson(Map json) => + _$UploadMediaRequestFromJson(json); +} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index ef91dbe..15fe7c4 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -1,9 +1,13 @@ +import "dart:io"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:file_selector/file_selector.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/attachment.dart"; +import "package:nexus/controllers/image_picker.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; @@ -11,6 +15,7 @@ import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/mention_overlay.dart"; import "package:nexus/widgets/composer/relation_preview.dart"; import "package:nexus/widgets/emoji_picker_button.dart"; +import "package:nexus/main.dart"; class Composer extends HookConsumerWidget { final String roomId; @@ -68,131 +73,190 @@ class Composer extends HookConsumerWidget { fontWeight: .bold, ); + final attachment = ref.watch(AttachmentController.provider(roomId)); + return Padding( padding: .all(12), - child: ClipRRect( - borderRadius: .all(.circular(12)), - child: Column( - children: [ - RelationPreview( - relatedEvent, - shouldMention: shouldMention.value, - toggleShouldMention: () => - shouldMention.value = !shouldMention.value, - relationType: relationType, - onDismiss: onDismiss, + child: Column( + children: [ + if (attachment != null) + Card( + margin: .only(bottom: 8), + child: ListTile( + leading: attachment.$2 == null + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(), + ) + : Icon(Icons.file_copy), + title: Text(attachment.$1), + trailing: IconButton( + onPressed: () => + ref.invalidate(AttachmentController.provider(roomId)), + icon: Icon(Icons.close), + ), + ), ), - Container( - color: theme.colorScheme.surfaceContainerHighest, - padding: .symmetric(horizontal: 8), - child: Row( - spacing: 8, - mainAxisAlignment: .center, - children: - ref.watch( - PowerLevelController.provider( - .new(eventType: .message, roomId: roomId), - ), - ) - ? [ - EmojiPickerButton( - context: context, - onSelection: (_) => node?.requestFocus(), - controller: controller.value, - ), - PopupMenuButton( - tooltip: "Add media", - itemBuilder: (context) => [ - PopupMenuItem( - child: ListTile( - title: Text("Camera"), - leading: Icon(Icons.add_a_photo), + ClipRRect( + borderRadius: .all(.circular(12)), + child: Column( + children: [ + RelationPreview( + relatedEvent, + shouldMention: shouldMention.value, + toggleShouldMention: () => + shouldMention.value = !shouldMention.value, + relationType: relationType, + onDismiss: onDismiss, + ), + Container( + color: theme.colorScheme.surfaceContainerHighest, + padding: .symmetric(horizontal: 8), + child: Row( + spacing: 8, + mainAxisAlignment: .center, + children: + ref.watch( + PowerLevelController.provider( + .new(eventType: .message, roomId: roomId), + ), + ) + ? [ + EmojiPickerButton( + context: context, + onSelection: (_) => node?.requestFocus(), + controller: controller.value, + ), + PopupMenuButton( + tooltip: "Add media", + enabled: attachment == null, + itemBuilder: (context) => [ + if (Platform.isAndroid || Platform.isIOS) + PopupMenuItem( + child: ListTile( + title: Text("Camera"), + leading: Icon(Icons.add_a_photo), + ), + ), + PopupMenuItem( + child: ListTile( + title: Text("Gallery"), + leading: Icon(Icons.add_photo_alternate), + ), + onTap: () async => ref + .watch( + AttachmentController.provider( + roomId, + ).notifier, + ) + .add( + (await ref + .watch( + ImagePickerController.provider, + ) + .pickImage(source: .gallery))!, + ) + .onError(showError), + ), + PopupMenuItem( + onTap: () async => ref + .watch( + AttachmentController.provider( + roomId, + ).notifier, + ) + .add((await openFile())!) + .onError(showError), + child: ListTile( + title: Text("Files"), + leading: Icon(Icons.attachment), + ), + ), + ], + icon: Icon(Icons.add), + ), + Expanded( + child: FlutterTagger( + triggerStrategy: .eager, + overlay: MentionOverlay( + roomId, + query: query.value, + triggerCharacter: triggerCharacter.value, + addTag: ({required id, required name}) { + controller.value.addTag(id: id, name: name); + node?.requestFocus(); + }, + ), + controller: controller.value, + onSearch: (newQuery, newTriggerCharacter) { + triggerCharacter.value = newTriggerCharacter; + query.value = newQuery; + }, + triggerCharacterAndStyles: { + "@": style, + "#": style, + }, + builder: (context, key) => Focus( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && + event.logicalKey == + LogicalKeyboardKey.enter) { + final shiftPressed = HardwareKeyboard + .instance + .isShiftPressed; + + if (!shiftPressed) { + send(); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + }, + child: TextField( + maxLines: 12, + minLines: 1, + autofocus: true, + decoration: .new( + hintText: "Your message here...", + border: .none, + ), + controller: controller.value, + key: key, + focusNode: node, + ), + ), ), ), - PopupMenuItem( - child: ListTile( - title: Text("Gallery"), - leading: Icon(Icons.add_photo_alternate), - ), + IconButton( + onPressed: + attachment != null && attachment.$2 == null + ? null + : send, + icon: Icon(Icons.send), + tooltip: "Send message", ), - PopupMenuItem( - child: ListTile( - title: Text("Files"), - leading: Icon(Icons.attachment), + ] + : [ + Expanded( + child: Padding( + padding: .symmetric( + horizontal: 8, + vertical: 12, + ), + child: Text( + "You don't have permission to send messages in this room...", + ), ), ), ], - icon: Icon(Icons.add), - ), - Expanded( - child: FlutterTagger( - triggerStrategy: .eager, - overlay: MentionOverlay( - roomId, - query: query.value, - triggerCharacter: triggerCharacter.value, - addTag: ({required id, required name}) { - controller.value.addTag(id: id, name: name); - node?.requestFocus(); - }, - ), - controller: controller.value, - onSearch: (newQuery, newTriggerCharacter) { - triggerCharacter.value = newTriggerCharacter; - query.value = newQuery; - }, - triggerCharacterAndStyles: {"@": style, "#": style}, - builder: (context, key) => Focus( - onKeyEvent: (_, event) { - if (event is KeyDownEvent && - event.logicalKey == - LogicalKeyboardKey.enter) { - final shiftPressed = - HardwareKeyboard.instance.isShiftPressed; - - if (!shiftPressed) { - send(); - return KeyEventResult.handled; - } - } - - return KeyEventResult.ignored; - }, - child: TextField( - maxLines: 12, - minLines: 1, - autofocus: true, - decoration: .new( - hintText: "Your message here...", - border: .none, - ), - controller: controller.value, - key: key, - focusNode: node, - ), - ), - ), - ), - IconButton( - onPressed: send, - icon: Icon(Icons.send), - tooltip: "Send message", - ), - ] - : [ - Expanded( - child: Padding( - padding: .symmetric(horizontal: 8, vertical: 12), - child: Text( - "You don't have permission to send messages in this room...", - ), - ), - ), - ], - ), + ), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 7b5a69f..f18d831 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,7 +7,6 @@ import Foundation import app_links import dynamic_color -import file_picker import file_selector_macos import media_kit_libs_macos_video import media_kit_video @@ -21,7 +20,6 @@ import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) - FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 3ba6c12..4e593b0 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,7 @@ com.apple.security.network.server + com.apple.security.files.user-selected.read-only + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index ee95ab7..741903e 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,5 +6,7 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-only + diff --git a/pubspec.lock b/pubspec.lock index ca82296..3a0aeb5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -402,14 +402,30 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - file_picker: + file_selector: dependency: "direct main" description: - name: file_picker - sha256: fdc6a37f715d19f35b131decf1ce39242eeed5ddae18c0818c3eccb731ab76be + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a url: "https://pub.dev" source: hosted - version: "12.0.0-beta.7" + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "6a26687fa65cbc28a5345c7ae6f227e89f0b47740978a4c475b1a625da7a331b" + url: "https://pub.dev" + source: hosted + version: "0.5.2+8" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" file_selector_linux: dependency: transitive description: @@ -434,6 +450,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" file_selector_windows: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0002bdd..2669fea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,8 +37,7 @@ dependencies: path_provider: 2.1.6 url_launcher: 6.3.2 freezed_annotation: 3.1.0 - image_picker: 1.2.3 - file_picker: 12.0.0-beta.7 + image_picker: ^1.2.3 path: 1.9.1 dynamic_color: 1.8.1 collection: 1.19.1 @@ -78,6 +77,7 @@ dependencies: xdg_directories: 1.1.0 package_info_plus: 10.2.1 app_links: 7.2.1 + file_selector: ^1.1.0 dev_dependencies: build_runner: ^2.15.1