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/assets/twim/oauth.webp b/assets/twim/oauth.webp new file mode 100644 index 0000000..36fbedf Binary files /dev/null and b/assets/twim/oauth.webp differ diff --git a/assets/twim/settings.png b/assets/twim/settings.png new file mode 100644 index 0000000..3037be1 Binary files /dev/null and b/assets/twim/settings.png differ diff --git a/gomuks b/gomuks index 3c759ba..c06eb1c 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit 3c759ba03a5235deb82cb1559ebc42c36ee0e874 +Subproject commit c06eb1cbda2d49ff199a351e2d55df6ff7ab602b diff --git a/lib/controllers/attachment.dart b/lib/controllers/attachment.dart new file mode 100644 index 0000000..e33493e --- /dev/null +++ b/lib/controllers/attachment.dart @@ -0,0 +1,40 @@ +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, + encrypt: room?.state[EventType.encryption.type]?.isNotEmpty == true, + ), + ); + + state = (filename, content); + } + + static final provider = NotifierProvider.family + .autoDispose( + AttachmentController.new, + ); +} diff --git a/lib/controllers/auth_url.dart b/lib/controllers/auth_url.dart index 1fe0695..156eb81 100644 --- a/lib/controllers/auth_url.dart +++ b/lib/controllers/auth_url.dart @@ -23,10 +23,8 @@ class AuthUrlController extends AsyncNotifier { ), ); - static final provider = - AsyncNotifierProvider.family< - AuthUrlController, - OAuthAuthCodeResponse, - Uri - >(AuthUrlController.new); + static final provider = AsyncNotifierProvider.family + .autoDispose( + AuthUrlController.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/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 1704ed0..7c9796b 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -5,6 +5,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; import "package:m3e_buttons/m3e_buttons.dart"; import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/main.dart"; @@ -89,41 +90,47 @@ class SettingsSectionsController ]), ), ]), - "Account": .new([ - .new(title: "Profile", icon: Icons.person, settings: .new([])), - .new( - title: "Other", - icon: Icons.key, - settings: .new([ - .new( - title: "Log Out", - description: - "Log out of your account, returning you to the login page.", - builder: (title, description, icon) => Builder( - builder: (context) { - final colorScheme = Theme.of(context).colorScheme; - return M3EButton.icon( - onPressed: () async { - await ref - .watch(ClientController.provider.notifier) - .logout(); - if (context.mounted) Navigator.of(context).pop(); - }, - label: Text(title), - icon: Icon(icon), - tooltip: description, - decoration: .styleFrom( - backgroundColor: colorScheme.errorContainer, - foregroundColor: colorScheme.onErrorContainer, - ), - ); - }, + if (ref.watch(ClientStateController.provider)?.isLoggedIn == true) + "Account": .new([ + .new(title: "Profile", icon: Icons.person, settings: .new([])), + .new( + title: "Other", + icon: Icons.key, + settings: .new([ + .new( + title: "Log Out", + description: + "Log out of your account, returning you to the login page.", + builder: (title, description, icon) => Builder( + builder: (context) { + final colorScheme = Theme.of(context).colorScheme; + return M3EButton.icon( + onPressed: () async { + Navigator.of( + context, + ).popUntil((route) => route.isFirst); + + await WidgetsBinding.instance.endOfFrame; + + await ref + .watch(ClientController.provider.notifier) + .logout(); + }, + label: Text(title), + icon: Icon(icon), + tooltip: description, + decoration: .styleFrom( + backgroundColor: colorScheme.errorContainer, + foregroundColor: colorScheme.onErrorContainer, + ), + ); + }, + ), + icon: Icons.logout, ), - icon: Icons.logout, - ), - ]), - ), - ]), + ]), + ), + ]), }); } diff --git a/lib/helpers/extensions/scheme_to_theme.dart b/lib/helpers/extensions/scheme_to_theme.dart index fdda5d8..bf48535 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -1,22 +1,36 @@ import "package:flutter/material.dart"; extension SchemeToTheme on ColorScheme { - ThemeData get theme => .from(colorScheme: this).copyWith( - cardTheme: .new(color: primaryContainer), - popupMenuTheme: .new( - shape: RoundedRectangleBorder(borderRadius: .circular(16)), - color: surfaceContainerHigh, - ), - appBarTheme: AppBarTheme( - titleSpacing: 0, - backgroundColor: surfaceContainerLow, - ), - textTheme: ThemeData( + ThemeData get theme { + final textTheme = ThemeData( fontFamilyFallback: ["sans", "emoji", "fallback-sans", "fallback-emoji"], brightness: brightness, - ).textTheme, - inputDecorationTheme: const InputDecorationTheme( - border: OutlineInputBorder(), - ), - ); + ).textTheme; + return .from(colorScheme: this).copyWith( + cardTheme: .new(color: primaryContainer), + popupMenuTheme: .new( + shape: RoundedRectangleBorder(borderRadius: .circular(16)), + color: surfaceContainerHigh, + ), + appBarTheme: AppBarTheme( + titleSpacing: 0, + backgroundColor: surfaceContainerLow, + ), + tooltipTheme: .new( + textStyle: textTheme.labelLarge?.copyWith( + fontSize: 16, + fontWeight: .w600, + ), + padding: .all(8), + decoration: BoxDecoration( + color: surfaceContainerHighest, + borderRadius: .circular(8), + ), + ), + textTheme: textTheme, + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + ), + ); + } } diff --git a/lib/main.dart b/lib/main.dart index 05731e4..f50d22d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -38,11 +38,16 @@ New Value: ${newValue is AsyncData ? newValue.value : newValue} } void showError(Object error, [StackTrace? stackTrace]) { - if (error.toString().contains("DioException")) return; - if (error.toString().contains("Invalid source")) return; - if (error.toString().contains("UTF-16")) return; - if (error.toString().contains("HTTP request failed")) return; - if (error.toString().contains("Invalid image data")) return; + if (error.toString().contains("DioException") || + error.toString().contains( + "setState() or markNeedsBuild() called during build.", + ) || + error.toString().contains("Invalid source") || + error.toString().contains("UTF-16") || + error.toString().contains("HTTP request failed") || + error.toString().contains("Invalid image data")) { + return; + } debugPrintStack(stackTrace: stackTrace, label: error.toString()); if (navigatorKey.currentContext != null) { @@ -79,7 +84,7 @@ void main() async { runApp( ProviderScope( - retry: null, + retry: (_, _) => null, observers: [ // Change false to true if you want debug information on provider reloads // ignore: dead_code diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index 6314161..acbe70e 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -7,6 +7,8 @@ import "package:nexus/models/info/video.dart"; part "message.freezed.dart"; part "message.g.dart"; +typedef EncryptedFile = Map; + @Freezed(unionKey: "msgtype", fallbackUnion: "default") abstract class MessageContent extends Content with _$MessageContent { MessageContent._(); @@ -38,7 +40,7 @@ abstract class MessageContent extends Content with _$MessageContent { required String body, MessageFormat? format, String? formattedBody, - // EncryptedFile? file + EncryptedFile? file, String? filename, ImageInfo? info, Uri? url, @@ -49,7 +51,7 @@ abstract class MessageContent extends Content with _$MessageContent { required String body, MessageFormat? format, String? formattedBody, - // EncryptedFile? file + EncryptedFile? file, String? filename, FileInfo? info, Uri? url, @@ -60,7 +62,7 @@ abstract class MessageContent extends Content with _$MessageContent { required String body, MessageFormat? format, String? formattedBody, - // EncryptedFile? file + EncryptedFile? file, String? filename, AudioInfo? info, Uri? url, @@ -71,7 +73,7 @@ abstract class MessageContent extends Content with _$MessageContent { required String body, MessageFormat? format, String? formattedBody, - // EncryptedFile? file + EncryptedFile? file, String? filename, VideoInfo? info, Uri? url, 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..a640bea --- /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 encrypt, + 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/pages/select_server.dart b/lib/pages/select_server.dart index 118e70a..f7cefb8 100644 --- a/lib/pages/select_server.dart +++ b/lib/pages/select_server.dart @@ -9,6 +9,7 @@ import "package:nexus/controllers/client_id.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/main.dart"; import "package:nexus/models/homeserver.dart"; +import "package:nexus/pages/settings.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/divider_text.dart"; @@ -57,6 +58,9 @@ class SelectServerPage extends HookConsumerWidget { await ref.watch(LaunchHelper.provider).launchUrl(codeResponse.url); AppLinks().uriLinkStream.listen((encodedUri) async { + final state = encodedUri.queryParameters["state"]; + if (state != codeResponse.state) return; + final code = encodedUri.queryParameters["code"]; if (code != null) { @@ -79,9 +83,6 @@ class SelectServerPage extends HookConsumerWidget { .onError(showError); } }); - // await Navigator.of(context).push( - // MaterialPageRoute(builder: (_) => LoginPage(homeserver: newUrl)), - // ); } } } catch (error, stackTrace) { @@ -92,7 +93,15 @@ class SelectServerPage extends HookConsumerWidget { } return Scaffold( - appBar: Appbar(), + appBar: Appbar( + actions: .new([ + IconButton( + onPressed: () => + showDialog(context: context, builder: (_) => SettingsPage()), + icon: Icon(Icons.settings), + ), + ]), + ), body: SafeArea( child: Center( child: ConstrainedBox( diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 96b9b82..fa4d8da 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -127,13 +127,13 @@ class SettingsPage extends ConsumerWidget { ), ...sections .mapTo( - (categoryGroup, categories) => [ + (section, categories) => [ SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric( horizontal: 16, ).copyWith(bottom: 4), - child: DividerText(categoryGroup), + child: DividerText(section), ), ), SliverM3ECardList( @@ -150,7 +150,14 @@ class SettingsPage extends ConsumerWidget { Navigator.of(context).push( MaterialPageRoute( builder: (context) => - SettingsCategoryPage(index), + SettingsCategoryPage( + sections + .values + .flattenedToList + .indexOf( + categories[index], + ), + ), ), ), itemBuilder: (context, index) => ListTile( diff --git a/lib/pages/verify.dart b/lib/pages/verify.dart index 1b4f7ad..4eefa6c 100644 --- a/lib/pages/verify.dart +++ b/lib/pages/verify.dart @@ -2,6 +2,7 @@ import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client.dart"; +import "package:nexus/pages/settings.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/helpers/required_validator_helper.dart"; @@ -32,7 +33,15 @@ class VerifyPage extends HookConsumerWidget { } return Scaffold( - appBar: Appbar(), + appBar: Appbar( + actions: .new([ + IconButton( + onPressed: () => + showDialog(context: context, builder: (_) => SettingsPage()), + icon: Icon(Icons.settings), + ), + ]), + ), body: AlertDialog( title: Text("Verify"), content: Form( diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index ef91dbe..c63d939 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; @@ -51,8 +56,10 @@ class Composer extends HookConsumerWidget { }; } + final attachment = ref.watch(AttachmentController.provider(roomId)); + void send() { - if (controller.value.text.isEmpty) return; + if (controller.value.text.isEmpty && attachment == null) return; onSend( controller.value.formattedText, shouldMention: shouldMention.value, @@ -70,129 +77,200 @@ class Composer extends HookConsumerWidget { 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), + ), + onTap: () async => ref + .watch( + AttachmentController.provider( + roomId, + ).notifier, + ) + .add( + (await ref + .watch( + ImagePickerController.provider, + ) + .pickImage(source: .camera))!, + ) + .onError(showError), + ), + 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/lib/widgets/composer/mention_overlay.dart b/lib/widgets/composer/mention_overlay.dart index 962ea84..ca4f95a 100644 --- a/lib/widgets/composer/mention_overlay.dart +++ b/lib/widgets/composer/mention_overlay.dart @@ -73,20 +73,23 @@ class MentionOverlay extends ConsumerWidget { :final displayName, :final avatarUrl, ) => - ListTile( - leading: AvatarOrHash( - avatarUrl, - displayName ?? - member.stateKey!.localpart, - ), - title: Text( - displayName ?? - member.stateKey!.localpart, - ), - subtitle: Text(member.stateKey!), - onTap: () => addTag( - id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})", - name: member.stateKey!.localpart, + Material( + color: Colors.transparent, + child: ListTile( + leading: AvatarOrHash( + avatarUrl, + displayName ?? + member.stateKey!.localpart, + ), + title: Text( + displayName ?? + member.stateKey!.localpart, + ), + subtitle: Text(member.stateKey!), + onTap: () => addTag( + id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})", + name: member.stateKey!.localpart, + ), ), ), _ => SizedBox.shrink(), @@ -101,40 +104,46 @@ class MentionOverlay extends ConsumerWidget { ? rooms.values : rooms.values.where( (room) => - (room.metadata?.name ?? room.metadata!.id) + (room.metadata?.name ?? + room.metadata?.id ?? + "") .toLowerCase() .contains(query.toLowerCase()), )) .map((room) { final name = room.metadata?.name ?? - room.metadata!.canonicalAlias ?? - room.metadata!.id; - return ListTile( - leading: AvatarOrHash( - room.metadata?.avatar, - name, - fallback: Icon(Icons.numbers), + room.metadata?.canonicalAlias ?? + room.metadata?.id ?? + "Unknown Room"; + return Material( + color: Colors.transparent, + child: ListTile( + leading: AvatarOrHash( + room.metadata?.avatar, + name, + fallback: Icon(Icons.numbers), + ), + title: Text(name), + subtitle: room.metadata?.topic == null + ? null + : Text(room.metadata!.topic!, maxLines: 1), + onTap: () { + final vias = ref.watch( + ViaController.provider(room), + ); + addTag( + id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)", + name: + (room.metadata?.canonicalAlias ?? + room.metadata?.id) + ?.substring(1) + .split(":") + .first ?? + "", + ); + }, ), - title: Text(name), - subtitle: room.metadata?.topic == null - ? null - : Text(room.metadata!.topic!, maxLines: 1), - onTap: () { - final vias = ref.watch( - ViaController.provider(room), - ); - addTag( - id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)", - name: - (room.metadata?.canonicalAlias ?? - room.metadata?.id) - ?.substring(1) - .split(":") - .first ?? - "", - ); - }, ); }) .toList(), 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