Compare commits

..
Author SHA1 Message Date
deac9ef24e
handle permission denials more gracefully 2026-10-18 16:36:36 -04:00
e24e99fd8e
slight improvements to windows and darwin notifs 2026-10-18 16:14:00 -04:00
4580110da0
update gomuks vendorHash 2026-10-18 16:04:56 -04:00
d1724932b2
clean up and improve notifications
Thanks to Gomuks' new handling, we have sender displayname and avatar, and can use android's conversation notifications.

On other platforms, we just append sender name to the body, bolded if possible.

This also improves the logic on when to send notifications, as we drop notifs where `response["notification"]` is null.

We also now correctly set if notifications should play a sound or not, based on `PushNotification.sound`.

`PushNotification.image` handling might come sometime in the future, but it seems hard on Android.
2026-10-18 16:01:37 -04:00
a6fe80fea2
split APKs per abi and only upload arm64 APK 2026-10-17 16:19:27 -04:00
9f45cbc1e7
add warning when user has cross signing but not secret storage 2026-10-17 16:12:10 -04:00
eca2b0b558
Revert "override webcrypto to a version with build hooks"
This reverts commit 4e6ba571fc.

Due to an upstream bug, this broke Linux aarch64 builds, as well as MacOS builds.
2026-10-17 11:53:23 -04:00
4e6ba571fc
override webcrypto to a version with build hooks 2026-10-17 11:37:51 -04:00
200e03f082
noheic always 2026-10-17 11:21:20 -04:00
8180338d99
use another avif library that works on aarch64-linux 2026-10-17 11:18:23 -04:00
72c5e7d819
add errorBuilder to reactions 2026-10-17 10:24:30 -04:00
254b7e134f
release v1.0.0-alpha4 2026-10-16 22:14:15 -04:00
ef25e765d4
handle events_decrypted 2026-10-16 22:12:15 -04:00
58a27fdd9e
tiny cleanup in avif decoding 2026-10-16 22:00:00 -04:00
6d42477041
add support for avif images
Fixes #138
2026-10-16 20:35:42 -04:00
2f17f5b6c6
add swipe to reply
Fixes #90
2026-10-16 18:08:42 -04:00
25 changed files with 573 additions and 143 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 --dart-define=GIT_COMMIT=${{ github.sha }}"
run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64 --split-per-abi --dart-define=GIT_COMMIT=${{ github.sha }}"
env:
KEYSTORE_PATH: ../../keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
@ -36,4 +36,4 @@ jobs:
uses: actions/upload-artifact@v6
with:
name: APK
path: build/app/outputs/flutter-apk/app-release.apk
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk

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-alpha3";
url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0-alpha4";
```
Then import the module and enable it in your NixOS configuration:

2
gomuks

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

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
if (targetOS == OS.android || targetOS == OS.iOS) "noheic",
"noheic",
].join(",");
final archiveFile = targetOS == OS.iOS

View file

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

View file

@ -17,6 +17,7 @@ import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/main.dart";
import "package:ffi/ffi.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/events_decrypted.dart";
import "package:nexus/models/sync_data.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
@ -60,6 +61,29 @@ class GomuksListenerController extends AsyncNotifier<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;
@ -119,6 +143,19 @@ 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,4 +1,5 @@
import "dart:async";
import "dart:convert";
import "dart:io";
import "package:flutter/services.dart";
@ -103,24 +104,75 @@ class NotificationController
return true;
}
/// Whether notifications can currently be shown, without prompting
Future<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.watch(PortalController.provider.future);
final portal = await ref.read(PortalController.provider.future);
const escape = HtmlEscape(HtmlEscapeMode.element);
await portal.notification.addNotification(
id.toString(),
title: title,
body: body,
body: prefix == null || body == null ? body : "$prefix: $body",
markupBody: prefix == null || body == null
? null
: "<b>${escape.convert(prefix)}</b>: ${escape.convert(body)}",
defaultAction: "app.event",
defaultActionTarget: payload,
sound: sound ? null : const XdgNotificationSoundSilent(),
category: XdgNotificationCategory.imReceived,
priority: .normal,
buttons: [
@ -133,25 +185,64 @@ class NotificationController
],
);
} else {
final controller = await future;
// Append to the conversation already shown under this id, if any
final existingStyle = Platform.isAndroid
? await controller
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.getActiveNotificationMessagingStyle(id: id)
.onError((_, _) => null)
: null;
final darwin = DarwinNotificationDetails(
presentSound: sound,
threadIdentifier: threadId,
);
final notificationDetails = NotificationDetails(
android: .new(
"messages",
"Messages",
silent: !sound,
largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path),
styleInformation: senderName == null || body == null
? null
: MessagingStyleInformation(
.new(name: selfName ?? "You"),
conversationTitle: isGroup ? title : null,
groupConversation: isGroup,
messages: [
...?existingStyle?.messages,
.new(
body,
timestamp ?? .now(),
.new(
name: senderName,
icon: senderAvatar == null
? null
: BitmapFilePathAndroidIcon(senderAvatar.path),
),
),
],
),
),
iOS: darwin,
macOS: darwin,
windows: .new(
audio: sound ? null : .silent(),
timestamp: timestamp,
header: threadId == null
? null
: .new(id: threadId, title: title, arguments: payload ?? ""),
),
// TODO: See if icons can be added to iOS, macOS, and Windows
// notifications (#68)
iOS: .new(),
macOS: .new(),
windows: .new(),
);
final controller = await future;
await controller.show(
id: id,
title: title,
body: body,
body: prefix == null || body == null ? body : "$prefix: $body",
notificationDetails: notificationDetails,
payload: payload,
);

View file

@ -46,7 +46,7 @@ class SettingsSectionsController
icon: Icon(icon),
title: title,
subtitle: Text(description),
initialValue: settings.theme,
value: settings.theme,
options: ThemeMode.values,
getName: (option) => toBeginningOfSentenceCase(option.name),
onChanged: (value) => ref
@ -105,7 +105,7 @@ class SettingsSectionsController
icon: Icon(icon),
title: title,
subtitle: Text(description),
initialValue: settings.roomOrder,
value: settings.roomOrder,
options: RoomOrder.values,
getName: (option) => switch (option) {
.unreadFirst => "Mentioned, then unread",
@ -117,6 +117,21 @@ class SettingsSectionsController
.onError(showError),
),
),
.new(
title: "Swipe To Reply",
description: "Swipe a message to the right to reply to it.",
icon: Icons.swipe_right,
builder: (title, description, icon) => SwitchListTile(
title: Text(title),
subtitle: Text(description),
secondary: Icon(icon),
value: settings.swipeToReply,
onChanged: (value) => ref
.read(SettingsController.provider.notifier)
.set(settings.copyWith(swipeToReply: value))
.onError(showError),
),
),
if (!Platform.isAndroid && !Platform.isIOS)
.new(
title: "Show Minimize Button",
@ -179,7 +194,6 @@ class SettingsSectionsController
final current = pusherRegistered.value;
return DialogListTile<String?>(
key: ValueKey(current),
title: title,
subtitle: Text(
unifiedPushAllowed.maybeWhen(
@ -189,8 +203,7 @@ class SettingsSectionsController
description,
),
icon: Icon(icon),
required: false,
initialValue: current,
value: current,
options: [
null,
...?distributors.value,
@ -212,21 +225,32 @@ class SettingsSectionsController
try {
loading.value = true;
if (value != null) {
if (await ref
final messenger = ScaffoldMessenger.of(
context,
);
if (!await ref
.read(
NotificationController
.provider
.notifier,
)
.requestPermissions()) {
await ref
.read(
UnifiedPushController
.provider
.notifier,
)
.register(value);
messenger.showSnackBar(
.new(
content: Text(
"Notifications are disabled. Enable them for Nexus in your system settings.",
),
),
);
return;
}
await ref
.read(
UnifiedPushController
.provider
.notifier,
)
.register(value);
} else {
await ref
.read(
@ -268,7 +292,7 @@ class SettingsSectionsController
icon: Icon(icon),
title: title,
subtitle: Text(description),
initialValue: ref
value: ref
.watch(AccountDataController.provider)
.invitePermissionConfig
.defaultAction,

View file

@ -10,9 +10,7 @@ import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/push_key.dart";
import "package:nexus/main.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/sticker.dart";
import "package:nexus/controllers/gomuks_listener.dart";
import "package:unifiedpush/unifiedpush.dart";
import "package:unifiedpush_storage_shared_preferences/storage.dart";
import "package:window_manager/window_manager.dart";
@ -74,47 +72,67 @@ 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 pushResponse = await ref
final notification = await ref
.read(ClientController.provider.notifier)
.handlePush(json.decode(String.fromCharCodes(message.content)));
if (pushResponse == null) return;
final (event, roomMetadata) = pushResponse;
if (event.unreadType?.has(.notify) != true ||
if (notification == null ||
(!isInBackground &&
await windowManager.isFocused().onError((_, _) => true) &&
ref.read(SelectedRoomController.provider) == event.roomId)) {
ref.read(SelectedRoomController.provider) ==
notification.roomId)) {
if (isInBackground) exit(0);
return;
}
final icon = roomMetadata.avatar == null
final client = ref.read(ClientController.provider.notifier);
final icon = notification.roomAvatar == null
? null
: await ref
.read(ClientController.provider.notifier)
.downloadMedia(
.new(mxc: roomMetadata.avatar!, isAvatar: true),
);
: await client.downloadMedia(
.new(mxc: notification.roomAvatar!, isAvatar: true),
);
final pmp = notification.perMessageProfile;
final senderAvatarUrl = pmp?.avatar ?? notification.sender.avatar;
// Only Android can show the sender's avatar
final senderAvatar = senderAvatarUrl == null || !Platform.isAndroid
? null
: await client.downloadMedia(
.new(mxc: senderAvatarUrl, isAvatar: true),
);
await ref
.read(NotificationController.provider.notifier)
.send(
id: event.eventId.hashCode & 0x7fffffff,
title: roomMetadata.name ?? "New Event",
// Android stacks messages into one conversation per room
id:
(Platform.isAndroid
? notification.roomId
: notification.eventId)
.hashCode &
0x7fffffff,
title: notification.roomName,
icon: icon,
payload: event.rowId.toString(),
body: switch (event.content) {
MessageContent(:final body?) ||
StickerContent(:final body) => body,
_ => null,
},
payload: notification.eventRowId.toString(),
body: notification.text,
senderName: pmp?.name ?? notification.sender.name,
senderAvatar: senderAvatar,
selfName: notification.self.name,
isGroup: !notification.isDm,
sound: notification.sound,
timestamp: notification.timestamp,
threadId: notification.roomId,
);
if (isInBackground) exit(0);
@ -122,9 +140,11 @@ class UnifiedPushController extends AsyncNotifier<String?> {
onUnregistered: deregister,
);
if (!registered) return null;
if (!registered || isInBackground) return null;
try {
if (await deregisterIfDisabled()) return null;
return await register();
} catch (error, stackTrace) {
showError(error, stackTrace);
@ -143,19 +163,9 @@ class UnifiedPushController extends AsyncNotifier<String?> {
state = .loading();
try {
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,
);
final deviceId = await ref
.read(GomuksListenerController.provider.notifier)
.deviceId();
await Future(() async {
final capabilities = await ref
@ -201,12 +211,27 @@ class UnifiedPushController extends AsyncNotifier<String?> {
}
}
Future<void> deregister([String? instance]) async {
final clientState = ref.read(ClientStateController.provider);
Future<bool> deregisterIfDisabled([String? notificationInstance]) async {
if (await ref
.read(NotificationController.provider.notifier)
.permissionsGranted()) {
return false;
}
final keyProvider = PushKeyController.provider(
instance ?? clientState!.deviceId!,
);
final instance =
notificationInstance ??
await ref.read(GomuksListenerController.provider.notifier).deviceId();
final key = await ref.read(PushKeyController.provider(instance).future);
if (key != null) await deregister(instance);
return true;
}
Future<void> deregister([String? instance]) async {
instance ??= await ref
.read(GomuksListenerController.provider.notifier)
.deviceId();
final keyProvider = PushKeyController.provider(instance);
final key = await ref.read(keyProvider.future);
if (key != null) {
@ -220,7 +245,7 @@ class UnifiedPushController extends AsyncNotifier<String?> {
);
}
await UnifiedPush.unregister(instance ?? clientState!.deviceId!);
await UnifiedPush.unregister(instance);
state = .data(null);
}

View file

@ -1,5 +1,9 @@
import "dart:ui";
import "dart:async";
import "dart:io";
import "dart:isolate";
import "dart:ui" as ui;
import "package:avif_native/avif_native.dart";
import "package:flutter/widgets.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
@ -15,13 +19,19 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request)
ImageStreamCompleter loadImage(MxcImage key, ImageDecoderCallback decode) =>
MultiFrameImageStreamCompleter(codec: _loadAsync(decode), scale: 1.0);
Future<Codec> _loadAsync(ImageDecoderCallback decode) async {
Future<ui.Codec> _loadAsync(ImageDecoderCallback decode) async {
final file = await ref
.read(ClientController.provider.notifier)
.downloadMedia(request);
final buffer = await ImmutableBuffer.fromFilePath(file.path);
final bytes = await File(file.path).readAsBytes();
return decode(buffer);
// Flutter can't decode AVIF on Linux/Windows (or older Android/iOS), so we do it ourselves
if (String.fromCharCodes(bytes.skip(4).take(8))
case "ftypavif" || "ftypavis") {
return AvifCodec(await Isolate.run(() => decodeAvif(bytes)));
}
return decode(await .fromUint8List(bytes));
}
@override
@ -31,3 +41,37 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request)
@override
int get hashCode => request.hashCode;
}
class AvifCodec(final AvifImageData data) implements ui.Codec {
var _frameIndex = 0;
@override
int get frameCount => data.frames.length;
// libavif uses -2 for unknown, which we treat as infinite like browsers do
@override
int get repetitionCount =>
data.repetitionCount < 0 ? -1 : data.repetitionCount;
@override
Future<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

@ -0,0 +1,23 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/epoch_date_time_converter.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/room.dart";
part "events_decrypted.freezed.dart";
part "events_decrypted.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
class const EventsDecrypted({
required final String roomId,
@JsonKey(name: "preview_event_rowid") final int? previewEventRowID,
@EpochDateTimeConverter() final DateTime? sortingTimestamp,
/// [events] is an IMap of eventRowId to event
@JsonKey(fromJson: Room.eventsJsonToIMap)
required final IMap<int, Event> events,
}) with _$EventsDecrypted {
factory EventsDecrypted.fromJson(Map<String, Object?> json) =>
_$EventsDecryptedFromJson(json);
}

View file

@ -0,0 +1,44 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/epoch_date_time_converter.dart";
part "push_notification.freezed.dart";
part "push_notification.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
class const PushNotification({
@EpochDateTimeConverter() required final DateTime timestamp,
required final String eventId,
@JsonKey(name: "event_rowid") required final int eventRowId,
required final String roomId,
required final String roomName,
final Uri? roomAvatar,
final bool isDm = false,
required final NotificationUser sender,
required final NotificationUser self,
final NotificationUser? perMessageProfile,
required final String text,
final Uri? image,
final bool mention = false,
final bool reply = false,
final bool sound = false,
}) with _$PushNotification {
factory PushNotification.fromJson(Map<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,7 @@ class const Settings({
final bool linuxMobileMode = false,
final bool showAllInHome = false,
final bool showMinimizeButton = false,
final bool swipeToReply = false,
final RoomOrder roomOrder = .unreadFirst,
}) with _$Settings {
Map<String, Object?> toJson() => _$SettingsToJson(this);

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/main.dart";
import "package:nexus/models/client_state.dart";
import "package:nexus/widgets/recovery_key_sheet.dart";
import "package:nexus/widgets/sheet_layout.dart";
@ -16,11 +17,12 @@ class const ResetEncryptionSheet({
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isReset = ref.watch(
final verificationState = ref.watch(
ClientStateController.provider.select(
(state) => state?.verificationState.hasCrossSigning ?? false,
(state) => state?.verificationState,
),
);
final isReset = verificationState?.hasCrossSigning ?? false;
final usePassphrase = useState(false);
final passphraseController = useTextEditingController();
@ -95,6 +97,13 @@ class const ResetEncryptionSheet({
),
],
children: [
if (verificationState case VerificationState(
hasCrossSigning: true,
hasSecretStorage: false,
))
const Text(
"Your account has an encryption identity, but no recovery key. If you're still signed in on another client, you can set up a recovery key there instead, then restart Nexus and use it to verify. Otherwise, continue with the reset:",
),
Text(
isReset
? "This replaces your encryption identity with a new one. Your other sessions will need to be verified again, and any messages you can't currently decrypt may become unreadable."

View file

@ -13,6 +13,7 @@ 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(
@ -26,6 +27,7 @@ class const ChatTimeline({
roomId: roomId,
jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions,
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId),
),
key: key,
isHighlighted: highlightedEvent == eventRowId,

View file

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

View file

@ -4,53 +4,35 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/settings/radio_sheet.dart";
final class const DialogListTile<T>({
required final T? initialValue,
required final T? value,
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) => FormField(
validator: (value) =>
value == null && required == true ? "This field is required." : null,
initialValue: initialValue,
builder: (field) => InputDecorator(
decoration: InputDecoration(
errorText: field.errorText,
contentPadding: EdgeInsets.zero,
enabledBorder: InputBorder.none,
Widget build(BuildContext context, WidgetRef ref) => ListTile(
enabled: onChanged != null,
onTap: () => context.showAdaptiveSheet(
(context) => RadioSheet<T>(
title: title,
getName: getName,
onChanged: onChanged,
value: value,
options: options,
),
child: ListTile(
enabled: onChanged != null,
onTap: () => context.showAdaptiveSheet(
(context) => RadioSheet<T>(
title: title,
getName: getName,
onChanged: onChanged == null
? null
: (value) {
field.didChange(value);
onChanged!.call(value);
},
value: field.value,
options: options,
),
),
title: Text(title),
subtitle: subtitle,
leading: icon,
trailing: Chip(
label: Text(
field.value == null ? "None" : getName(field.value as T),
overflow: TextOverflow.ellipsis,
style: onChanged == null ? .new(color: Colors.grey) : null,
),
),
),
title: Text(title),
subtitle: subtitle,
leading: icon,
trailing: Chip(
label: Text(
value == null ? "None" : getName(value as T),
overflow: TextOverflow.ellipsis,
style: onChanged == null ? .new(color: Colors.grey) : null,
),
),
);

View file

@ -0,0 +1,58 @@
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
class const SwipeToReply(
final Widget child, {
required final VoidCallback? onSwipe,
super.key,
}) extends HookWidget {
static const maxDrag = 64.0;
static const threshold = 0.75;
@override
Widget build(BuildContext context) {
final controller = useAnimationController(duration: Durations.short4);
useListenable(controller);
if (onSwipe == null) return child;
final progress = controller.value;
return GestureDetector(
onHorizontalDragUpdate: (details) {
final previous = controller.value;
controller.value = previous + details.primaryDelta! / maxDrag;
if (previous < threshold && controller.value >= threshold) {
HapticFeedback.lightImpact();
}
},
onHorizontalDragEnd: (_) {
if (controller.value >= threshold) onSwipe!();
controller.animateBack(0, curve: Curves.easeOut);
},
onHorizontalDragCancel: () =>
controller.animateBack(0, curve: Curves.easeOut),
child: Stack(
alignment: .centerLeft,
children: [
Positioned(
left: 16,
child: Opacity(
opacity: (progress / threshold).clamp(0, 1),
child: Transform.scale(
scale: progress >= threshold ? 1 : 0.75,
child: const Icon(Icons.reply),
),
),
),
Transform.translate(
offset: .new(progress * maxDrag, 0),
child: child,
),
],
),
);
}
}

View file

@ -0,0 +1,49 @@
# avif_native's build hook downloads its prebuilt library, which the sandbox
# can't do, so bundle it and point the manifest at the local copy instead
{
stdenv,
fetchurl,
jq,
}:
let
artifacts = {
x86_64-linux = {
arch = "x64";
sha256 = "252b064f8ff4f0f265bc44294d4a7ed703dd7199688942c272dd8aa8d3039d5a";
};
aarch64-linux = {
arch = "arm64";
sha256 = "cdcf17cfd18b9b0f965b72615ddf11246121114577dc570d0bba25964e56c726";
};
};
inherit (artifacts.${stdenv.hostPlatform.system}) arch sha256;
key = "linux-${arch}-decode-dav1d";
in
{ version, src, ... }:
stdenv.mkDerivation {
pname = "avif_native";
inherit version src;
inherit (src) passthru;
nativeBuildInputs = [ jq ];
library = fetchurl {
url = "https://github.com/Zhangw1998/avif_native/releases/download/native-v0.1.0/${key}-libavif_native.so.gz";
inherit sha256;
};
# The hook checksums the library, so it must stay byte-identical
dontFixup = true;
installPhase = ''
runHook preInstall
cp -r . $out
chmod -R u+w $out
gunzip -c $library > $out/native/libavif_native.so
jq '.artifacts["${key}"].path = "libavif_native.so"' \
native/manifest.json > $out/native/manifest.json
runHook postInstall
'';
}

View file

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

View file

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

View file

@ -105,6 +105,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.1"
avif_native:
dependency: "direct main"
description:
name: avif_native
sha256: "0a374a7ddc6e48d671792bd33efd9f46f199389cdcd42561210bd886facbc62e"
url: "https://pub.dev"
source: hosted
version: "0.1.1"
boolean_selector:
dependency: transitive
description:

View file

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