Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
deac9ef24e |
|||
|
e24e99fd8e |
|||
|
4580110da0 |
|||
|
d1724932b2 |
|||
|
a6fe80fea2 |
|||
|
9f45cbc1e7 |
|||
|
eca2b0b558 |
|||
|
4e6ba571fc |
|||
|
200e03f082 |
|||
|
8180338d99 |
|||
|
72c5e7d819 |
|||
|
254b7e134f |
|||
|
ef25e765d4 |
|||
|
58a27fdd9e |
|||
|
6d42477041 |
|||
|
2f17f5b6c6 |
25 changed files with 573 additions and 143 deletions
4
.github/workflows/android.yml
vendored
4
.github/workflows/android.yml
vendored
|
|
@ -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 --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:
|
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-release.apk
|
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
||||||
|
|
|
||||||
|
|
@ -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-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:
|
Then import the module and enable it in your NixOS configuration:
|
||||||
|
|
|
||||||
2
gomuks
2
gomuks
|
|
@ -1 +1 @@
|
||||||
Subproject commit ad275f7613894d9d2fa4d16d733a6816e2978de9
|
Subproject commit 7e43a63e578b0d6f52269d65297198077bf5b45d
|
||||||
|
|
@ -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
|
||||||
if (targetOS == OS.android || targetOS == OS.iOS) "noheic",
|
"noheic",
|
||||||
].join(",");
|
].join(",");
|
||||||
|
|
||||||
final archiveFile = targetOS == OS.iOS
|
final archiveFile = targetOS == OS.iOS
|
||||||
|
|
|
||||||
|
|
@ -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/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";
|
||||||
|
|
@ -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/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,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(
|
final response = await callGomuksMethod(
|
||||||
data,
|
data,
|
||||||
(handle, data) async => GomuksHandlePush(handle, data),
|
(handle, data) async => GomuksHandlePush(handle, data),
|
||||||
);
|
);
|
||||||
|
|
||||||
return response == null
|
return response?["notification"] == null
|
||||||
? null
|
? null
|
||||||
: (
|
: .fromJson(response["notification"]);
|
||||||
Event.fromJson(response["event"]),
|
|
||||||
RoomMetadata.fromJson(response["room"]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> _sendCommand(
|
Future<dynamic> _sendCommand(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ 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";
|
||||||
|
|
@ -60,6 +61,29 @@ 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;
|
||||||
|
|
@ -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(
|
static Future<int> _start(
|
||||||
int handle,
|
int handle,
|
||||||
Pointer<
|
Pointer<
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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";
|
||||||
|
|
@ -103,24 +104,75 @@ 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.watch(PortalController.provider.future);
|
final portal = await ref.read(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: 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",
|
defaultAction: "app.event",
|
||||||
defaultActionTarget: payload,
|
defaultActionTarget: payload,
|
||||||
|
sound: sound ? null : const XdgNotificationSoundSilent(),
|
||||||
category: XdgNotificationCategory.imReceived,
|
category: XdgNotificationCategory.imReceived,
|
||||||
priority: .normal,
|
priority: .normal,
|
||||||
buttons: [
|
buttons: [
|
||||||
|
|
@ -133,25 +185,64 @@ 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: body,
|
body: prefix == null || body == null ? body : "$prefix: $body",
|
||||||
notificationDetails: notificationDetails,
|
notificationDetails: notificationDetails,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ class SettingsSectionsController
|
||||||
icon: Icon(icon),
|
icon: Icon(icon),
|
||||||
title: title,
|
title: title,
|
||||||
subtitle: Text(description),
|
subtitle: Text(description),
|
||||||
initialValue: settings.theme,
|
value: 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),
|
||||||
initialValue: settings.roomOrder,
|
value: 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,6 +117,21 @@ 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",
|
||||||
|
|
@ -179,7 +194,6 @@ 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(
|
||||||
|
|
@ -189,8 +203,7 @@ class SettingsSectionsController
|
||||||
description,
|
description,
|
||||||
),
|
),
|
||||||
icon: Icon(icon),
|
icon: Icon(icon),
|
||||||
required: false,
|
value: current,
|
||||||
initialValue: current,
|
|
||||||
options: [
|
options: [
|
||||||
null,
|
null,
|
||||||
...?distributors.value,
|
...?distributors.value,
|
||||||
|
|
@ -212,21 +225,32 @@ class SettingsSectionsController
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
if (await ref
|
final messenger = ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
if (!await ref
|
||||||
.read(
|
.read(
|
||||||
NotificationController
|
NotificationController
|
||||||
.provider
|
.provider
|
||||||
.notifier,
|
.notifier,
|
||||||
)
|
)
|
||||||
.requestPermissions()) {
|
.requestPermissions()) {
|
||||||
await ref
|
messenger.showSnackBar(
|
||||||
.read(
|
.new(
|
||||||
UnifiedPushController
|
content: Text(
|
||||||
.provider
|
"Notifications are disabled. Enable them for Nexus in your system settings.",
|
||||||
.notifier,
|
),
|
||||||
)
|
),
|
||||||
.register(value);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
await ref
|
||||||
|
.read(
|
||||||
|
UnifiedPushController
|
||||||
|
.provider
|
||||||
|
.notifier,
|
||||||
|
)
|
||||||
|
.register(value);
|
||||||
} else {
|
} else {
|
||||||
await ref
|
await ref
|
||||||
.read(
|
.read(
|
||||||
|
|
@ -268,7 +292,7 @@ class SettingsSectionsController
|
||||||
icon: Icon(icon),
|
icon: Icon(icon),
|
||||||
title: title,
|
title: title,
|
||||||
subtitle: Text(description),
|
subtitle: Text(description),
|
||||||
initialValue: ref
|
value: ref
|
||||||
.watch(AccountDataController.provider)
|
.watch(AccountDataController.provider)
|
||||||
.invitePermissionConfig
|
.invitePermissionConfig
|
||||||
.defaultAction,
|
.defaultAction,
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@ 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/client_state.dart";
|
import "package:nexus/controllers/gomuks_listener.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";
|
||||||
|
|
@ -74,47 +72,67 @@ 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 pushResponse = await ref
|
final notification = 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 (pushResponse == null) return;
|
if (notification == null ||
|
||||||
|
|
||||||
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) == event.roomId)) {
|
ref.read(SelectedRoomController.provider) ==
|
||||||
|
notification.roomId)) {
|
||||||
if (isInBackground) exit(0);
|
if (isInBackground) exit(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final icon = roomMetadata.avatar == null
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
|
|
||||||
|
final icon = notification.roomAvatar == null
|
||||||
? null
|
? null
|
||||||
: await ref
|
: await client.downloadMedia(
|
||||||
.read(ClientController.provider.notifier)
|
.new(mxc: notification.roomAvatar!, isAvatar: true),
|
||||||
.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(
|
||||||
id: event.eventId.hashCode & 0x7fffffff,
|
// Android stacks messages into one conversation per room
|
||||||
title: roomMetadata.name ?? "New Event",
|
id:
|
||||||
|
(Platform.isAndroid
|
||||||
|
? notification.roomId
|
||||||
|
: notification.eventId)
|
||||||
|
.hashCode &
|
||||||
|
0x7fffffff,
|
||||||
|
title: notification.roomName,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
payload: event.rowId.toString(),
|
payload: notification.eventRowId.toString(),
|
||||||
body: switch (event.content) {
|
body: notification.text,
|
||||||
MessageContent(:final body?) ||
|
senderName: pmp?.name ?? notification.sender.name,
|
||||||
StickerContent(:final body) => body,
|
senderAvatar: senderAvatar,
|
||||||
_ => null,
|
selfName: notification.self.name,
|
||||||
},
|
isGroup: !notification.isDm,
|
||||||
|
sound: notification.sound,
|
||||||
|
timestamp: notification.timestamp,
|
||||||
|
threadId: notification.roomId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isInBackground) exit(0);
|
if (isInBackground) exit(0);
|
||||||
|
|
@ -122,9 +140,11 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
||||||
onUnregistered: deregister,
|
onUnregistered: deregister,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!registered) return null;
|
if (!registered || isInBackground) 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);
|
||||||
|
|
@ -143,19 +163,9 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
||||||
|
|
||||||
state = .loading();
|
state = .loading();
|
||||||
try {
|
try {
|
||||||
final deviceIdCompleter = Completer<String>();
|
final deviceId = await ref
|
||||||
final subscription = ref.listen(
|
.read(GomuksListenerController.provider.notifier)
|
||||||
ClientStateController.provider.select((value) => value?.deviceId),
|
.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
|
||||||
|
|
@ -201,12 +211,27 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deregister([String? instance]) async {
|
Future<bool> deregisterIfDisabled([String? notificationInstance]) async {
|
||||||
final clientState = ref.read(ClientStateController.provider);
|
if (await ref
|
||||||
|
.read(NotificationController.provider.notifier)
|
||||||
|
.permissionsGranted()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
final keyProvider = PushKeyController.provider(
|
final instance =
|
||||||
instance ?? clientState!.deviceId!,
|
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);
|
final key = await ref.read(keyProvider.future);
|
||||||
|
|
||||||
if (key != null) {
|
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);
|
state = .data(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/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";
|
||||||
|
|
@ -15,13 +19,19 @@ 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<Codec> _loadAsync(ImageDecoderCallback decode) async {
|
Future<ui.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 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
|
@override
|
||||||
|
|
@ -31,3 +41,37 @@ 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;
|
||||||
|
|
|
||||||
23
lib/models/events_decrypted.dart
Normal file
23
lib/models/events_decrypted.dart
Normal 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);
|
||||||
|
}
|
||||||
44
lib/models/push_notification.dart
Normal file
44
lib/models/push_notification.dart
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,12 @@ 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),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ 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(
|
||||||
|
|
@ -39,6 +40,9 @@ 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
|
||||||
|
|
@ -239,18 +243,21 @@ class const EventRenderer(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onSecondaryTapUp: contextMenuCallback,
|
onSecondaryTapUp: contextMenuCallback,
|
||||||
onLongPressStart: contextMenuCallback,
|
onLongPressStart: contextMenuCallback,
|
||||||
child: Padding(
|
child: SwipeToReply(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8)
|
onSwipe: onSwipe,
|
||||||
.copyWith(top: isGrouped ? 0 : 8),
|
Padding(
|
||||||
child: Wrap(
|
padding: EdgeInsets.symmetric(horizontal: 8)
|
||||||
verticalDirection: .up,
|
.copyWith(top: isGrouped ? 0 : 8),
|
||||||
spacing: 6,
|
child: Wrap(
|
||||||
crossAxisAlignment: .center,
|
verticalDirection: .up,
|
||||||
children: [
|
spacing: 6,
|
||||||
child,
|
crossAxisAlignment: .center,
|
||||||
if (child is! MessageRenderer)
|
children: [
|
||||||
Timestamp(event.timestamp),
|
child,
|
||||||
],
|
if (child is! MessageRenderer)
|
||||||
|
Timestamp(event.timestamp),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ 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";
|
||||||
|
|
||||||
|
|
@ -16,11 +17,12 @@ class const ResetEncryptionSheet({
|
||||||
}) extends HookConsumerWidget {
|
}) extends HookConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isReset = ref.watch(
|
final verificationState = ref.watch(
|
||||||
ClientStateController.provider.select(
|
ClientStateController.provider.select(
|
||||||
(state) => state?.verificationState.hasCrossSigning ?? false,
|
(state) => state?.verificationState,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
final isReset = verificationState?.hasCrossSigning ?? false;
|
||||||
|
|
||||||
final usePassphrase = useState(false);
|
final usePassphrase = useState(false);
|
||||||
final passphraseController = useTextEditingController();
|
final passphraseController = useTextEditingController();
|
||||||
|
|
@ -95,6 +97,13 @@ 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."
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ 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(
|
||||||
|
|
@ -26,6 +27,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ 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";
|
||||||
|
|
@ -171,19 +173,30 @@ 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: (rowId, type) {
|
onRelation: onRelation,
|
||||||
relatedEvent.value = rowId;
|
|
||||||
relationType.value = type;
|
|
||||||
composerNode.requestFocus();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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(
|
return Scaffold(
|
||||||
endDrawer: PinnedEventsDrawer(
|
endDrawer: PinnedEventsDrawer(
|
||||||
roomId,
|
roomId,
|
||||||
|
|
@ -224,6 +237,9 @@ 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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,53 +4,35 @@ 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? initialValue,
|
required final T? value,
|
||||||
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) => FormField(
|
Widget build(BuildContext context, WidgetRef ref) => ListTile(
|
||||||
validator: (value) =>
|
enabled: onChanged != null,
|
||||||
value == null && required == true ? "This field is required." : null,
|
onTap: () => context.showAdaptiveSheet(
|
||||||
initialValue: initialValue,
|
(context) => RadioSheet<T>(
|
||||||
builder: (field) => InputDecorator(
|
title: title,
|
||||||
decoration: InputDecoration(
|
getName: getName,
|
||||||
errorText: field.errorText,
|
onChanged: onChanged,
|
||||||
contentPadding: EdgeInsets.zero,
|
value: value,
|
||||||
enabledBorder: InputBorder.none,
|
options: options,
|
||||||
),
|
),
|
||||||
child: ListTile(
|
),
|
||||||
enabled: onChanged != null,
|
title: Text(title),
|
||||||
onTap: () => context.showAdaptiveSheet(
|
subtitle: subtitle,
|
||||||
(context) => RadioSheet<T>(
|
leading: icon,
|
||||||
title: title,
|
trailing: Chip(
|
||||||
getName: getName,
|
label: Text(
|
||||||
onChanged: onChanged == null
|
value == null ? "None" : getName(value as T),
|
||||||
? null
|
overflow: TextOverflow.ellipsis,
|
||||||
: (value) {
|
style: onChanged == null ? .new(color: Colors.grey) : null,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
58
lib/widgets/swipe_to_reply.dart
Normal file
58
lib/widgets/swipe_to_reply.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
linux/nix/pkg/avif_native.nix
Normal file
49
linux/nix/pkg/avif_native.nix
Normal 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
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
flutter.buildFlutterApplication {
|
flutter.buildFlutterApplication {
|
||||||
pname = "nexus";
|
pname = "nexus";
|
||||||
version = "1.0.0-alpha3";
|
version = "1.0.0-alpha4";
|
||||||
inherit src;
|
inherit src;
|
||||||
|
|
||||||
preBuild = ''
|
preBuild = ''
|
||||||
|
|
@ -34,6 +34,8 @@ 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=";
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ buildGoModule (
|
||||||
|
|
||||||
src = "${src}/gomuks";
|
src = "${src}/gomuks";
|
||||||
|
|
||||||
vendorHash = "sha256-+0iFK2ribhiNskXgPz7vslncuOgPMB/ZmFSfDOcz6ww=";
|
vendorHash = "sha256-nd0NEKXscgrFE/gBEW2aP+YeZNTOdbRa4QDLTRwIAZU=";
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,14 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -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-alpha3
|
version: 1.0.0-alpha4
|
||||||
publish_to: none
|
publish_to: none
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
|
|
@ -109,7 +109,7 @@ dependencies:
|
||||||
pasteboard: 0.5.0
|
pasteboard: 0.5.0
|
||||||
flutter_file_dialog: 3.3.3
|
flutter_file_dialog: 3.3.3
|
||||||
mime: 2.1.0
|
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,3 +133,8 @@ 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue