diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b9b6207..cf64422 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -27,7 +27,7 @@ jobs: KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }} - name: Build app - run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64 --dart-define=GIT_COMMIT=${{ github.sha }}" + run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64 --split-per-abi --dart-define=GIT_COMMIT=${{ github.sha }}" env: KEYSTORE_PATH: ../../keystore.jks KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} @@ -36,4 +36,4 @@ jobs: uses: actions/upload-artifact@v6 with: name: APK - path: build/app/outputs/flutter-apk/app-release.apk + path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk diff --git a/README.md b/README.md index 97f5ec8..8a370c0 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ nexus = { This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag: ```nix -url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0-alpha2"; +url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0-alpha4"; ``` Then import the module and enable it in your NixOS configuration: diff --git a/gomuks b/gomuks index ad275f7..7e43a63 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit ad275f7613894d9d2fa4d16d733a6816e2978de9 +Subproject commit 7e43a63e578b0d6f52269d65297198077bf5b45d diff --git a/hook/build.dart b/hook/build.dart index 9315e04..8726642 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -122,7 +122,7 @@ Future main(List args) => build(args, (input, output) async { "sqlite_fts5", "goolm", // goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream - if (targetOS == OS.android || targetOS == OS.iOS) "noheic", + "noheic", ].join(","); final archiveFile = targetOS == OS.iOS diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index a282f81..52b49ee 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -31,6 +31,7 @@ import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/get_space_hierarchy.dart"; import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/profile_response.dart"; +import "package:nexus/models/push_notification.dart"; import "package:nexus/models/recovery_key.dart"; import "package:nexus/models/requests/oauth/exchange_token.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart"; @@ -47,7 +48,6 @@ 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/room_metadata.dart"; import "package:nexus/models/room_summary.dart"; import "package:nexus/models/space_hierarchy.dart"; import "package:nexus/models/spec_versions_response.dart"; @@ -128,18 +128,15 @@ class ClientController extends AsyncNotifier { } } - Future<(Event, RoomMetadata)?> handlePush(Map data) async { + Future handlePush(Map data) async { final response = await callGomuksMethod( data, (handle, data) async => GomuksHandlePush(handle, data), ); - return response == null + return response?["notification"] == null ? null - : ( - Event.fromJson(response["event"]), - RoomMetadata.fromJson(response["room"]), - ); + : .fromJson(response["notification"]); } Future _sendCommand( diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart index 62c16c5..96a21db 100644 --- a/lib/controllers/gomuks_listener.dart +++ b/lib/controllers/gomuks_listener.dart @@ -17,6 +17,7 @@ import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/main.dart"; import "package:ffi/ffi.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/models/events_decrypted.dart"; import "package:nexus/models/sync_data.dart"; import "package:nexus/src/third_party/gomuks.g.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; @@ -60,6 +61,29 @@ class GomuksListenerController extends AsyncNotifier { event.roomId: .new(events: .new({event.rowId: event})), }), ); + case "events_decrypted": + final decrypted = EventsDecrypted.fromJson(decodedMuksEvent); + final metadata = ref + .read(RoomsController.provider)[decrypted.roomId] + ?.metadata; + + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + decrypted.roomId: .new( + events: decrypted.events, + metadata: metadata?.copyWith( + previewEventRowID: + decrypted.previewEventRowID ?? + metadata.previewEventRowID, + sortingTimestamp: + decrypted.sortingTimestamp ?? + metadata.sortingTimestamp, + ), + ), + }), + ); case "sync_complete": final syncData = SyncData.fromJson(decodedMuksEvent); final roomProvider = RoomsController.provider; @@ -119,6 +143,19 @@ class GomuksListenerController extends AsyncNotifier { } } + /// Waits for gomuks to report the client's device ID + Future deviceId() { + final completer = Completer(); + final subscription = ref.listen( + ClientStateController.provider.select((value) => value?.deviceId), + (_, next) { + if (next != null && !completer.isCompleted) completer.complete(next); + }, + fireImmediately: true, + ); + return completer.future.whenComplete(subscription.close); + } + static Future _start( int handle, Pointer< diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart index 27c2f91..2356c32 100644 --- a/lib/controllers/notification.dart +++ b/lib/controllers/notification.dart @@ -1,4 +1,5 @@ import "dart:async"; +import "dart:convert"; import "dart:io"; import "package:flutter/services.dart"; @@ -103,24 +104,75 @@ class NotificationController return true; } + /// Whether notifications can currently be shown, without prompting + Future permissionsGranted() async { + if (Platform.isLinux) { + return true; + } + + final controller = await future; + + if (Platform.isIOS) { + return (await controller + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >() + ?.checkPermissions()) + ?.isEnabled ?? + true; + } else if (Platform.isMacOS) { + return (await controller + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin + >() + ?.checkPermissions()) + ?.isEnabled ?? + true; + } else if (Platform.isAndroid) { + return await controller + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.areNotificationsEnabled() ?? + true; + } + + return true; + } + Future send({ required int id, required String title, String? body, File? icon, String? payload, + String? senderName, + File? senderAvatar, + String? selfName, + bool isGroup = false, + bool sound = true, + DateTime? timestamp, + String? threadId, }) async { debugPrint("Sending notification for $id"); + // In DMs the title is already the sender, so only prefix in groups + final prefix = isGroup ? senderName : null; + if (Platform.isLinux) { - final portal = await ref.watch(PortalController.provider.future); + final portal = await ref.read(PortalController.provider.future); + const escape = HtmlEscape(HtmlEscapeMode.element); await portal.notification.addNotification( id.toString(), title: title, - body: body, + body: prefix == null || body == null ? body : "$prefix: $body", + markupBody: prefix == null || body == null + ? null + : "${escape.convert(prefix)}: ${escape.convert(body)}", defaultAction: "app.event", defaultActionTarget: payload, + sound: sound ? null : const XdgNotificationSoundSilent(), category: XdgNotificationCategory.imReceived, priority: .normal, buttons: [ @@ -133,25 +185,64 @@ class NotificationController ], ); } else { + final controller = await future; + + // Append to the conversation already shown under this id, if any + final existingStyle = Platform.isAndroid + ? await controller + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.getActiveNotificationMessagingStyle(id: id) + .onError((_, _) => null) + : null; + + final darwin = DarwinNotificationDetails( + presentSound: sound, + threadIdentifier: threadId, + ); final notificationDetails = NotificationDetails( android: .new( "messages", "Messages", + silent: !sound, largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path), + styleInformation: senderName == null || body == null + ? null + : MessagingStyleInformation( + .new(name: selfName ?? "You"), + conversationTitle: isGroup ? title : null, + groupConversation: isGroup, + messages: [ + ...?existingStyle?.messages, + .new( + body, + timestamp ?? .now(), + .new( + name: senderName, + icon: senderAvatar == null + ? null + : BitmapFilePathAndroidIcon(senderAvatar.path), + ), + ), + ], + ), + ), + iOS: darwin, + macOS: darwin, + windows: .new( + audio: sound ? null : .silent(), + timestamp: timestamp, + header: threadId == null + ? null + : .new(id: threadId, title: title, arguments: payload ?? ""), ), - // TODO: See if icons can be added to iOS, macOS, and Windows - // notifications (#68) - iOS: .new(), - macOS: .new(), - windows: .new(), ); - final controller = await future; - await controller.show( id: id, title: title, - body: body, + body: prefix == null || body == null ? body : "$prefix: $body", notificationDetails: notificationDetails, payload: payload, ); diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 0d0688e..c4a8dda 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -25,6 +25,7 @@ class RoomChatController(final ChatParams info) @override Future build() async { final client = ref.read(ClientController.provider.notifier); + final room = ref.read( RoomsController.provider.select((rooms) => rooms[info.roomId]), ); diff --git a/lib/controllers/selected_room.dart b/lib/controllers/selected_room.dart new file mode 100644 index 0000000..f59232c --- /dev/null +++ b/lib/controllers/selected_room.dart @@ -0,0 +1,31 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/selected_space.dart"; +import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; + +class SelectedRoomController extends Notifier { + @override + String? build() { + final roomId = ref.watch( + KeyController.provider(KeyController.roomKey) + .select((async) => async.value), + ); + + final roomIds = ref.watch( + SelectedSpaceController.provider.select( + (space) => space?.allChildRooms + .map((room) => room.metadata?.id) + .nonNulls + .toIList(), + ), + ); + if (roomIds == null) return roomId; + + return roomIds.contains(roomId) ? roomId : roomIds.firstOrNull; + } + + static final provider = NotifierProvider( + SelectedRoomController.new, + ); +} diff --git a/lib/controllers/selected_space.dart b/lib/controllers/selected_space.dart new file mode 100644 index 0000000..7a54060 --- /dev/null +++ b/lib/controllers/selected_space.dart @@ -0,0 +1,25 @@ +import "package:collection/collection.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/spaces.dart"; +import "package:nexus/models/space.dart"; + +class SelectedSpaceController extends Notifier { + @override + Space? build() { + final spaces = ref.watch(SpacesController.provider).value; + if (spaces == null) return null; + + final spaceId = ref.watch( + KeyController.provider(KeyController.spaceKey) + .select((async) => async.value), + ); + + return spaces.firstWhereOrNull((space) => space.id == spaceId) ?? + spaces.firstOrNull; + } + + static final provider = NotifierProvider( + SelectedSpaceController.new, + ); +} diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index bf84252..1ca07a8 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -46,7 +46,7 @@ class SettingsSectionsController icon: Icon(icon), title: title, subtitle: Text(description), - initialValue: settings.theme, + value: settings.theme, options: ThemeMode.values, getName: (option) => toBeginningOfSentenceCase(option.name), onChanged: (value) => ref @@ -105,7 +105,7 @@ class SettingsSectionsController icon: Icon(icon), title: title, subtitle: Text(description), - initialValue: settings.roomOrder, + value: settings.roomOrder, options: RoomOrder.values, getName: (option) => switch (option) { .unreadFirst => "Mentioned, then unread", @@ -117,6 +117,21 @@ class SettingsSectionsController .onError(showError), ), ), + .new( + title: "Swipe To Reply", + description: "Swipe a message to the right to reply to it.", + icon: Icons.swipe_right, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.swipeToReply, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(swipeToReply: value)) + .onError(showError), + ), + ), if (!Platform.isAndroid && !Platform.isIOS) .new( title: "Show Minimize Button", @@ -179,7 +194,6 @@ class SettingsSectionsController final current = pusherRegistered.value; return DialogListTile( - key: ValueKey(current), title: title, subtitle: Text( unifiedPushAllowed.maybeWhen( @@ -189,8 +203,7 @@ class SettingsSectionsController description, ), icon: Icon(icon), - required: false, - initialValue: current, + value: current, options: [ null, ...?distributors.value, @@ -212,23 +225,32 @@ class SettingsSectionsController try { loading.value = true; if (value != null) { - if (await ref + final messenger = ScaffoldMessenger.of( + context, + ); + if (!await ref .read( NotificationController .provider .notifier, ) .requestPermissions()) { - await ref - .read( - UnifiedPushController - .provider - .notifier, - ) - .register(value); - } else { - // TODO: Handle not granted + messenger.showSnackBar( + .new( + content: Text( + "Notifications are disabled. Enable them for Nexus in your system settings.", + ), + ), + ); + return; } + await ref + .read( + UnifiedPushController + .provider + .notifier, + ) + .register(value); } else { await ref .read( @@ -270,7 +292,7 @@ class SettingsSectionsController icon: Icon(icon), title: title, subtitle: Text(description), - initialValue: ref + value: ref .watch(AccountDataController.provider) .invitePermissionConfig .defaultAction, diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 638e330..e862d8d 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -5,14 +5,12 @@ import "dart:io"; import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; -import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/selected_room.dart"; import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/push_key.dart"; import "package:nexus/main.dart"; import "package:nexus/controllers/client.dart"; -import "package:nexus/controllers/client_state.dart"; -import "package:nexus/models/content/message.dart"; -import "package:nexus/models/content/sticker.dart"; +import "package:nexus/controllers/gomuks_listener.dart"; import "package:unifiedpush/unifiedpush.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart"; import "package:window_manager/window_manager.dart"; @@ -74,50 +72,67 @@ class UnifiedPushController extends AsyncNotifier { }, onMessage: (message, instance) async { debugPrint("UP message received for $instance"); + if (await deregisterIfDisabled(instance)) { + if (isInBackground) exit(0); + return; + } + if (message.decrypted == false) { throw Exception( "Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.", ); } - final pushResponse = await ref + final notification = await ref .read(ClientController.provider.notifier) .handlePush(json.decode(String.fromCharCodes(message.content))); - if (pushResponse == null) return; - - final (event, roomMetadata) = pushResponse; - - if (event.unreadType?.has(.notify) != true || + if (notification == null || (!isInBackground && await windowManager.isFocused().onError((_, _) => true) && - await ref.read( - KeyController.provider(KeyController.roomKey).future, - ) == - event.roomId)) { + ref.read(SelectedRoomController.provider) == + notification.roomId)) { if (isInBackground) exit(0); return; } - final icon = roomMetadata.avatar == null + final client = ref.read(ClientController.provider.notifier); + + final icon = notification.roomAvatar == null ? null - : await ref - .read(ClientController.provider.notifier) - .downloadMedia( - .new(mxc: roomMetadata.avatar!, isAvatar: true), - ); + : await client.downloadMedia( + .new(mxc: notification.roomAvatar!, isAvatar: true), + ); + + final pmp = notification.perMessageProfile; + final senderAvatarUrl = pmp?.avatar ?? notification.sender.avatar; + // Only Android can show the sender's avatar + final senderAvatar = senderAvatarUrl == null || !Platform.isAndroid + ? null + : await client.downloadMedia( + .new(mxc: senderAvatarUrl, isAvatar: true), + ); await ref .read(NotificationController.provider.notifier) .send( - id: event.eventId.hashCode & 0x7fffffff, - title: roomMetadata.name ?? "New Event", + // Android stacks messages into one conversation per room + id: + (Platform.isAndroid + ? notification.roomId + : notification.eventId) + .hashCode & + 0x7fffffff, + title: notification.roomName, icon: icon, - payload: event.rowId.toString(), - body: switch (event.content) { - MessageContent(:final body?) || - StickerContent(:final body) => body, - _ => null, - }, + payload: notification.eventRowId.toString(), + body: notification.text, + senderName: pmp?.name ?? notification.sender.name, + senderAvatar: senderAvatar, + selfName: notification.self.name, + isGroup: !notification.isDm, + sound: notification.sound, + timestamp: notification.timestamp, + threadId: notification.roomId, ); if (isInBackground) exit(0); @@ -125,9 +140,11 @@ class UnifiedPushController extends AsyncNotifier { onUnregistered: deregister, ); - if (!registered) return null; + if (!registered || isInBackground) return null; try { + if (await deregisterIfDisabled()) return null; + return await register(); } catch (error, stackTrace) { showError(error, stackTrace); @@ -146,19 +163,9 @@ class UnifiedPushController extends AsyncNotifier { state = .loading(); try { - final deviceIdCompleter = Completer(); - final subscription = ref.listen( - ClientStateController.provider.select((value) => value?.deviceId), - (_, next) { - if (next != null && !deviceIdCompleter.isCompleted) { - deviceIdCompleter.complete(next); - } - }, - fireImmediately: true, - ); - final deviceId = await deviceIdCompleter.future.whenComplete( - subscription.close, - ); + final deviceId = await ref + .read(GomuksListenerController.provider.notifier) + .deviceId(); await Future(() async { final capabilities = await ref @@ -204,12 +211,27 @@ class UnifiedPushController extends AsyncNotifier { } } - Future deregister([String? instance]) async { - final clientState = ref.read(ClientStateController.provider); + Future deregisterIfDisabled([String? notificationInstance]) async { + if (await ref + .read(NotificationController.provider.notifier) + .permissionsGranted()) { + return false; + } - final keyProvider = PushKeyController.provider( - instance ?? clientState!.deviceId!, - ); + final instance = + notificationInstance ?? + await ref.read(GomuksListenerController.provider.notifier).deviceId(); + final key = await ref.read(PushKeyController.provider(instance).future); + if (key != null) await deregister(instance); + return true; + } + + Future deregister([String? instance]) async { + instance ??= await ref + .read(GomuksListenerController.provider.notifier) + .deviceId(); + + final keyProvider = PushKeyController.provider(instance); final key = await ref.read(keyProvider.future); if (key != null) { @@ -223,7 +245,7 @@ class UnifiedPushController extends AsyncNotifier { ); } - await UnifiedPush.unregister(instance ?? clientState!.deviceId!); + await UnifiedPush.unregister(instance); state = .data(null); } diff --git a/lib/helpers/extensions/save_attachment.dart b/lib/helpers/extensions/save_attachment.dart new file mode 100644 index 0000000..1fb048f --- /dev/null +++ b/lib/helpers/extensions/save_attachment.dart @@ -0,0 +1,34 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/show_error_snack_bar.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; + +extension SaveAttachment on BuildContext { + /// Downloads the media at [mxc], displaying a snackbar whilst it's donwnloading, + /// then lets the user pick where to save it, suggesting [filename]. + Future saveAttachment( + WidgetRef ref, + Uri mxc, { + required bool encrypted, + String? filename, + }) async { + final file = await withProgressSnackbar( + "Downloading file...", + () => ref + .read(ClientController.provider.notifier) + .downloadMedia(.new(mxc: mxc, encrypted: encrypted)), + ); + if (file == null || !mounted) return; + + final messenger = ScaffoldMessenger.of(this); + try { + await ref + .read(FileDialogHelper.provider) + .saveFile(file, filename ?? mxc.pathSegments.last); + } catch (error) { + messenger.showErrorSnackBar(error.toString()); + } + } +} diff --git a/lib/helpers/file_dialog_helper.dart b/lib/helpers/file_dialog_helper.dart new file mode 100644 index 0000000..de93d12 --- /dev/null +++ b/lib/helpers/file_dialog_helper.dart @@ -0,0 +1,93 @@ +import "dart:io"; + +import "package:file_selector/file_selector.dart"; +import "package:flutter_file_dialog/flutter_file_dialog.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:mime/mime.dart"; +import "package:nexus/controllers/image_picker.dart"; +import "package:nexus/controllers/portal.dart"; +import "package:xdg_desktop_portal/xdg_desktop_portal.dart"; + +class FileDialogHelper(final Ref ref) { + Future saveFile(File file, String filename) async { + final mimeType = lookupMimeType( + file.path, + headerBytes: await file + .openRead(0, defaultMagicNumbersMaxLength) + .expand((bytes) => bytes) + .toList(), + ); + + final filenameWithExtension = mimeType == null + ? filename + : "$filename.${extensionFromMime(mimeType)}"; + + if (Platform.isAndroid || Platform.isIOS) { + return await FlutterFileDialog.saveFile( + params: .new( + sourceFilePath: file.path, + fileName: filenameWithExtension, + ), + ) != + null; + } + + final destination = Platform.isLinux + ? await getLinuxSaveLocation(filenameWithExtension) + : (await getSaveLocation(suggestedName: filenameWithExtension))?.path; + if (destination == null) return false; + + await file.copy(destination); + return true; + } + + Future getLinuxSaveLocation(String filename) async { + final portal = await ref.read(PortalController.provider.future); + + try { + final result = await portal.fileChooser + .saveFile(title: "Save file", currentName: filename) + .first; + + return switch (result.uris.firstOrNull) { + final uri? => Uri.parse(uri).toFilePath(), + null => null, + }; + } on XdgPortalRequestCancelledException catch (_) { + return null; + } + } + + /// Lets the user pick a file, or only images if [images] is true. Returns + /// null if the user cancelled. + Future pickFile({bool images = false}) async { + if (!Platform.isLinux) { + return images + ? ref.read(ImagePickerController.provider).pickImage(source: .gallery) + : openFile(); + } + + final portal = await ref.read(PortalController.provider.future); + + try { + final result = await portal.fileChooser + .openFile( + title: images ? "Pick image" : "Pick file", + filters: [ + if (images) + .new("Images", [XdgFileChooserMimeTypePattern("image/*")]), + ], + ) + .first; + + return switch (result.uris.firstOrNull) { + final uri? => XFile(Uri.parse(uri).toFilePath()), + null => null, + }; + } on XdgPortalRequestCancelledException catch (_) { + return null; + } + } + + static final provider = Provider(FileDialogHelper.new); +} diff --git a/lib/helpers/mxc_image.dart b/lib/helpers/mxc_image.dart index 83a345a..4013397 100644 --- a/lib/helpers/mxc_image.dart +++ b/lib/helpers/mxc_image.dart @@ -1,5 +1,9 @@ -import "dart:ui"; +import "dart:async"; +import "dart:io"; +import "dart:isolate"; +import "dart:ui" as ui; +import "package:avif_native/avif_native.dart"; import "package:flutter/widgets.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; @@ -15,13 +19,19 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request) ImageStreamCompleter loadImage(MxcImage key, ImageDecoderCallback decode) => MultiFrameImageStreamCompleter(codec: _loadAsync(decode), scale: 1.0); - Future _loadAsync(ImageDecoderCallback decode) async { + Future _loadAsync(ImageDecoderCallback decode) async { final file = await ref .read(ClientController.provider.notifier) .downloadMedia(request); - final buffer = await ImmutableBuffer.fromFilePath(file.path); + final bytes = await File(file.path).readAsBytes(); - return decode(buffer); + // Flutter can't decode AVIF on Linux/Windows (or older Android/iOS), so we do it ourselves + if (String.fromCharCodes(bytes.skip(4).take(8)) + case "ftypavif" || "ftypavis") { + return AvifCodec(await Isolate.run(() => decodeAvif(bytes))); + } + + return decode(await .fromUint8List(bytes)); } @override @@ -31,3 +41,37 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request) @override int get hashCode => request.hashCode; } + +class AvifCodec(final AvifImageData data) implements ui.Codec { + var _frameIndex = 0; + + @override + int get frameCount => data.frames.length; + + // libavif uses -2 for unknown, which we treat as infinite like browsers do + @override + int get repetitionCount => + data.repetitionCount < 0 ? -1 : data.repetitionCount; + + @override + Future getNextFrame() { + final frame = data.frames[_frameIndex++ % frameCount]; + final completer = Completer(); + ui.decodeImageFromPixels( + frame.rgba, + frame.width, + frame.height, + .rgba8888, + (image) => completer.complete(AvifFrameInfo(frame.duration, image)), + ); + return completer.future; + } + + @override + void dispose() {} +} + +class AvifFrameInfo( + @override final Duration duration, + @override final ui.Image image, +) implements ui.FrameInfo; diff --git a/lib/models/events_decrypted.dart b/lib/models/events_decrypted.dart new file mode 100644 index 0000000..f35c8aa --- /dev/null +++ b/lib/models/events_decrypted.dart @@ -0,0 +1,23 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/room.dart"; + +part "events_decrypted.freezed.dart"; +part "events_decrypted.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const EventsDecrypted({ + required final String roomId, + @JsonKey(name: "preview_event_rowid") final int? previewEventRowID, + @EpochDateTimeConverter() final DateTime? sortingTimestamp, + + /// [events] is an IMap of eventRowId to event + @JsonKey(fromJson: Room.eventsJsonToIMap) + required final IMap events, +}) with _$EventsDecrypted { + factory EventsDecrypted.fromJson(Map json) => + _$EventsDecryptedFromJson(json); +} diff --git a/lib/models/push_notification.dart b/lib/models/push_notification.dart new file mode 100644 index 0000000..859b457 --- /dev/null +++ b/lib/models/push_notification.dart @@ -0,0 +1,44 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; + +part "push_notification.freezed.dart"; +part "push_notification.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const PushNotification({ + @EpochDateTimeConverter() required final DateTime timestamp, + + required final String eventId, + @JsonKey(name: "event_rowid") required final int eventRowId, + + required final String roomId, + required final String roomName, + final Uri? roomAvatar, + final bool isDm = false, + + required final NotificationUser sender, + required final NotificationUser self, + final NotificationUser? perMessageProfile, + + required final String text, + final Uri? image, + + final bool mention = false, + final bool reply = false, + final bool sound = false, +}) with _$PushNotification { + factory PushNotification.fromJson(Map json) => + _$PushNotificationFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const NotificationUser({ + required final String id, + required final String name, + final Uri? avatar, +}) with _$NotificationUser { + factory NotificationUser.fromJson(Map json) => + _$NotificationUserFromJson(json); +} diff --git a/lib/models/requests/register_pusher.dart b/lib/models/requests/register_pusher.dart index dd34e62..f38ecc0 100644 --- a/lib/models/requests/register_pusher.dart +++ b/lib/models/requests/register_pusher.dart @@ -13,8 +13,6 @@ class const RegisterPusherRequest({ required final String deviceDisplayName, required final PusherKind kind, required final String lang, - - /// TODO: What does this do? final String? profileTag, @JsonKey(name: "pushkey") required final String pushKey, diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 407aa19..f9198ed 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -13,6 +13,7 @@ class const Settings({ final bool linuxMobileMode = false, final bool showAllInHome = false, final bool showMinimizeButton = false, + final bool swipeToReply = false, final RoomOrder roomOrder = .unreadFirst, }) with _$Settings { Map toJson() => _$SettingsToJson(this); diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 82f0087..9b596c6 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -1,7 +1,6 @@ import "dart:io"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:file_selector/file_selector.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/widgets/emoji_picker.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; @@ -13,6 +12,7 @@ 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/helpers/file_dialog_helper.dart"; import "package:image_picker/image_picker.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/models/content/message.dart"; @@ -245,14 +245,24 @@ class const Composer( leading: Icon(icon), ), onTap: () => attach( - ref - .read(ImagePickerController.provider) - .pickImage(source: source), + source == .gallery + ? ref + .read(FileDialogHelper.provider) + .pickFile(images: true) + : ref + .read( + ImagePickerController + .provider, + ) + .pickImage(source: source), ).onError(showError), ), PopupMenuItem( - onTap: () => - attach(openFile()).onError(showError), + onTap: () => attach( + ref + .read(FileDialogHelper.provider) + .pickFile(), + ).onError(showError), child: ListTile( title: Text("Files"), leading: Icon(Icons.attachment), diff --git a/lib/widgets/expandable_image.dart b/lib/widgets/expandable_image.dart index 0639417..f10221e 100644 --- a/lib/widgets/expandable_image.dart +++ b/lib/widgets/expandable_image.dart @@ -1,6 +1,7 @@ import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:m3e_buttons/m3e_buttons.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; import "package:nexus/helpers/mxc_image.dart"; import "package:nexus/models/requests/download_media.dart"; import "package:nexus/widgets/error_dialog.dart"; @@ -8,6 +9,9 @@ import "package:nexus/widgets/error_dialog.dart"; final class const ExpandableImage( final DownloadMediaRequest? request, { required final Widget child, + + /// If set, a download button is shown, suggesting this filename + final String? downloadFilename, super.key, }) extends ConsumerWidget { @override @@ -38,9 +42,25 @@ final class const ExpandableImage( alignment: .topRight, child: Padding( padding: .all(32), - child: M3EButton( - onPressed: Navigator.of(context).pop, - child: Icon(Icons.close), + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + if (downloadFilename != null) + M3EButton( + onPressed: () => context.saveAttachment( + ref, + request!.mxc, + encrypted: request!.encrypted, + filename: downloadFilename, + ), + child: Icon(Icons.download), + ), + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ], ), ), ), diff --git a/lib/widgets/file_card.dart b/lib/widgets/file_card.dart index 4c0fdde..4c3ce43 100644 --- a/lib/widgets/file_card.dart +++ b/lib/widgets/file_card.dart @@ -1,4 +1,6 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; import "package:nexus/helpers/extensions/size_to_string.dart"; import "package:nexus/models/info/file.dart"; @@ -6,10 +8,11 @@ final class const FileCard( final Uri uri, final FileInfo? info, { final String? filename, + final bool encrypted = false, super.key, -}) extends StatelessWidget { +}) extends ConsumerWidget { @override - Widget build(BuildContext context) => SizedBox( + Widget build(BuildContext context, WidgetRef ref) => SizedBox( width: 320, child: Card( color: Theme.of(context).colorScheme.surfaceContainer, @@ -17,8 +20,15 @@ final class const FileCard( leading: Icon(Icons.file_copy), title: Text(filename ?? "file", maxLines: 1, overflow: .ellipsis), subtitle: info?.size == null ? null : Text(info!.size!.sizeAsString), - // TODO: Downloading files - trailing: IconButton(onPressed: null, icon: Icon(Icons.download)), + trailing: IconButton( + onPressed: () => context.saveAttachment( + ref, + uri, + encrypted: encrypted, + filename: filename, + ), + icon: Icon(Icons.download), + ), ), ), ); diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart index 325e449..5ea584f 100644 --- a/lib/widgets/html/html.dart +++ b/lib/widgets/html/html.dart @@ -79,6 +79,7 @@ class const Html( alignment: PlaceholderAlignment.middle, child: ExpandableImage( .new(mxc: src), + downloadFilename: src.pathSegments.last, child: Image( image: MxcImage(ref, .new(mxc: src)), errorBuilder: (_, error, _) => Text( diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart index c6a6e5b..94aec91 100644 --- a/lib/widgets/html/mention_chip.dart +++ b/lib/widgets/html/mention_chip.dart @@ -43,7 +43,6 @@ class const MentionChip(final String content, final String? roomId, {super.key}) if (data case MembershipContent membership) { context.showUserPopover(membership, mention, roomId: roomId); } else if (data case RoomSummary summary) { - // TODO: This is an extra call that isn't truly needed, get rid of it final eventRowId = linkInfo?.eventId == null ? null : await ref diff --git a/lib/widgets/message_image.dart b/lib/widgets/message_image.dart index b04c209..42c08d6 100644 --- a/lib/widgets/message_image.dart +++ b/lib/widgets/message_image.dart @@ -11,6 +11,7 @@ final class const MessageImage( final Uri url, { final i.ImageInfo? info, required final bool encrypted, + final String? filename, super.key, }) extends ConsumerWidget { @override @@ -18,6 +19,7 @@ final class const MessageImage( final request = DownloadMediaRequest(mxc: url, encrypted: encrypted); return ExpandableImage( request, + downloadFilename: filename, child: ClipRRect( borderRadius: .all(.circular(8)), child: AspectRatio( diff --git a/lib/widgets/pages/chat.dart b/lib/widgets/pages/chat.dart index 8502d02..9e18da7 100644 --- a/lib/widgets/pages/chat.dart +++ b/lib/widgets/pages/chat.dart @@ -2,7 +2,7 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/init_complete.dart"; -import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/selected_room.dart"; import "package:nexus/controllers/notification_request.dart"; import "package:nexus/helpers/extensions/is_desktop.dart"; import "package:nexus/widgets/appbar.dart"; @@ -37,42 +37,46 @@ class const ChatPage({super.key}) extends HookConsumerWidget { builder: (context, constraints) { final isDesktop = context.isDesktop; final showMembersByDefault = constraints.maxWidth > 1000; - final roomId = ref - .watch(KeyController.provider(KeyController.roomKey)) - .requireValue; + final roomId = ref.watch(SelectedRoomController.provider); - return SafeArea( - child: Scaffold( - key: scaffoldKey, - appBar: initComplete ? null : Appbar(), - body: initComplete - ? Row( - children: [ - if (isDesktop) Sidebar(), - Expanded( - child: RoomChat( - key: ValueKey(roomId), - roomId: roomId, - isDesktop: isDesktop, - showMembersByDefault: showMembersByDefault, - ), - ), - ], - ) - : Column( - children: [ - const SyncStatusBanner(), - Expanded( - child: Center( - child: Column( - mainAxisSize: .min, - children: [Loading(), Text("Syncing...")], + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) scaffoldKey.currentState?.openDrawer(); + }, + child: SafeArea( + child: Scaffold( + key: scaffoldKey, + appBar: initComplete ? null : Appbar(), + body: initComplete + ? Row( + children: [ + if (isDesktop) Sidebar(), + Expanded( + child: RoomChat( + key: ValueKey(roomId), + roomId: roomId, + isDesktop: isDesktop, + showMembersByDefault: showMembersByDefault, ), ), - ), - ], - ), - drawer: isDesktop || !initComplete ? null : Sidebar(), + ], + ) + : Column( + children: [ + const SyncStatusBanner(), + Expanded( + child: Center( + child: Column( + mainAxisSize: .min, + children: [Loading(), Text("Syncing...")], + ), + ), + ), + ], + ), + drawer: isDesktop || !initComplete ? null : Sidebar(), + ), ), ); }, diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart index cd69126..598687c 100644 --- a/lib/widgets/players/audio.dart +++ b/lib/widgets/players/audio.dart @@ -5,6 +5,7 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:media_kit/media_kit.dart"; import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; import "package:nexus/models/info/audio.dart"; import "package:nexus/widgets/players/player_controls.dart"; @@ -12,6 +13,7 @@ class const AudioPlayer( final Uri uri, final AudioInfo? info, { final bool encrypted = false, + final String? filename, super.key, }) extends HookConsumerWidget { @override @@ -38,8 +40,24 @@ class const AudioPlayer( child: Card( color: Theme.of(context).colorScheme.surfaceContainer, child: Padding( - padding: .only(left: 8, right: 16), - child: PlayerControls(player, loading: !loaded.value), + padding: .symmetric(horizontal: 8), + child: PlayerControls( + player, + loading: !loaded.value, + actions: .new([ + SizedBox(width: 4), + IconButton( + onPressed: () => context.saveAttachment( + ref, + uri, + encrypted: encrypted, + filename: filename, + ), + tooltip: "Download", + icon: Icon(Icons.download), + ), + ]), + ), ), ), ); diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart index 385602d..557e7c1 100644 --- a/lib/widgets/players/video.dart +++ b/lib/widgets/players/video.dart @@ -3,6 +3,7 @@ import "dart:async"; import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; import "package:nexus/models/info/video.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:media_kit/media_kit.dart"; @@ -13,6 +14,7 @@ class const VideoPlayer( final Uri uri, final VideoInfo? info, { final bool encrypted = false, + final String? filename, super.key, }) extends HookConsumerWidget { @override @@ -61,6 +63,24 @@ class const VideoPlayer( onTap: loaded.value ? player.playOrPause : null, ), ), + Positioned( + top: 4, + right: 4, + child: IconButton( + onPressed: () => context.saveAttachment( + ref, + uri, + encrypted: encrypted, + filename: filename, + ), + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: Colors.black38, + ), + tooltip: "Download", + icon: Icon(Icons.download), + ), + ), Positioned( left: 0, right: 0, diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart index edef81c..0b90781 100644 --- a/lib/widgets/reaction_row.dart +++ b/lib/widgets/reaction_row.dart @@ -53,6 +53,12 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { ref, .new(mxc: Uri.parse(reaction)), ), + errorBuilder: (context, _, _) => Icon( + Icons.broken_image, + color: Theme.of(context) + .colorScheme + .error, + ), ) : Text(reaction, overflow: .ellipsis), ), diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 14e5c94..3ec3d4a 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -29,6 +29,7 @@ import "package:nexus/widgets/renderers/message.dart"; import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/renderers/membership.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; +import "package:nexus/widgets/swipe_to_reply.dart"; import "package:nexus/widgets/timestamp.dart"; class const EventRenderer( @@ -39,6 +40,9 @@ class const EventRenderer( final int? maxLines, final JumpToEvent? jumpToEvent, final IList Function(Event event)? getEventOptions, + + /// If set, events can be swiped, calling this + final VoidCallback? onSwipe, super.key, }) extends HookConsumerWidget { @override @@ -239,18 +243,21 @@ class const EventRenderer( child: GestureDetector( onSecondaryTapUp: contextMenuCallback, onLongPressStart: contextMenuCallback, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 8) - .copyWith(top: isGrouped ? 0 : 8), - child: Wrap( - verticalDirection: .up, - spacing: 6, - crossAxisAlignment: .center, - children: [ - child, - if (child is! MessageRenderer) - Timestamp(event.timestamp), - ], + child: SwipeToReply( + onSwipe: onSwipe, + Padding( + padding: EdgeInsets.symmetric(horizontal: 8) + .copyWith(top: isGrouped ? 0 : 8), + child: Wrap( + verticalDirection: .up, + spacing: 6, + crossAxisAlignment: .center, + children: [ + child, + if (child is! MessageRenderer) + Timestamp(event.timestamp), + ], + ), ), ), ), diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart index 3404bc5..598e9f9 100644 --- a/lib/widgets/renderers/message.dart +++ b/lib/widgets/renderers/message.dart @@ -110,14 +110,53 @@ class const MessageRenderer( ConstrainedBox( constraints: .loose(.square(500)), child: switch (event.content) { - VideoMessageContent(:final info, :final file) => - VideoPlayer(url, info, encrypted: file != null), - AudioMessageContent(:final info, :final file) => - AudioPlayer(url, info, encrypted: file != null), - FileMessageContent(:final info, :final filename) => - FileCard(url, info, filename: filename), - ImageMessageContent(:final info, :final file) => - MessageImage(url, info: info, encrypted: file != null), + VideoMessageContent( + :final info, + :final file, + :final filename, + :final body, + ) => + VideoPlayer( + url, + info, + encrypted: file != null, + filename: filename ?? body, + ), + AudioMessageContent( + :final info, + :final file, + :final filename, + :final body, + ) => + AudioPlayer( + url, + info, + encrypted: file != null, + filename: filename ?? body, + ), + FileMessageContent( + :final info, + :final filename, + :final file, + ) => + FileCard( + url, + info, + filename: filename, + encrypted: file != null, + ), + ImageMessageContent( + :final info, + :final file, + :final filename, + :final body, + ) => + MessageImage( + url, + info: info, + encrypted: file != null, + filename: filename ?? body, + ), _ => SizedBox.shrink(), }, ), diff --git a/lib/widgets/reset_encryption_sheet.dart b/lib/widgets/reset_encryption_sheet.dart index a3044a1..8482a8f 100644 --- a/lib/widgets/reset_encryption_sheet.dart +++ b/lib/widgets/reset_encryption_sheet.dart @@ -5,6 +5,7 @@ import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/main.dart"; +import "package:nexus/models/client_state.dart"; import "package:nexus/widgets/recovery_key_sheet.dart"; import "package:nexus/widgets/sheet_layout.dart"; @@ -16,11 +17,12 @@ class const ResetEncryptionSheet({ }) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isReset = ref.watch( + final verificationState = ref.watch( ClientStateController.provider.select( - (state) => state?.verificationState.hasCrossSigning ?? false, + (state) => state?.verificationState, ), ); + final isReset = verificationState?.hasCrossSigning ?? false; final usePassphrase = useState(false); final passphraseController = useTextEditingController(); @@ -95,6 +97,13 @@ class const ResetEncryptionSheet({ ), ], children: [ + if (verificationState case VerificationState( + hasCrossSigning: true, + hasSecretStorage: false, + )) + const Text( + "Your account has an encryption identity, but no recovery key. If you're still signed in on another client, you can set up a recovery key there instead, then restart Nexus and use it to verify. Otherwise, continue with the reset:", + ), Text( isReset ? "This replaces your encryption identity with a new one. Your other sessions will need to be verified again, and any messages you can't currently decrypt may become unreadable." diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 254c782..8a4c071 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -13,6 +13,7 @@ class const ChatTimeline({ required final IList Function(Event) getEventOptions, required final int? highlightedEvent, required final double composerHeight, + final void Function(int rowId)? onSwipe, super.key, }) extends StatelessWidget { Widget eventRow( @@ -26,6 +27,7 @@ class const ChatTimeline({ roomId: roomId, jumpToEvent: jumpToEvent, getEventOptions: getEventOptions, + onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId), ), key: key, isHighlighted: highlightedEvent == eventRowId, diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index f45a6f2..155e90e 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -7,6 +7,8 @@ import "package:measure_size/measure_size.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/member_list_opened.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/room_chat.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; @@ -171,19 +173,30 @@ final class const RoomChat({ return null; }, [jumpRowId]); + void onRelation(int rowId, RelationType type) { + relatedEvent.value = rowId; + relationType.value = type; + composerNode.requestFocus(); + } + IList getEventOptions(Event event) => event.buildEventOptions( context: context, ref: ref, roomId: roomId, userId: userId, - onRelation: (rowId, type) { - relatedEvent.value = rowId; - relationType.value = type; - composerNode.requestFocus(); - }, + onRelation: onRelation, ); + final swipeToReply = ref.watch( + SettingsController.provider.select( + (settings) => settings.value?.swipeToReply ?? false, + ), + ); + final canReply = ref.watch( + PowerLevelController.provider(.new(eventType: .message, roomId: roomId)), + ); + return Scaffold( endDrawer: PinnedEventsDrawer( roomId, @@ -224,6 +237,9 @@ final class const RoomChat({ roomId: roomId, jumpToEvent: jumpToEvent, getEventOptions: getEventOptions, + onSwipe: swipeToReply && canReply + ? (rowId) => onRelation(rowId, .reply) + : null, highlightedEvent: highlightedEvent.value, composerHeight: composerSize.value, ), diff --git a/lib/widgets/room_list.dart b/lib/widgets/room_list.dart index 9562e0f..f8d366e 100644 --- a/lib/widgets/room_list.dart +++ b/lib/widgets/room_list.dart @@ -22,9 +22,10 @@ import "package:nexus/widgets/room_summary_sheet.dart"; import "package:nexus/widgets/room_menu_button.dart"; import "package:nexus/widgets/space_hierarchy_sheet.dart"; import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/selected_room.dart"; +import "package:nexus/controllers/selected_space.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/spaces.dart"; -import "package:collection/collection.dart"; // Needed for navigation_rail_m3e (#65). import "package:flutter/material.dart" as old_mat; @@ -217,23 +218,8 @@ class const SpacesRail({super.key}) extends ConsumerWidget { class const RoomsPane({super.key}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final selectedSpaceId = ref.watch( - KeyController.provider(KeyController.spaceKey) - .select((async) => async.requireValue), - ); - final selectedRoomId = ref.watch( - KeyController.provider(KeyController.roomKey) - .select((async) => async.requireValue), - ); - final selectedSpace = ref.watch( - SpacesController.provider.select((async) { - final spaces = async.requireValue; - return spaces.firstWhereOrNull( - (space) => space.id == selectedSpaceId, - ) ?? - spaces.first; - }), - ); + final selectedRoomId = ref.watch(SelectedRoomController.provider); + final selectedSpace = ref.watch(SelectedSpaceController.provider)!; final isDm = selectedSpace.id == "dms"; final spaceMetadata = selectedSpace.room?.metadata; diff --git a/lib/widgets/room_summary_sheet.dart b/lib/widgets/room_summary_sheet.dart index bfc14c7..60c5baa 100644 --- a/lib/widgets/room_summary_sheet.dart +++ b/lib/widgets/room_summary_sheet.dart @@ -30,6 +30,7 @@ class const RoomSummarySheet( if (summary.avatarUrl != null) ExpandableImage( summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!), + downloadFilename: name, child: AvatarOrHash( summary.avatarUrl, "", diff --git a/lib/widgets/settings/dialog_list_tile.dart b/lib/widgets/settings/dialog_list_tile.dart index afa79f3..d802d7b 100644 --- a/lib/widgets/settings/dialog_list_tile.dart +++ b/lib/widgets/settings/dialog_list_tile.dart @@ -4,53 +4,35 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/widgets/settings/radio_sheet.dart"; final class const DialogListTile({ - required final T? initialValue, + required final T? value, required final Icon icon, required final String title, required final List options, required final void Function(T value)? onChanged, required final String Function(T option) getName, final Widget? subtitle, - final bool required = true, super.key, }) extends ConsumerWidget { @override - Widget build(BuildContext context, WidgetRef ref) => FormField( - validator: (value) => - value == null && required == true ? "This field is required." : null, - initialValue: initialValue, - builder: (field) => InputDecorator( - decoration: InputDecoration( - errorText: field.errorText, - contentPadding: EdgeInsets.zero, - enabledBorder: InputBorder.none, + Widget build(BuildContext context, WidgetRef ref) => ListTile( + enabled: onChanged != null, + onTap: () => context.showAdaptiveSheet( + (context) => RadioSheet( + title: title, + getName: getName, + onChanged: onChanged, + value: value, + options: options, ), - child: ListTile( - enabled: onChanged != null, - onTap: () => context.showAdaptiveSheet( - (context) => RadioSheet( - title: title, - getName: getName, - onChanged: onChanged == null - ? null - : (value) { - field.didChange(value); - onChanged!.call(value); - }, - value: field.value, - options: options, - ), - ), - title: Text(title), - subtitle: subtitle, - leading: icon, - trailing: Chip( - label: Text( - field.value == null ? "None" : getName(field.value as T), - overflow: TextOverflow.ellipsis, - style: onChanged == null ? .new(color: Colors.grey) : null, - ), - ), + ), + title: Text(title), + subtitle: subtitle, + leading: icon, + trailing: Chip( + label: Text( + value == null ? "None" : getName(value as T), + overflow: TextOverflow.ellipsis, + style: onChanged == null ? .new(color: Colors.grey) : null, ), ), ); diff --git a/lib/widgets/space_hierarchy_sheet.dart b/lib/widgets/space_hierarchy_sheet.dart index 7f8b588..85ae46c 100644 --- a/lib/widgets/space_hierarchy_sheet.dart +++ b/lib/widgets/space_hierarchy_sheet.dart @@ -236,6 +236,8 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) if (space?.avatarUrl case final avatarUrl?) ExpandableImage( .new(mxc: avatarUrl), + downloadFilename: + space?.name ?? space?.canonicalAlias ?? spaceId, child: AvatarOrHash(avatarUrl, "", dimension: 32), ), Expanded( diff --git a/lib/widgets/swipe_to_reply.dart b/lib/widgets/swipe_to_reply.dart new file mode 100644 index 0000000..c02b310 --- /dev/null +++ b/lib/widgets/swipe_to_reply.dart @@ -0,0 +1,58 @@ +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; + +class const SwipeToReply( + final Widget child, { + required final VoidCallback? onSwipe, + super.key, +}) extends HookWidget { + static const maxDrag = 64.0; + static const threshold = 0.75; + + @override + Widget build(BuildContext context) { + final controller = useAnimationController(duration: Durations.short4); + useListenable(controller); + + if (onSwipe == null) return child; + + final progress = controller.value; + + return GestureDetector( + onHorizontalDragUpdate: (details) { + final previous = controller.value; + controller.value = previous + details.primaryDelta! / maxDrag; + + if (previous < threshold && controller.value >= threshold) { + HapticFeedback.lightImpact(); + } + }, + onHorizontalDragEnd: (_) { + if (controller.value >= threshold) onSwipe!(); + controller.animateBack(0, curve: Curves.easeOut); + }, + onHorizontalDragCancel: () => + controller.animateBack(0, curve: Curves.easeOut), + child: Stack( + alignment: .centerLeft, + children: [ + Positioned( + left: 16, + child: Opacity( + opacity: (progress / threshold).clamp(0, 1), + child: Transform.scale( + scale: progress >= threshold ? 1 : 0.75, + child: const Icon(Icons.reply), + ), + ), + ), + Transform.translate( + offset: .new(progress * maxDrag, 0), + child: child, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index 0232b08..b6da63d 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -103,6 +103,7 @@ final class const UserBottomSheet( ExpandableImage( member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), + downloadFilename: member.displayName ?? userId.localpart, child: AvatarOrHash( member.avatarUrl, member.displayName ?? userId.localpart, diff --git a/linux/nix/pkg/avif_native.nix b/linux/nix/pkg/avif_native.nix new file mode 100644 index 0000000..16d0fc0 --- /dev/null +++ b/linux/nix/pkg/avif_native.nix @@ -0,0 +1,49 @@ +# avif_native's build hook downloads its prebuilt library, which the sandbox +# can't do, so bundle it and point the manifest at the local copy instead +{ + stdenv, + fetchurl, + jq, +}: +let + artifacts = { + x86_64-linux = { + arch = "x64"; + sha256 = "252b064f8ff4f0f265bc44294d4a7ed703dd7199688942c272dd8aa8d3039d5a"; + }; + aarch64-linux = { + arch = "arm64"; + sha256 = "cdcf17cfd18b9b0f965b72615ddf11246121114577dc570d0bba25964e56c726"; + }; + }; + inherit (artifacts.${stdenv.hostPlatform.system}) arch sha256; + key = "linux-${arch}-decode-dav1d"; +in +{ version, src, ... }: +stdenv.mkDerivation { + pname = "avif_native"; + inherit version src; + inherit (src) passthru; + + nativeBuildInputs = [ jq ]; + + library = fetchurl { + url = "https://github.com/Zhangw1998/avif_native/releases/download/native-v0.1.0/${key}-libavif_native.so.gz"; + inherit sha256; + }; + + # The hook checksums the library, so it must stay byte-identical + dontFixup = true; + + installPhase = '' + runHook preInstall + + cp -r . $out + chmod -R u+w $out + gunzip -c $library > $out/native/libavif_native.so + jq '.artifacts["${key}"].path = "libavif_native.so"' \ + native/manifest.json > $out/native/manifest.json + + runHook postInstall + ''; +} diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index 7aabf9b..f2bbbff 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -12,7 +12,7 @@ flutter.buildFlutterApplication { pname = "nexus"; - version = "1.0.0-alpha2"; + version = "1.0.0-alpha4"; inherit src; preBuild = '' @@ -34,6 +34,8 @@ flutter.buildFlutterApplication { autoPubspecLock = src + "/pubspec.lock"; + customSourceBuilders.avif_native = callPackage ./avif_native.nix { }; + gitHashes = { linkify = "sha256-TpMD6+0zyY6i9l+6d8ErnVufmepCv362rCtnbOht/z4="; fab_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix index 604f5be..6c383d2 100644 --- a/linux/nix/pkg/gomuks.nix +++ b/linux/nix/pkg/gomuks.nix @@ -17,7 +17,7 @@ buildGoModule ( src = "${src}/gomuks"; - vendorHash = "sha256-+0iFK2ribhiNskXgPz7vslncuOgPMB/ZmFSfDOcz6ww="; + vendorHash = "sha256-nd0NEKXscgrFE/gBEW2aP+YeZNTOdbRa4QDLTRwIAZU="; buildPhase = '' runHook preBuild diff --git a/pubspec.lock b/pubspec.lock index 6b755da..1958e04 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + avif_native: + dependency: "direct main" + description: + name: avif_native + sha256: "0a374a7ddc6e48d671792bd33efd9f46f199389cdcd42561210bd886facbc62e" + url: "https://pub.dev" + source: hosted + version: "0.1.1" boolean_selector: dependency: transitive description: @@ -455,6 +463,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.1" + flutter_file_dialog: + dependency: "direct main" + description: + name: flutter_file_dialog + sha256: "5d270d69b6a5fdf45bdb88ebcf590e073dd89dfb45526ac83a64524ba57303b5" + url: "https://pub.dev" + source: hosted + version: "3.3.3" flutter_hooks: dependency: "direct main" description: @@ -985,7 +1001,7 @@ packages: source: hosted version: "1.19.0" mime: - dependency: transitive + dependency: "direct main" description: name: mime sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 diff --git a/pubspec.yaml b/pubspec.yaml index 1c6fb4a..c93eaac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: nexus description: "A simple and user-friendly Matrix client" -version: 1.0.0-alpha2 +version: 1.0.0-alpha4 publish_to: none flutter: @@ -107,7 +107,9 @@ dependencies: url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart ref: quad/nexus pasteboard: 0.5.0 - + flutter_file_dialog: 3.3.3 + mime: 2.1.0 + avif_native: 0.1.1 dev_dependencies: build_runner: 2.16.2 flutter_lints: 6.0.0 @@ -131,3 +133,8 @@ flutter_launcher_icons: macos: generate: true image_path: assets/mobile.png + +hooks: + user_defines: + avif_native: + features: decode