Compare commits

..
45 changed files with 227 additions and 957 deletions

View file

@ -27,7 +27,7 @@ jobs:
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }} KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
- name: Build app - 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 --split-per-abi --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 --dart-define=GIT_COMMIT=${{ github.sha }}"
env: env:
KEYSTORE_PATH: ../../keystore.jks KEYSTORE_PATH: ../../keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
@ -36,4 +36,4 @@ jobs:
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6
with: with:
name: APK name: APK
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk path: build/app/outputs/flutter-apk/app-release.apk

View file

@ -49,7 +49,7 @@ nexus = {
This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag: This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag:
```nix ```nix
url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0-alpha4"; url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0-alpha2";
``` ```
Then import the module and enable it in your NixOS configuration: Then import the module and enable it in your NixOS configuration:

2
gomuks

@ -1 +1 @@
Subproject commit 7e43a63e578b0d6f52269d65297198077bf5b45d Subproject commit ad275f7613894d9d2fa4d16d733a6816e2978de9

View file

@ -122,7 +122,7 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
"sqlite_fts5", "sqlite_fts5",
"goolm", "goolm",
// goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream // goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream
"noheic", if (targetOS == OS.android || targetOS == OS.iOS) "noheic",
].join(","); ].join(",");
final archiveFile = targetOS == OS.iOS final archiveFile = targetOS == OS.iOS

View file

