Compare commits

..
45 changed files with 227 additions and 957 deletions

View file

@ -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 --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:
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-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:
```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:

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",
"goolm",
// 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(",");
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/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";
@ -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/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,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(
data,
(handle, data) async => GomuksHandlePush(handle, data),
);
return response?["notification"] == null
return response == null
? null
: .fromJson(response["notification"]);
: (
Event.fromJson(response["event"]),
RoomMetadata.fromJson(response["room"]),
);
}
Future<dynamic> _sendCommand(

View file

@ -17,7 +17,6 @@ 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";
@ -61,29 +60,6 @@ class GomuksListenerController extends AsyncNotifier<void> {
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;
@ -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(
int handle,
Pointer<

View file

@ -1,5 +1,4 @@
import "dart:async";
import "dart:convert";
import "dart:io";
import "package:flutter/services.dart";
@ -104,75 +103,24 @@ class NotificationController
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({
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.read(PortalController.provider.future);
const escape = HtmlEscape(HtmlEscapeMode.element);
final portal = await ref.watch(PortalController.provider.future);
await portal.notification.addNotification(
id.toString(),
title: title,
body: prefix == null || body == null ? body : "$prefix: $body",
markupBody: prefix == null || body == null
? null
: "<b>${escape.convert(prefix)}</b>: ${escape.convert(body)}",
body: body,
defaultAction: "app.event",
defaultActionTarget: payload,
sound: sound ? null : const XdgNotificationSoundSilent(),
category: XdgNotificationCategory.imReceived,
priority: .normal,
buttons: [
@ -185,64 +133,25 @@ 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: prefix == null || body == null ? body : "$prefix: $body",
body: body,
notificationDetails: notificationDetails,
payload: payload,
);

View file

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

View file

@ -5,12 +5,14 @@ import "dart:io";
import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.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/push_key.dart";
import "package:nexus/main.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_storage_shared_preferences/storage.dart";
import "package:window_manager/window_manager.dart";
@ -72,67 +74,50 @@ class UnifiedPushController extends AsyncNotifier<String?> {
},
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 notification = await ref
final pushResponse = await ref
.read(ClientController.provider.notifier)
.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 &&
await windowManager.isFocused().onError((_, _) => true) &&
ref.read(SelectedRoomController.provider) ==
notification.roomId)) {
await ref.read(
KeyController.provider(KeyController.roomKey).future,
) ==
event.roomId)) {
if (isInBackground) exit(0);
return;
}
final client = ref.read(ClientController.provider.notifier);
final icon = notification.roomAvatar == null
final icon = roomMetadata.avatar == null
? null
: 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(ClientController.provider.notifier)
.downloadMedia(
.new(mxc: roomMetadata.avatar!, isAvatar: true),
);
await ref
.read(NotificationController.provider.notifier)
.send(
// Android stacks messages into one conversation per room
id:
(Platform.isAndroid
? notification.roomId
: notification.eventId)
.hashCode &
0x7fffffff,
title: notification.roomName,
id: event.eventId.hashCode & 0x7fffffff,
title: roomMetadata.name ?? "New Event",
icon: icon,
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,
payload: event.rowId.toString(),
body: switch (event.content) {
MessageContent(:final body?) ||
StickerContent(:final body) => body,
_ => null,
},
);
if (isInBackground) exit(0);
@ -140,11 +125,9 @@ class UnifiedPushController extends AsyncNotifier<String?> {
onUnregistered: deregister,
);
if (!registered || isInBackground) return null;
if (!registered) return null;
try {
if (await deregisterIfDisabled()) return null;
return await register();
} catch (error, stackTrace) {
showError(error, stackTrace);
@ -163,9 +146,19 @@ class UnifiedPushController extends AsyncNotifier<String?> {
state = .loading();
try {
final deviceId = await ref
.read(GomuksListenerController.provider.notifier)
.deviceId();
final deviceIdCompleter = Completer<String>();
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,
);
await Future(() async {
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 {
instance ??= await ref
.read(GomuksListenerController.provider.notifier)
.deviceId();
final clientState = ref.read(ClientStateController.provider);
final keyProvider = PushKeyController.provider(instance);
final keyProvider = PushKeyController.provider(
instance ?? clientState!.deviceId!,
);
final key = await ref.read(keyProvider.future);
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);
}

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:io";
import "dart:isolate";
import "dart:ui" as ui;
import "dart: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";
@ -19,19 +15,13 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request)
ImageStreamCompleter loadImage(MxcImage key, ImageDecoderCallback decode) =>
MultiFrameImageStreamCompleter(codec: _loadAsync(decode), scale: 1.0);
Future<ui.Codec> _loadAsync(ImageDecoderCallback decode) async {
Future<Codec> _loadAsync(ImageDecoderCallback decode) async {
final file = await ref
.read(ClientController.provider.notifier)
.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
if (String.fromCharCodes(bytes.skip(4).take(8))
case "ftypavif" || "ftypavis") {
return AvifCodec(await Isolate.run(() => decodeAvif(bytes)));
}
return decode(await .fromUint8List(bytes));
return decode(buffer);
}
@override
@ -41,37 +31,3 @@ 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<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 PusherKind kind,
required final String lang,
/// TODO: What does this do?
final String? profileTag,
@JsonKey(name: "pushkey") required final String pushKey,

View file

@ -13,7 +13,6 @@ 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<String, Object?> toJson() => _$SettingsToJson(this);

View file

@ -1,6 +1,7 @@
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";
@ -12,7 +13,6 @@ 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,24 +245,14 @@ class const Composer(
leading: Icon(icon),
),
onTap: () => attach(
source == .gallery
? ref
.read(FileDialogHelper.provider)
.pickFile(images: true)
: ref
.read(
ImagePickerController
.provider,
)
ref
.read(ImagePickerController.provider)
.pickImage(source: source),
).onError(showError),
),
PopupMenuItem(
onTap: () => attach(
ref
.read(FileDialogHelper.provider)
.pickFile(),
).onError(showError),
onTap: () =>
attach(openFile()).onError(showError),
child: ListTile(
title: Text("Files"),
leading: Icon(Icons.attachment),

View file

@ -1,7 +1,6 @@
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";
@ -9,9 +8,6 @@ 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
@ -42,26 +38,10 @@ final class const ExpandableImage(
alignment: .topRight,
child: Padding(
padding: .all(32),
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(
child: M3EButton(
onPressed: Navigator.of(context).pop,
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:nexus/helpers/extensions/save_attachment.dart";
import "package:nexus/helpers/extensions/size_to_string.dart";
import "package:nexus/models/info/file.dart";
@ -8,11 +6,10 @@ final class const FileCard(
final Uri uri,
final FileInfo? info, {
final String? filename,
final bool encrypted = false,
super.key,
}) extends ConsumerWidget {
}) extends StatelessWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => SizedBox(
Widget build(BuildContext context) => SizedBox(
width: 320,
child: Card(
color: Theme.of(context).colorScheme.surfaceContainer,
@ -20,15 +17,8 @@ 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),
trailing: IconButton(
onPressed: () => context.saveAttachment(
ref,
uri,
encrypted: encrypted,
filename: filename,
),
icon: Icon(Icons.download),
),
// TODO: Downloading files
trailing: IconButton(onPressed: null, icon: Icon(Icons.download)),
),
),
);

View file

@ -79,7 +79,6 @@ 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(

View file

@ -43,6 +43,7 @@ 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

View file

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

View file

@ -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/selected_room.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/notification_request.dart";
import "package:nexus/helpers/extensions/is_desktop.dart";
import "package:nexus/widgets/appbar.dart";
@ -37,14 +37,11 @@ class const ChatPage({super.key}) extends HookConsumerWidget {
builder: (context, constraints) {
final isDesktop = context.isDesktop;
final showMembersByDefault = constraints.maxWidth > 1000;
final roomId = ref.watch(SelectedRoomController.provider);
final roomId = ref
.watch(KeyController.provider(KeyController.roomKey))
.requireValue;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) scaffoldKey.currentState?.openDrawer();
},
child: SafeArea(
return SafeArea(
child: Scaffold(
key: scaffoldKey,
appBar: initComplete ? null : Appbar(),
@ -77,7 +74,6 @@ class const ChatPage({super.key}) extends HookConsumerWidget {
),
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: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";
@ -13,7 +12,6 @@ class const AudioPlayer(
final Uri uri,
final AudioInfo? info, {
final bool encrypted = false,
final String? filename,
super.key,
}) extends HookConsumerWidget {
@override
@ -40,24 +38,8 @@ class const AudioPlayer(
child: Card(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Padding(
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),
),
]),
),
padding: .only(left: 8, right: 16),
child: PlayerControls(player, loading: !loaded.value),
),
),
);

View file

@ -3,7 +3,6 @@ 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";
@ -14,7 +13,6 @@ class const VideoPlayer(
final Uri uri,
final VideoInfo? info, {
final bool encrypted = false,
final String? filename,
super.key,
}) extends HookConsumerWidget {
@override
@ -63,24 +61,6 @@ 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,

View file

@ -53,12 +53,6 @@ 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),
),

View file

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

View file

@ -110,53 +110,14 @@ class const MessageRenderer(
ConstrainedBox(
constraints: .loose(.square(500)),
child: switch (event.content) {
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,
),
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),
_ => SizedBox.shrink(),
},
),

View file

@ -5,7 +5,6 @@ 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";
@ -17,12 +16,11 @@ class const ResetEncryptionSheet({
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final verificationState = ref.watch(
final isReset = ref.watch(
ClientStateController.provider.select(
(state) => state?.verificationState,
(state) => state?.verificationState.hasCrossSigning ?? false,
),
);
final isReset = verificationState?.hasCrossSigning ?? false;
final usePassphrase = useState(false);
final passphraseController = useTextEditingController();
@ -97,13 +95,6 @@ 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."

View file

@ -13,7 +13,6 @@ class const ChatTimeline({
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
required final int? highlightedEvent,
required final double composerHeight,
final void Function(int rowId)? onSwipe,
super.key,
}) extends StatelessWidget {
Widget eventRow(
@ -27,7 +26,6 @@ class const ChatTimeline({
roomId: roomId,
jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions,
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId),
),
key: key,
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_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";
@ -173,28 +171,17 @@ final class const RoomChat({
return null;
}, [jumpRowId]);
void onRelation(int rowId, RelationType type) {
relatedEvent.value = rowId;
relationType.value = type;
composerNode.requestFocus();
}
IList<PopupMenuEntry> getEventOptions(Event event) =>
event.buildEventOptions(
context: context,
ref: ref,
roomId: roomId,
userId: userId,
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)),
onRelation: (rowId, type) {
relatedEvent.value = rowId;
relationType.value = type;
composerNode.requestFocus();
},
);
return Scaffold(
@ -237,9 +224,6 @@ final class const RoomChat({
roomId: roomId,
jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions,
onSwipe: swipeToReply && canReply
? (rowId) => onRelation(rowId, .reply)
: null,
highlightedEvent: highlightedEvent.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/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;
@ -218,8 +217,23 @@ class const SpacesRail({super.key}) extends ConsumerWidget {
class const RoomsPane({super.key}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedRoomId = ref.watch(SelectedRoomController.provider);
final selectedSpace = ref.watch(SelectedSpaceController.provider)!;
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 isDm = selectedSpace.id == "dms";
final spaceMetadata = selectedSpace.room?.metadata;

View file

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

View file

@ -4,24 +4,40 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/settings/radio_sheet.dart";
final class const DialogListTile<T>({
required final T? value,
required final T? initialValue,
required final Icon icon,
required final String title,
required final List<T> 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) => 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,
onTap: () => context.showAdaptiveSheet(
(context) => RadioSheet<T>(
title: title,
getName: getName,
onChanged: onChanged,
value: value,
onChanged: onChanged == null
? null
: (value) {
field.didChange(value);
onChanged!.call(value);
},
value: field.value,
options: options,
),
),
@ -30,10 +46,12 @@ final class const DialogListTile<T>({
leading: icon,
trailing: Chip(
label: Text(
value == null ? "None" : getName(value as T),
field.value == null ? "None" : getName(field.value as T),
overflow: TextOverflow.ellipsis,
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?)
ExpandableImage(
.new(mxc: avatarUrl),
downloadFilename:
space?.name ?? space?.canonicalAlias ?? spaceId,
child: AvatarOrHash(avatarUrl, "", dimension: 32),
),
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(
member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
downloadFilename: member.displayName ?? userId.localpart,
child: AvatarOrHash(
member.avatarUrl,
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 {
pname = "nexus";
version = "1.0.0-alpha4";
version = "1.0.0-alpha2";
inherit src;
preBuild = ''
@ -34,8 +34,6 @@ flutter.buildFlutterApplication {
autoPubspecLock = src + "/pubspec.lock";
customSourceBuilders.avif_native = callPackage ./avif_native.nix { };
gitHashes = {
linkify = "sha256-TpMD6+0zyY6i9l+6d8ErnVufmepCv362rCtnbOht/z4=";
fab_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI=";

View file

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

View file

@ -105,14 +105,6 @@ 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:
@ -463,14 +455,6 @@ 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:
@ -1001,7 +985,7 @@ packages:
source: hosted
version: "1.19.0"
mime:
dependency: "direct main"
dependency: transitive
description:
name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6

View file

@ -1,6 +1,6 @@
name: nexus
description: "A simple and user-friendly Matrix client"
version: 1.0.0-alpha4
version: 1.0.0-alpha2
publish_to: none
flutter:
@ -107,9 +107,7 @@ 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
@ -133,8 +131,3 @@ flutter_launcher_icons:
macos:
generate: true
image_path: assets/mobile.png
hooks:
user_defines:
avif_native:
features: decode