@ -31,7 +31,6 @@ import "package:nexus/models/requests/get_room_state.dart";
import "package:nexus/models/requests/get_space_hierarchy.dart"; import "package:nexus/models/requests/get_space_hierarchy.dart";
import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/requests/join_room.dart";
import "package:nexus/models/profile_response.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/recovery_key.dart";
import "package:nexus/models/requests/oauth/exchange_token.dart"; import "package:nexus/models/requests/oauth/exchange_token.dart";
import "package:nexus/models/requests/oauth/get_auth_url.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart";
@ -48,6 +47,7 @@ import "package:nexus/models/requests/set_membership.dart";
import "package:nexus/models/requests/set_state.dart"; import "package:nexus/models/requests/set_state.dart";
import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/requests/upload_media.dart";
import "package:nexus/models/room.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/room_summary.dart";
import "package:nexus/models/space_hierarchy.dart"; import "package:nexus/models/space_hierarchy.dart";
import "package:nexus/models/spec_versions_response.dart"; import "package:nexus/models/spec_versions_response.dart";
@ -128,15 +128,18 @@ class ClientController extends AsyncNotifier<int> {
} }
} }
Future<PushNotification?> handlePush(Map<String, dynamic> data) async { Future<(Event, RoomMetadata)?> handlePush(Map<String, dynamic> data) async {
final response = await callGomuksMethod( final response = await callGomuksMethod(
data, data,
(handle, data) async => GomuksHandlePush(handle, data), (handle, data) async => GomuksHandlePush(handle, data),
); );
return response?["notification"] == null return response == null
? null ? null
: .fromJson(response["notification"]); : (
Event.fromJson(response["event"]),
RoomMetadata.fromJson(response["room"]),
);
} }
Future<dynamic> _sendCommand( Future<dynamic> _sendCommand(

View file

@ -17,7 +17,6 @@ import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:ffi/ffi.dart"; import "package:ffi/ffi.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/events_decrypted.dart";
import "package:nexus/models/sync_data.dart"; import "package:nexus/models/sync_data.dart";
import "package:nexus/src/third_party/gomuks.g.dart"; import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
@ -61,29 +60,6 @@ class GomuksListenerController extends AsyncNotifier<void> {
event.roomId: .new(events: .new({event.rowId: event})), 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": case "sync_complete":
final syncData = SyncData.fromJson(decodedMuksEvent); final syncData = SyncData.fromJson(decodedMuksEvent);
final roomProvider = RoomsController.provider; final roomProvider = RoomsController.provider;
@ -143,19 +119,6 @@ class GomuksListenerController extends AsyncNotifier<void> {
} }
} }
/// Waits for gomuks to report the client's device ID
Future<String> deviceId() {
final completer = Completer<String>();
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<int> _start( static Future<int> _start(
int handle, int handle,
Pointer< Pointer<

View file

@ -1,5 +1,4 @@
import "dart:async"; import "dart:async";
import "dart:convert";
import "dart:io"; import "dart:io";
import "package:flutter/services.dart"; import "package:flutter/services.dart";
@ -104,75 +103,24 @@ class NotificationController
return true; return true;
} }
/// Whether notifications can currently be shown, without prompting
Future<bool> 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<void> send({ Future<void> send({
required int id, required int id,
required String title, required String title,
String? body, String? body,
File? icon, File? icon,
String? payload, String? payload,
String? senderName,
File? senderAvatar,
String? selfName,
bool isGroup = false,
bool sound = true,
DateTime? timestamp,
String? threadId,
}) async { }) async {
debugPrint("Sending notification for $id"); 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) { if (Platform.isLinux) {
final portal = await ref.read(PortalController.provider.future); final portal = await ref.watch(PortalController.provider.future);
const escape = HtmlEscape(HtmlEscapeMode.element);
await portal.notification.addNotification( await portal.notification.addNotification(
id.toString(), id.toString(),
title: title, title: title,
body: prefix == null || body == null ? body : "$prefix: $body", body: body,
markupBody: prefix == null || body == null
? null
: "<b>${escape.convert(prefix)}</b>: ${escape.convert(body)}",
defaultAction: "app.event", defaultAction: "app.event",
defaultActionTarget: payload, defaultActionTarget: payload,
sound: sound ? null : const XdgNotificationSoundSilent(),
category: XdgNotificationCategory.imReceived, category: XdgNotificationCategory.imReceived,
priority: .normal, priority: .normal,
buttons: [ buttons: [
@ -185,64 +133,25 @@ class NotificationController
], ],
); );
} else { } 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( final notificationDetails = NotificationDetails(
android: .new( android: .new(
"messages", "messages",
"Messages", "Messages",
silent: !sound,
largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path), 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( await controller.show(
id: id, id: id,
title: title, title: title,
body: prefix == null || body == null ? body : "$prefix: $body", body: body,
notificationDetails: notificationDetails, notificationDetails: notificationDetails,
payload: payload, payload: payload,
); );

View file

@ -25,7 +25,6 @@ class RoomChatController(final ChatParams info)
@override @override
Future<RoomChat?> build() async { Future<RoomChat?> build() async {
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
final room = ref.read( final room = ref.read(
RoomsController.provider.select((rooms) => rooms[info.roomId]), RoomsController.provider.select((rooms) => rooms[info.roomId]),
); );

View file

@ -1,31 +0,0 @@
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<String?> {
@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, String?>(
SelectedRoomController.new,
);
}

View file

@ -1,25 +0,0 @@
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<Space?> {
@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, Space?>(
SelectedSpaceController.new,
);
}

View file

@ -46,7 +46,7 @@ class SettingsSectionsController
icon: Icon(icon), icon: Icon(icon),
title: title, title: title,
subtitle: Text(description), subtitle: Text(description),
value: settings.theme, initialValue: settings.theme,
options: ThemeMode.values, options: ThemeMode.values,
getName: (option) => toBeginningOfSentenceCase(option.name), getName: (option) => toBeginningOfSentenceCase(option.name),
onChanged: (value) => ref onChanged: (value) => ref
@ -105,7 +105,7 @@ class SettingsSectionsController
icon: Icon(icon), icon: Icon(icon),
title: title, title: title,
subtitle: Text(description), subtitle: Text(description),
value: settings.roomOrder, initialValue: settings.roomOrder,
options: RoomOrder.values, options: RoomOrder.values,
getName: (option) => switch (option) { getName: (option) => switch (option) {
.unreadFirst => "Mentioned, then unread", .unreadFirst => "Mentioned, then unread",
@ -117,21 +117,6 @@ class SettingsSectionsController
.onError(showError), .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) if (!Platform.isAndroid && !Platform.isIOS)
.new( .new(
title: "Show Minimize Button", title: "Show Minimize Button",
@ -194,6 +179,7 @@ class SettingsSectionsController
final current = pusherRegistered.value; final current = pusherRegistered.value;
return DialogListTile<String?>( return DialogListTile<String?>(
key: ValueKey(current),
title: title, title: title,
subtitle: Text( subtitle: Text(
unifiedPushAllowed.maybeWhen( unifiedPushAllowed.maybeWhen(
@ -203,7 +189,8 @@ class SettingsSectionsController
description, description,
), ),
icon: Icon(icon), icon: Icon(icon),
value: current, required: false,
initialValue: current,
options: [ options: [
null, null,
...?distributors.value, ...?distributors.value,
@ -225,25 +212,13 @@ class SettingsSectionsController
try { try {
loading.value = true; loading.value = true;
if (value != null) { if (value != null) {
final messenger = ScaffoldMessenger.of( if (await ref
context,
);
if (!await ref
.read( .read(
NotificationController NotificationController
.provider .provider
.notifier, .notifier,
) )
.requestPermissions()) { .requestPermissions()) {
messenger.showSnackBar(
.new(
content: Text(
"Notifications are disabled. Enable them for Nexus in your system settings.",
),
),
);
return;
}
await ref await ref
.read( .read(
UnifiedPushController UnifiedPushController
@ -251,6 +226,9 @@ class SettingsSectionsController
.notifier, .notifier,
) )
.register(value); .register(value);
} else {
// TODO: Handle not granted
}
} else { } else {
await ref await ref
.read( .read(
@ -292,7 +270,7 @@ class SettingsSectionsController
icon: Icon(icon), icon: Icon(icon),
title: title, title: title,
subtitle: Text(description), subtitle: Text(description),
value: ref initialValue: ref
.watch(AccountDataController.provider) .watch(AccountDataController.provider)
.invitePermissionConfig .invitePermissionConfig
.defaultAction, .defaultAction,

View file

@ -5,12 +5,14 @@ import "dart:io";
import "package:flutter/foundation.dart"; import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:intl/intl.dart"; import "package:intl/intl.dart";
import "package:nexus/controllers/selected_room.dart"; import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/push_key.dart"; import "package:nexus/controllers/push_key.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/gomuks_listener.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/sticker.dart";
import "package:unifiedpush/unifiedpush.dart"; import "package:unifiedpush/unifiedpush.dart";
import "package:unifiedpush_storage_shared_preferences/storage.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart";
import "package:window_manager/window_manager.dart"; import "package:window_manager/window_manager.dart";
@ -72,67 +74,50 @@ class UnifiedPushController extends AsyncNotifier<String?> {
}, },
onMessage: (message, instance) async { onMessage: (message, instance) async {
debugPrint("UP message received for $instance"); debugPrint("UP message received for $instance");
if (await deregisterIfDisabled(instance)) {
if (isInBackground) exit(0);
return;
}
if (message.decrypted == false) { if (message.decrypted == false) {
throw Exception( throw Exception(
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.", "Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
); );
} }
final notification = await ref final pushResponse = await ref
.read(ClientController.provider.notifier) .read(ClientController.provider.notifier)
.handlePush(json.decode(String.fromCharCodes(message.content))); .handlePush(json.decode(String.fromCharCodes(message.content)));
if (notification == null || if (pushResponse == null) return;
final (event, roomMetadata) = pushResponse;
if (event.unreadType?.has(.notify) != true ||
(!isInBackground && (!isInBackground &&
await windowManager.isFocused().onError((_, _) => true) && await windowManager.isFocused().onError((_, _) => true) &&
ref.read(SelectedRoomController.provider) == await ref.read(
notification.roomId)) { KeyController.provider(KeyController.roomKey).future,
) ==
event.roomId)) {
if (isInBackground) exit(0); if (isInBackground) exit(0);
return; return;
} }
final client = ref.read(ClientController.provider.notifier); final icon = roomMetadata.avatar == null
final icon = notification.roomAvatar == null
? null ? null
: await client.downloadMedia( : await ref
.new(mxc: notification.roomAvatar!, isAvatar: true), .read(ClientController.provider.notifier)
); .downloadMedia(
.new(mxc: roomMetadata.avatar!, 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 await ref
.read(NotificationController.provider.notifier) .read(NotificationController.provider.notifier)
.send( .send(
// Android stacks messages into one conversation per room id: event.eventId.hashCode & 0x7fffffff,
id: title: roomMetadata.name ?? "New Event",
(Platform.isAndroid
? notification.roomId
: notification.eventId)
.hashCode &
0x7fffffff,
title: notification.roomName,
icon: icon, icon: icon,
payload: notification.eventRowId.toString(), payload: event.rowId.toString(),
body: notification.text, body: switch (event.content) {
senderName: pmp?.name ?? notification.sender.name, MessageContent(:final body?) ||
senderAvatar: senderAvatar, StickerContent(:final body) => body,
selfName: notification.self.name, _ => null,
isGroup: !notification.isDm, },
sound: notification.sound,
timestamp: notification.timestamp,
threadId: notification.roomId,
); );
if (isInBackground) exit(0); if (isInBackground) exit(0);
@ -140,11 +125,9 @@ class UnifiedPushController extends AsyncNotifier<String?> {
onUnregistered: deregister, onUnregistered: deregister,
); );
if (!registered || isInBackground) return null; if (!registered) return null;
try { try {
if (await deregisterIfDisabled()) return null;
return await register(); return await register();
} catch (error, stackTrace) { } catch (error, stackTrace) {
showError(error, stackTrace); showError(error, stackTrace);
@ -163,9 +146,19 @@ class UnifiedPushController extends AsyncNotifier<String?> {
state = .loading(); state = .loading();
try { try {
final deviceId = await ref final deviceIdCompleter = Completer<String>();
.read(GomuksListenerController.provider.notifier) final subscription = ref.listen(
.deviceId(); 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,
);
await Future(() async { await Future(() async {
final capabilities = await ref final capabilities = await ref
@ -211,27 +204,12 @@ class UnifiedPushController extends AsyncNotifier<String?> {
} }
} }
Future<bool> deregisterIfDisabled([String? notificationInstance]) async {
if (await ref
.read(NotificationController.provider.notifier)
.permissionsGranted()) {
return false;
}
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<void> deregister([String? instance]) async { Future<void> deregister([String? instance]) async {
instance ??= await ref final clientState = ref.read(ClientStateController.provider);
.read(GomuksListenerController.provider.notifier)
.deviceId();
final keyProvider = PushKeyController.provider(instance); final keyProvider = PushKeyController.provider(
instance ?? clientState!.deviceId!,
);
final key = await ref.read(keyProvider.future); final key = await ref.read(keyProvider.future);
if (key != null) { if (key != null) {
@ -245,7 +223,7 @@ class UnifiedPushController extends AsyncNotifier<String?> {
); );
} }
await UnifiedPush.unregister(instance); await UnifiedPush.unregister(instance ?? clientState!.deviceId!);
state = .data(null); state = .data(null);
} }

View file

@ -1,34 +0,0 @@
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<void> 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());
}
}
}

View file

@ -1,93 +0,0 @@
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<bool> 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<String?> 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<XFile?> 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>(FileDialogHelper.new);
}

View file

@ -1,9 +1,5 @@
import "dart:async"; import "dart:ui";
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/widgets.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
@ -19,19 +15,13 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request)
ImageStreamCompleter loadImage(MxcImage key, ImageDecoderCallback decode) => ImageStreamCompleter loadImage(MxcImage key, ImageDecoderCallback decode) =>
MultiFrameImageStreamCompleter(codec: _loadAsync(decode), scale: 1.0); MultiFrameImageStreamCompleter(codec: _loadAsync(decode), scale: 1.0);
Future<ui.Codec> _loadAsync(ImageDecoderCallback decode) async { Future<Codec> _loadAsync(ImageDecoderCallback decode) async {
final file = await ref final file = await ref
.read(ClientController.provider.notifier) .read(ClientController.provider.notifier)
.downloadMedia(request); .downloadMedia(request);
final bytes = await File(file.path).readAsBytes(); final buffer = await ImmutableBuffer.fromFilePath(file.path);
// Flutter can't decode AVIF on Linux/Windows (or older Android/iOS), so we do it ourselves return decode(buffer);
if (String.fromCharCodes(bytes.skip(4).take(8))
case "ftypavif" || "ftypavis") {
return AvifCodec(await Isolate.run(() => decodeAvif(bytes)));
}
return decode(await .fromUint8List(bytes));
} }
@override @override
@ -41,37 +31,3 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request)
@override @override
int get hashCode => request.hashCode; 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<ui.FrameInfo> getNextFrame() {
final frame = data.frames[_frameIndex++ % frameCount];
final completer = Completer<ui.FrameInfo>();
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;

View file

@ -1,23 +0,0 @@
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<int, Event> events,
}) with _$EventsDecrypted {
factory EventsDecrypted.fromJson(Map<String, Object?> json) =>
_$EventsDecryptedFromJson(json);
}

View file

@ -1,44 +0,0 @@
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<String, Object?> 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<String, Object?> json) =>
_$NotificationUserFromJson(json);
}

View file

@ -13,6 +13,8 @@ class const RegisterPusherRequest({
required final String deviceDisplayName, required final String deviceDisplayName,
required final PusherKind kind, required final PusherKind kind,
required final String lang, required final String lang,
/// TODO: What does this do?
final String? profileTag, final String? profileTag,
@JsonKey(name: "pushkey") required final String pushKey, @JsonKey(name: "pushkey") required final String pushKey,

View file

@ -13,7 +13,6 @@ class const Settings({
final bool linuxMobileMode = false, final bool linuxMobileMode = false,
final bool showAllInHome = false, final bool showAllInHome = false,
final bool showMinimizeButton = false, final bool showMinimizeButton = false,
final bool swipeToReply = false,
final RoomOrder roomOrder = .unreadFirst, final RoomOrder roomOrder = .unreadFirst,
}) with _$Settings { }) with _$Settings {
Map<String, Object?> toJson() => _$SettingsToJson(this); Map<String, Object?> toJson() => _$SettingsToJson(this);

View file

@ -1,6 +1,7 @@
import "dart:io"; import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; 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/controllers/rooms.dart";
import "package:nexus/widgets/emoji_picker.dart"; import "package:nexus/widgets/emoji_picker.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
@ -12,7 +13,6 @@ import "package:fluttertagger/fluttertagger.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/attachment.dart"; import "package:nexus/controllers/attachment.dart";
import "package:nexus/controllers/image_picker.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:image_picker/image_picker.dart";
import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/power_level.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
@ -245,24 +245,14 @@ class const Composer(
leading: Icon(icon), leading: Icon(icon),
), ),
onTap: () => attach( onTap: () => attach(
source == .gallery ref
? ref .read(ImagePickerController.provider)
.read(FileDialogHelper.provider)
.pickFile(images: true)
: ref
.read(
ImagePickerController
.provider,
)
.pickImage(source: source), .pickImage(source: source),
).onError(showError), ).onError(showError),
), ),
PopupMenuItem( PopupMenuItem(
onTap: () => attach( onTap: () =>
ref attach(openFile()).onError(showError),
.read(FileDialogHelper.provider)
.pickFile(),
).onError(showError),
child: ListTile( child: ListTile(
title: Text("Files"), title: Text("Files"),
leading: Icon(Icons.attachment), leading: Icon(Icons.attachment),

View file

@ -1,7 +1,6 @@
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:m3e_buttons/m3e_buttons.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/helpers/mxc_image.dart";
import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/download_media.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
@ -9,9 +8,6 @@ import "package:nexus/widgets/error_dialog.dart";
final class const ExpandableImage( final class const ExpandableImage(
final DownloadMediaRequest? request, { final DownloadMediaRequest? request, {
required final Widget child, required final Widget child,
/// If set, a download button is shown, suggesting this filename
final String? downloadFilename,
super.key, super.key,
}) extends ConsumerWidget { }) extends ConsumerWidget {
@override @override
@ -42,26 +38,10 @@ final class const ExpandableImage(
alignment: .topRight, alignment: .topRight,
child: Padding( child: Padding(
padding: .all(32), padding: .all(32),
child: Row( child: M3EButton(
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, onPressed: Navigator.of(context).pop,
child: Icon(Icons.close), child: Icon(Icons.close),
), ),
],
),
), ),
), ),
], ],

View file

@ -1,6 +1,4 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.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/helpers/extensions/size_to_string.dart";
import "package:nexus/models/info/file.dart"; import "package:nexus/models/info/file.dart";
@ -8,11 +6,10 @@ final class const FileCard(
final Uri uri, final Uri uri,
final FileInfo? info, { final FileInfo? info, {
final String? filename, final String? filename,
final bool encrypted = false,
super.key, super.key,
}) extends ConsumerWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => SizedBox( Widget build(BuildContext context) => SizedBox(
width: 320, width: 320,
child: Card( child: Card(
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.surfaceContainer,
@ -20,15 +17,8 @@ final class const FileCard(
leading: Icon(Icons.file_copy), leading: Icon(Icons.file_copy),
title: Text(filename ?? "file", maxLines: 1, overflow: .ellipsis), title: Text(filename ?? "file", maxLines: 1, overflow: .ellipsis),
subtitle: info?.size == null ? null : Text(info!.size!.sizeAsString), subtitle: info?.size == null ? null : Text(info!.size!.sizeAsString),
trailing: IconButton( // TODO: Downloading files
onPressed: () => context.saveAttachment( trailing: IconButton(onPressed: null, icon: Icon(Icons.download)),
ref,
uri,
encrypted: encrypted,
filename: filename,
),
icon: Icon(Icons.download),
),
), ),
), ),
); );

View file

@ -79,7 +79,6 @@ class const Html(
alignment: PlaceholderAlignment.middle, alignment: PlaceholderAlignment.middle,
child: ExpandableImage( child: ExpandableImage(
.new(mxc: src), .new(mxc: src),
downloadFilename: src.pathSegments.last,
child: Image( child: Image(
image: MxcImage(ref, .new(mxc: src)), image: MxcImage(ref, .new(mxc: src)),
errorBuilder: (_, error, _) => Text( errorBuilder: (_, error, _) => Text(

View file

@ -43,6 +43,7 @@ class const MentionChip(final String content, final String? roomId, {super.key})
if (data case MembershipContent membership) { if (data case MembershipContent membership) {
context.showUserPopover(membership, mention, roomId: roomId); context.showUserPopover(membership, mention, roomId: roomId);
} else if (data case RoomSummary summary) { } 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 final eventRowId = linkInfo?.eventId == null
? null ? null
: await ref : await ref

View file

@ -11,7 +11,6 @@ final class const MessageImage(
final Uri url, { final Uri url, {
final i.ImageInfo? info, final i.ImageInfo? info,
required final bool encrypted, required final bool encrypted,
final String? filename,
super.key, super.key,
}) extends ConsumerWidget { }) extends ConsumerWidget {
@override @override
@ -19,7 +18,6 @@ final class const MessageImage(
final request = DownloadMediaRequest(mxc: url, encrypted: encrypted); final request = DownloadMediaRequest(mxc: url, encrypted: encrypted);
return ExpandableImage( return ExpandableImage(
request, request,
downloadFilename: filename,
child: ClipRRect( child: ClipRRect(
borderRadius: .all(.circular(8)), borderRadius: .all(.circular(8)),
child: AspectRatio( child: AspectRatio(

View file

@ -2,7 +2,7 @@ import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/init_complete.dart"; import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/selected_room.dart"; import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/notification_request.dart"; import "package:nexus/controllers/notification_request.dart";
import "package:nexus/helpers/extensions/is_desktop.dart"; import "package:nexus/helpers/extensions/is_desktop.dart";
import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/appbar.dart";
@ -37,14 +37,11 @@ class const ChatPage({super.key}) extends HookConsumerWidget {
builder: (context, constraints) { builder: (context, constraints) {
final isDesktop = context.isDesktop; final isDesktop = context.isDesktop;
final showMembersByDefault = constraints.maxWidth > 1000; final showMembersByDefault = constraints.maxWidth > 1000;
final roomId = ref.watch(SelectedRoomController.provider); final roomId = ref
.watch(KeyController.provider(KeyController.roomKey))
.requireValue;
return PopScope( return SafeArea(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) scaffoldKey.currentState?.openDrawer();
},
child: SafeArea(
child: Scaffold( child: Scaffold(
key: scaffoldKey, key: scaffoldKey,
appBar: initComplete ? null : Appbar(), appBar: initComplete ? null : Appbar(),
@ -77,7 +74,6 @@ class const ChatPage({super.key}) extends HookConsumerWidget {
), ),
drawer: isDesktop || !initComplete ? null : Sidebar(), drawer: isDesktop || !initComplete ? null : Sidebar(),
), ),
),
); );
}, },
); );

View file

@ -5,7 +5,6 @@ import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:media_kit/media_kit.dart"; import "package:media_kit/media_kit.dart";
import "package:nexus/controllers/client.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/models/info/audio.dart";
import "package:nexus/widgets/players/player_controls.dart"; import "package:nexus/widgets/players/player_controls.dart";
@ -13,7 +12,6 @@ class const AudioPlayer(
final Uri uri, final Uri uri,
final AudioInfo? info, { final AudioInfo? info, {
final bool encrypted = false, final bool encrypted = false,
final String? filename,
super.key, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@override @override
@ -40,24 +38,8 @@ class const AudioPlayer(
child: Card( child: Card(
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.surfaceContainer,
child: Padding( child: Padding(
padding: .symmetric(horizontal: 8), padding: .only(left: 8, right: 16),
child: PlayerControls( child: PlayerControls(player, loading: !loaded.value),
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),
),
]),
),
), ),
), ),
); );

View file

@ -3,7 +3,6 @@ import "dart:async";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/helpers/extensions/save_attachment.dart";
import "package:nexus/models/info/video.dart"; import "package:nexus/models/info/video.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:media_kit/media_kit.dart"; import "package:media_kit/media_kit.dart";
@ -14,7 +13,6 @@ class const VideoPlayer(
final Uri uri, final Uri uri,
final VideoInfo? info, { final VideoInfo? info, {
final bool encrypted = false, final bool encrypted = false,
final String? filename,
super.key, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@override @override
@ -63,24 +61,6 @@ class const VideoPlayer(
onTap: loaded.value ? player.playOrPause : null, 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( Positioned(
left: 0, left: 0,
right: 0, right: 0,

View file

@ -53,12 +53,6 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
ref, ref,
.new(mxc: Uri.parse(reaction)), .new(mxc: Uri.parse(reaction)),
), ),
errorBuilder: (context, _, _) => Icon(
Icons.broken_image,
color: Theme.of(context)
.colorScheme
.error,
),
) )
: Text(reaction, overflow: .ellipsis), : Text(reaction, overflow: .ellipsis),
), ),

View file

@ -29,7 +29,6 @@ import "package:nexus/widgets/renderers/message.dart";
import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/reaction_row.dart";
import "package:nexus/widgets/renderers/membership.dart"; import "package:nexus/widgets/renderers/membership.dart";
import "package:nexus/widgets/renderers/generic_event.dart"; import "package:nexus/widgets/renderers/generic_event.dart";
import "package:nexus/widgets/swipe_to_reply.dart";
import "package:nexus/widgets/timestamp.dart"; import "package:nexus/widgets/timestamp.dart";
class const EventRenderer( class const EventRenderer(
@ -40,9 +39,6 @@ class const EventRenderer(
final int? maxLines, final int? maxLines,
final JumpToEvent? jumpToEvent, final JumpToEvent? jumpToEvent,
final IList<PopupMenuEntry> Function(Event event)? getEventOptions, final IList<PopupMenuEntry> Function(Event event)? getEventOptions,
/// If set, events can be swiped, calling this
final VoidCallback? onSwipe,
super.key, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@override @override
@ -243,9 +239,7 @@ class const EventRenderer(
child: GestureDetector( child: GestureDetector(
onSecondaryTapUp: contextMenuCallback, onSecondaryTapUp: contextMenuCallback,
onLongPressStart: contextMenuCallback, onLongPressStart: contextMenuCallback,
child: SwipeToReply( child: Padding(
onSwipe: onSwipe,
Padding(
padding: EdgeInsets.symmetric(horizontal: 8) padding: EdgeInsets.symmetric(horizontal: 8)
.copyWith(top: isGrouped ? 0 : 8), .copyWith(top: isGrouped ? 0 : 8),
child: Wrap( child: Wrap(
@ -263,7 +257,6 @@ class const EventRenderer(
), ),
), ),
), ),
),
...[ ...[
if (child is! MessageRenderer) ReactionRow(event), if (child is! MessageRenderer) ReactionRow(event),

View file

@ -110,53 +110,14 @@ class const MessageRenderer(
ConstrainedBox( ConstrainedBox(
constraints: .loose(.square(500)), constraints: .loose(.square(500)),
child: switch (event.content) { child: switch (event.content) {
VideoMessageContent( VideoMessageContent(:final info, :final file) =>
:final info, VideoPlayer(url, info, encrypted: file != null),
:final file, AudioMessageContent(:final info, :final file) =>
:final filename, AudioPlayer(url, info, encrypted: file != null),
:final body, FileMessageContent(:final info, :final filename) =>
) => FileCard(url, info, filename: filename),
VideoPlayer( ImageMessageContent(:final info, :final file) =>
url, MessageImage(url, info: info, encrypted: file != null),
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(), _ => SizedBox.shrink(),
}, },
), ),

View file

@ -5,7 +5,6 @@ import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/main.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/recovery_key_sheet.dart";
import "package:nexus/widgets/sheet_layout.dart"; import "package:nexus/widgets/sheet_layout.dart";
@ -17,12 +16,11 @@ class const ResetEncryptionSheet({
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final verificationState = ref.watch( final isReset = ref.watch(
ClientStateController.provider.select( ClientStateController.provider.select(
(state) => state?.verificationState, (state) => state?.verificationState.hasCrossSigning ?? false,
), ),
); );
final isReset = verificationState?.hasCrossSigning ?? false;
final usePassphrase = useState(false); final usePassphrase = useState(false);
final passphraseController = useTextEditingController(); final passphraseController = useTextEditingController();
@ -97,13 +95,6 @@ class const ResetEncryptionSheet({
), ),
], ],
children: [ 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( Text(
isReset 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." ? "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."

View file

@ -13,7 +13,6 @@ class const ChatTimeline({
required final IList<PopupMenuEntry> Function(Event) getEventOptions, required final IList<PopupMenuEntry> Function(Event) getEventOptions,
required final int? highlightedEvent, required final int? highlightedEvent,
required final double composerHeight, required final double composerHeight,
final void Function(int rowId)? onSwipe,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
Widget eventRow( Widget eventRow(
@ -27,7 +26,6 @@ class const ChatTimeline({
roomId: roomId, roomId: roomId,
jumpToEvent: jumpToEvent, jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId),
), ),
key: key, key: key,
isHighlighted: highlightedEvent == eventRowId, isHighlighted: highlightedEvent == eventRowId,

View file

@ -7,8 +7,6 @@ import "package:measure_size/measure_size.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/member_list_opened.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/rooms.dart";
import "package:nexus/controllers/room_chat.dart"; import "package:nexus/controllers/room_chat.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart";
@ -173,28 +171,17 @@ final class const RoomChat({
return null; return null;
}, [jumpRowId]); }, [jumpRowId]);
void onRelation(int rowId, RelationType type) {
relatedEvent.value = rowId;
relationType.value = type;
composerNode.requestFocus();
}
IList<PopupMenuEntry> getEventOptions(Event event) => IList<PopupMenuEntry> getEventOptions(Event event) =>
event.buildEventOptions( event.buildEventOptions(
context: context, context: context,
ref: ref, ref: ref,
roomId: roomId, roomId: roomId,
userId: userId, userId: userId,
onRelation: onRelation, onRelation: (rowId, type) {
); relatedEvent.value = rowId;
relationType.value = type;
final swipeToReply = ref.watch( composerNode.requestFocus();
SettingsController.provider.select( },
(settings) => settings.value?.swipeToReply ?? false,
),
);
final canReply = ref.watch(
PowerLevelController.provider(.new(eventType: .message, roomId: roomId)),
); );
return Scaffold( return Scaffold(
@ -237,9 +224,6 @@ final class const RoomChat({
roomId: roomId, roomId: roomId,
jumpToEvent: jumpToEvent, jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
onSwipe: swipeToReply && canReply
? (rowId) => onRelation(rowId, .reply)
: null,
highlightedEvent: highlightedEvent.value, highlightedEvent: highlightedEvent.value,
composerHeight: composerSize.value, composerHeight: composerSize.value,
), ),

View file

@ -22,10 +22,9 @@ import "package:nexus/widgets/room_summary_sheet.dart";
import "package:nexus/widgets/room_menu_button.dart"; import "package:nexus/widgets/room_menu_button.dart";
import "package:nexus/widgets/space_hierarchy_sheet.dart"; import "package:nexus/widgets/space_hierarchy_sheet.dart";
import "package:nexus/controllers/key.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/rooms.dart";
import "package:nexus/controllers/spaces.dart"; import "package:nexus/controllers/spaces.dart";
import "package:collection/collection.dart";
// Needed for navigation_rail_m3e (#65). // Needed for navigation_rail_m3e (#65).
import "package:flutter/material.dart" as old_mat; import "package:flutter/material.dart" as old_mat;
@ -218,8 +217,23 @@ class const SpacesRail({super.key}) extends ConsumerWidget {
class const RoomsPane({super.key}) extends ConsumerWidget { class const RoomsPane({super.key}) extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final selectedRoomId = ref.watch(SelectedRoomController.provider); final selectedSpaceId = ref.watch(
final selectedSpace = ref.watch(SelectedSpaceController.provider)!; 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 isDm = selectedSpace.id == "dms"; final isDm = selectedSpace.id == "dms";
final spaceMetadata = selectedSpace.room?.metadata; final spaceMetadata = selectedSpace.room?.metadata;

View file

@ -30,7 +30,6 @@ class const RoomSummarySheet(
if (summary.avatarUrl != null) if (summary.avatarUrl != null)
ExpandableImage( ExpandableImage(
summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!), summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!),
downloadFilename: name,
child: AvatarOrHash( child: AvatarOrHash(
summary.avatarUrl, summary.avatarUrl,
"", "",

View file

@ -4,24 +4,40 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/settings/radio_sheet.dart"; import "package:nexus/widgets/settings/radio_sheet.dart";
final class const DialogListTile<T>({ final class const DialogListTile<T>({
required final T? value, required final T? initialValue,
required final Icon icon, required final Icon icon,
required final String title, required final String title,
required final List<T> options, required final List<T> options,
required final void Function(T value)? onChanged, required final void Function(T value)? onChanged,
required final String Function(T option) getName, required final String Function(T option) getName,
final Widget? subtitle, final Widget? subtitle,
final bool required = true,
super.key, super.key,
}) extends ConsumerWidget { }) extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => ListTile( 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,
),
child: ListTile(
enabled: onChanged != null, enabled: onChanged != null,
onTap: () => context.showAdaptiveSheet( onTap: () => context.showAdaptiveSheet(
(context) => RadioSheet<T>( (context) => RadioSheet<T>(
title: title, title: title,
getName: getName, getName: getName,
onChanged: onChanged, onChanged: onChanged == null
value: value, ? null
: (value) {
field.didChange(value);
onChanged!.call(value);
},
value: field.value,
options: options, options: options,
), ),
), ),
@ -30,10 +46,12 @@ final class const DialogListTile<T>({
leading: icon, leading: icon,
trailing: Chip( trailing: Chip(
label: Text( label: Text(
value == null ? "None" : getName(value as T), field.value == null ? "None" : getName(field.value as T),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: onChanged == null ? .new(color: Colors.grey) : null, style: onChanged == null ? .new(color: Colors.grey) : null,
), ),
), ),
),
),
); );
} }

View file

@ -236,8 +236,6 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
if (space?.avatarUrl case final avatarUrl?) if (space?.avatarUrl case final avatarUrl?)
ExpandableImage( ExpandableImage(
.new(mxc: avatarUrl), .new(mxc: avatarUrl),
downloadFilename:
space?.name ?? space?.canonicalAlias ?? spaceId,
child: AvatarOrHash(avatarUrl, "", dimension: 32), child: AvatarOrHash(avatarUrl, "", dimension: 32),
), ),
Expanded( Expanded(

View file

@ -1,58 +0,0 @@
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,
),
],
),
);
}
}

View file

@ -103,7 +103,6 @@ final class const UserBottomSheet(
ExpandableImage( ExpandableImage(
member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
downloadFilename: member.displayName ?? userId.localpart,
child: AvatarOrHash( child: AvatarOrHash(
member.avatarUrl, member.avatarUrl,
member.displayName ?? userId.localpart, member.displayName ?? userId.localpart,

View file

@ -1,49 +0,0 @@
# 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
'';
}

View file

@ -12,7 +12,7 @@
flutter.buildFlutterApplication { flutter.buildFlutterApplication {
pname = "nexus"; pname = "nexus";
version = "1.0.0-alpha4"; version = "1.0.0-alpha2";
inherit src; inherit src;
preBuild = '' preBuild = ''
@ -34,8 +34,6 @@ flutter.buildFlutterApplication {
autoPubspecLock = src + "/pubspec.lock"; autoPubspecLock = src + "/pubspec.lock";
customSourceBuilders.avif_native = callPackage ./avif_native.nix { };
gitHashes = { gitHashes = {
linkify = "sha256-TpMD6+0zyY6i9l+6d8ErnVufmepCv362rCtnbOht/z4="; linkify = "sha256-TpMD6+0zyY6i9l+6d8ErnVufmepCv362rCtnbOht/z4=";
fab_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; fab_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI=";

View file

@ -17,7 +17,7 @@ buildGoModule (
src = "${src}/gomuks"; src = "${src}/gomuks";
vendorHash = "sha256-nd0NEKXscgrFE/gBEW2aP+YeZNTOdbRa4QDLTRwIAZU="; vendorHash = "sha256-+0iFK2ribhiNskXgPz7vslncuOgPMB/ZmFSfDOcz6ww=";
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild

View file

@ -105,14 +105,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" 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: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -463,14 +455,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.1" 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: flutter_hooks:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1001,7 +985,7 @@ packages:
source: hosted source: hosted
version: "1.19.0" version: "1.19.0"
mime: mime:
dependency: "direct main" dependency: transitive
description: description:
name: mime name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6

View file

@ -1,6 +1,6 @@
name: nexus name: nexus
description: "A simple and user-friendly Matrix client" description: "A simple and user-friendly Matrix client"
version: 1.0.0-alpha4 version: 1.0.0-alpha2
publish_to: none publish_to: none
flutter: flutter:
@ -107,9 +107,7 @@ dependencies:
url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart
ref: quad/nexus ref: quad/nexus
pasteboard: 0.5.0 pasteboard: 0.5.0
flutter_file_dialog: 3.3.3
mime: 2.1.0
avif_native: 0.1.1
dev_dependencies: dev_dependencies:
build_runner: 2.16.2 build_runner: 2.16.2
flutter_lints: 6.0.0 flutter_lints: 6.0.0
@ -133,8 +131,3 @@ flutter_launcher_icons:
macos: macos:
generate: true generate: true
image_path: assets/mobile.png image_path: assets/mobile.png
hooks:
user_defines:
avif_native:
features: decode